diff --git a/.gitignore b/.gitignore index 57250c70..f4cc4ca4 100644 --- a/.gitignore +++ b/.gitignore @@ -122,6 +122,10 @@ METRICS_DEADLOCK_FIX.md !README.md **/*.md !**/README.md +!extensions/dashboard/contract/*.md +!extensions/dashboard/contract/shell/*.md +!extensions/dashboard/contract/shell/test/ +!extensions/dashboard/contract/shell/test/** /**/*.disabled extensions/dashboard/forgeui *.blob 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) +} diff --git a/cmd/forge/plugins/dev.go b/cmd/forge/plugins/dev.go index b44018f9..9fd4726d 100644 --- a/cmd/forge/plugins/dev.go +++ b/cmd/forge/plugins/dev.go @@ -9,6 +9,7 @@ import ( "os/exec" "os/signal" "path/filepath" + "runtime" "strconv" "strings" "sync" @@ -641,6 +642,35 @@ func newAppWatcher(cfg *config.ForgeConfig, app *AppInfo) (*appWatcher, error) { // have been extracted to dev_shared.go as package-level functions shared with // dockerAppWatcher. +// buildApp compiles the user's main package into a stable per-app path inside +// the project's .forge/dev directory. We rebuild in place each time so the +// path stays predictable (helpful for debuggers and codesigning) and old +// artifacts don't accumulate in the OS temp dir. +func (aw *appWatcher) buildApp() (string, error) { + buildDir := filepath.Join(aw.config.RootDir, ".forge", "dev") + if err := os.MkdirAll(buildDir, 0o755); err != nil { + return "", fmt.Errorf("create build dir: %w", err) + } + + binName := aw.app.Name + if binName == "" { + binName = "app" + } + if runtime.GOOS == "windows" { + binName += ".exe" + } + binPath := filepath.Join(buildDir, binName) + + cmd := exec.Command("go", "build", "-tags", "forge_debug", "-o", binPath, aw.mainFile) + cmd.Dir = aw.config.RootDir + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + if err := cmd.Run(); err != nil { + return "", fmt.Errorf("go build: %w", err) + } + return binPath, nil +} + // Start starts the application process. func (aw *appWatcher) Start(ctx cli.CommandContext) error { aw.mu.Lock() @@ -671,9 +701,19 @@ func (aw *appWatcher) Start(ctx cli.CommandContext) error { ctx.Success(fmt.Sprintf("Starting %s...", aw.app.Name)) - // Create new command — compile with forge_debug tag so the in-process - // debug server is included; disabled automatically in release builds. - cmd := exec.Command("go", "run", "-tags", "forge_debug", aw.mainFile) + // Build the binary first, then exec it directly. We deliberately avoid + // `go run` here: it spawns the compiled binary as a grandchild, and on + // abrupt shutdown (kill -9 of forge dev, parent shell exit) those + // grandchildren get reparented to PID 1 and survive with their dispatch + // loops still hammering the database. Building once and exec'ing the + // binary directly means there's exactly one process to track and + // killProcessGroup reliably reaches it. + binPath, err := aw.buildApp() + if err != nil { + return fmt.Errorf("build failed: %w", err) + } + + cmd := exec.Command(binPath) cmd.Dir = aw.config.RootDir cmd.Stdout = os.Stdout cmd.Stderr = os.Stderr @@ -1000,11 +1040,15 @@ func (p *DevPlugin) startContributorDevServers(ctx cli.CommandContext) []*contri devCmd = adapter.DefaultDevCmd() } - // Start the dev server + // Start the dev server. Put the shell in its own process group so + // killProcessGroup can take down the whole tree (npm/node/vite/...) + // instead of just the sh wrapper, which would otherwise leave the + // real dev server orphaned on rebuild. cmd := exec.Command("sh", "-c", devCmd) cmd.Dir = uiDir cmd.Stdout = os.Stdout cmd.Stderr = os.Stderr + setupProcessGroup(cmd) if err := cmd.Start(); err != nil { ctx.Warning(fmt.Sprintf("Failed to start contributor dev server %s: %v", cfg.Name, err)) @@ -1024,9 +1068,13 @@ func (p *DevPlugin) startContributorDevServers(ctx cli.CommandContext) []*contri // stopContributorDevServers stops all running contributor dev server processes. func stopContributorDevServers(processes []*contributorDevProcess) { for _, proc := range processes { - if proc.Cmd != nil && proc.Cmd.Process != nil { - proc.Cmd.Process.Kill() - proc.Cmd.Process.Wait() + if proc.Cmd == nil || proc.Cmd.Process == nil { + continue } + // killProcessGroup tears down the whole sh→npm→node tree. A bare + // Process.Kill would only stop the sh wrapper, leaving npm/node + // running and holding the dev port. + killProcessGroup(proc.Cmd) + _, _ = proc.Cmd.Process.Wait() } } diff --git a/extensions/dashboard/auth/context.go b/extensions/dashboard/auth/context.go index ad02effd..55e54cfd 100644 --- a/extensions/dashboard/auth/context.go +++ b/extensions/dashboard/auth/context.go @@ -1,6 +1,9 @@ package dashauth -import "context" +import ( + "context" + "net/http" +) type contextKey string @@ -47,3 +50,37 @@ func HasTenantInContext(ctx context.Context) bool { return t.HasTenant() } + +const ( + respWriterContextKey contextKey = "forge:dashboard:resp" + requestContextKey contextKey = "forge:dashboard:req" +) + +// WithHTTP stashes the live ResponseWriter and Request on ctx so contract +// command handlers that legitimately need to touch HTTP — e.g. the auth +// extension's auth.login handler that issues a Set-Cookie — can reach them. +// The contract transport calls this before dispatching commands. +// +// Most contract handlers are pure data and should NOT pull these out; +// reaching for the response writer is an escape hatch for the small set of +// concerns where the cookie/header IS the contract (auth, downloads). +func WithHTTP(ctx context.Context, w http.ResponseWriter, r *http.Request) context.Context { + ctx = context.WithValue(ctx, respWriterContextKey, w) + ctx = context.WithValue(ctx, requestContextKey, r) + return ctx +} + +// ResponseWriterFromContext returns the ResponseWriter previously stashed by +// WithHTTP, or nil when called outside an HTTP-served dispatch (background +// jobs, tests). +func ResponseWriterFromContext(ctx context.Context) http.ResponseWriter { + w, _ := ctx.Value(respWriterContextKey).(http.ResponseWriter) + return w +} + +// RequestFromContext returns the Request previously stashed by WithHTTP, or +// nil outside an HTTP-served dispatch. +func RequestFromContext(ctx context.Context) *http.Request { + r, _ := ctx.Value(requestContextKey).(*http.Request) + return r +} diff --git a/extensions/dashboard/aware.go b/extensions/dashboard/aware.go index 0da3e07d..71d55ef8 100644 --- a/extensions/dashboard/aware.go +++ b/extensions/dashboard/aware.go @@ -1,6 +1,8 @@ package dashboard import ( + "github.com/xraph/forge/extensions/dashboard/contract" + "github.com/xraph/forge/extensions/dashboard/contract/dispatcher" "github.com/xraph/forge/extensions/dashboard/contributor" "github.com/xraph/forge/extensions/dashboard/ui/shell" "github.com/xraph/forgeui/bridge" @@ -52,6 +54,43 @@ type BridgeAware interface { // ext.SetAuthChecker(myAuthChecker) // ext.EnableAuth() // } +// +// Slice (l) note — wiring an auth extension into the contract React shell: +// +// The dashboard shell ships a built-in LoginScreen that submits a contract +// command (default `auth.login`) and reloads the principal on success. Auth +// extensions can plug in by implementing both DashboardAuthAware *and* +// ContractContributorAware: +// +// - DashboardAuthAware.RegisterDashboardAuth wires the AuthChecker so +// /api/dashboard/v1/principal returns the current user. The shell's +// AuthGate listens for the 401 envelope (auth required) vs the 200 +// `{authenticated:false}` envelope (auth disabled) and renders the +// LoginScreen only in the former case. +// - ContractContributorAware.RegisterContractContributor registers the +// `auth.login` command intent (and optionally `auth.logout`) on the +// dispatcher. The built-in LoginScreen issues the command; an +// extension that wants a richer flow can also publish a `/login` +// graph route in its manifest, and the shell's AuthGate will render +// that page instead of the built-in form. +// +// Example combined integration sketch: +// +// func (a *AuthsomeExtension) RegisterDashboardAuth(ext *dashboard.Extension) { +// ext.SetAuthChecker(a.checker) +// ext.EnableAuth() +// } +// func (a *AuthsomeExtension) RegisterContractContributor( +// disp *dispatcher.Dispatcher, +// reg contract.Registry, +// wreg contract.WardenRegistry, +// ) error { +// return authsomecontract.Register(disp, reg, wreg, authsomecontract.Deps{ +// Sessions: a.sessions, +// // Registers `auth.login` (command) and optionally a `/login` +// // graph route that overrides the built-in LoginScreen. +// }) +// } type DashboardAuthAware interface { RegisterDashboardAuth(ext *Extension) } @@ -72,3 +111,35 @@ type DashboardAuthAware interface { type DashboardFooterContributor interface { DashboardUserDropdownActions(basePath string) []shell.UserDropdownAction } + +// ContractContributorAware is an optional interface that Forge extensions can +// implement to register a contract-based dashboard contributor (the slice (f)+ +// shape — declarative YAML manifest + typed dispatcher handlers). The +// dashboard auto-discovers extensions implementing this interface during +// Start() and calls RegisterContractContributor with the dashboard's contract +// registry, warden registry, and dispatcher. +// +// Coexists with DashboardAware: an extension can implement both to register +// its legacy templ-based contributor AND its contract contributor during the +// migration window. New extensions should prefer this interface; legacy ones +// migrate over time per the per-slice (f, g, h) plan. +// +// Example implementation: +// +// func (e *StreamingExtension) RegisterContractContributor( +// disp *dispatcher.Dispatcher, +// reg contract.Registry, +// wreg contract.WardenRegistry, +// ) error { +// return streamingcontract.Register(disp, reg, wreg, streamingcontract.Deps{ +// Manager: func() streaming.Manager { return e.manager }, +// Config: func() streaming.Config { return e.config }, +// }) +// } +type ContractContributorAware interface { + RegisterContractContributor( + disp *dispatcher.Dispatcher, + reg contract.Registry, + wreg contract.WardenRegistry, + ) error +} diff --git a/extensions/dashboard/config.go b/extensions/dashboard/config.go index abd3ba2e..7776009c 100644 --- a/extensions/dashboard/config.go +++ b/extensions/dashboard/config.go @@ -53,12 +53,22 @@ 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 LoginPath string `json:"login_path" yaml:"login_path"` // relative auth login path (e.g. "/auth/login") LogoutPath string `json:"logout_path" yaml:"logout_path"` // relative auth logout path (e.g. "/auth/logout") DefaultAccess string `json:"default_access" yaml:"default_access"` // "public", "protected", "partial" + // RequiredRoles, when non-empty, restricts dashboard access to users + // carrying at least one matching role. The principal endpoint surfaces + // 403 PERMISSION_DENIED to the React shell for users who don't qualify; + // the shell renders an "access denied" panel instead of the dashboard. + RequiredRoles []string `json:"required_roles" yaml:"required_roles"` // Theming Theme string `json:"theme" yaml:"theme"` // light, dark, auto @@ -101,8 +111,9 @@ func DefaultConfig() Config { SSEKeepAlive: 15 * time.Second, - EnableCSP: true, - EnableCSRF: true, + EnableCSP: true, + EnableCSRF: true, + EnableContractSecurity: true, EnableAuth: false, LoginPath: "/login", @@ -255,6 +266,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 } @@ -285,6 +304,15 @@ func WithEnableAuth(enabled bool) ConfigOption { return func(c *Config) { c.EnableAuth = enabled } } +// WithRequiredRoles restricts dashboard access to users carrying at least +// one of the given roles. Pass nil/empty to allow all authenticated users. +// Auth extensions (e.g. authsome) call this via Extension.SetRequiredRoles +// when their config declares a role gate; deployments can also configure +// it directly via this option. +func WithRequiredRoles(roles []string) ConfigOption { + return func(c *Config) { c.RequiredRoles = append([]string(nil), roles...) } +} + // WithLoginPath sets the relative login page path (e.g. "/auth/login"). func WithLoginPath(path string) ConfigOption { return func(c *Config) { c.LoginPath = path } 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. 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. 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. 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. 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. 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). 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. diff --git a/extensions/dashboard/contract/SLICE_E_DESIGN.md b/extensions/dashboard/contract/SLICE_E_DESIGN.md new file mode 100644 index 00000000..9cb63481 --- /dev/null +++ b/extensions/dashboard/contract/SLICE_E_DESIGN.md @@ -0,0 +1,148 @@ +# 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** (Base UI primitives + Tailwind), giving the dashboard accessible, themed, polished UI without bespoke styling work. + +> **Slice (e.5) note:** the original draft of this slice wired shadcn's Radix variant; the implementation was swapped to shadcn's **Base UI** (`@base-ui-components/react`) variant per a later directive. Public component imports (`@/components/ui/*`) and the v1 vocabulary are unchanged; only the primitive layer underneath shifted. + +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 primitive deps — 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. diff --git a/extensions/dashboard/contract/SLICE_F_DESIGN.md b/extensions/dashboard/contract/SLICE_F_DESIGN.md new file mode 100644 index 00000000..ad1262e6 --- /dev/null +++ b/extensions/dashboard/contract/SLICE_F_DESIGN.md @@ -0,0 +1,150 @@ +# Slice (f) — Streaming Extension Migration + +> Companion design doc to [DESIGN.md](DESIGN.md). Slice (f) is the first real external contributor migrating from the legacy templ-based contributor system to the new contract YAML. + +## Context + +Slices (a)–(e.5) shipped the contract package, dispatcher, security stack, pilot contributor, React shell, the v1 vocabulary on shadcn/Base UI, and the docs. The streaming extension is the only **external** contributor today. Its dashboard surface (6 routes, 4 widgets, 1 settings page, 5 mutations) is implemented as a templ-rendering `LocalContributor` at [extensions/streaming/dashboard/](../../streaming/dashboard/). + +Slice (f) ports that surface to the new contract: a YAML manifest declaring the routes + intents, typed dispatcher handlers calling the existing `Manager` interface, and a small wire-up in `extensions/streaming/extension.go` to register both the legacy and the contract paths during the migration window. + +The legacy `/dashboard/ext/streaming/*` URLs keep working (templ paths stay registered). The new path is `/dashboard/contract/streaming-contract/*`. Once stakeholders are happy, a follow-on slice will retire the templ paths globally (across `streaming-contract` and `core-contract` and CoreContributor) — that retirement is **explicitly out of scope here**. + +## Architecture Decisions (locked in) + +| Decision | Choice | Rationale | +|---|---|---| +| Contributor name | `streaming-contract` | Matches the slice (c) pilot pattern (`core-contract`). Name is namespaced so legacy `streaming` keeps coexisting. | +| Sub-package layout | `extensions/streaming/contract/` | Mirrors the pilot at `extensions/dashboard/contract/pilot/`. Keeps migration code adjacent to the manager it calls without polluting the main streaming package. | +| Handlers | All five Mutations as `command` kind; all reads as `query` kind. No subscriptions in v1 (today's contributor polls). | Matches today's behavior. Subscriptions are a later optimization once we measure traffic. | +| Settings page | Read-only `dashboard.grid` of config fields | Today's settings page is read-only; preserve the contract. Mutable config is future work. | +| Presence page | `resource.list` over a flattened presence-per-user query | Today's templ does the per-user iteration server-side; we move that into a single query handler. | +| Playground page | Five `form.edit` cards in a `dashboard.grid` | Each form binds an `op` (the command intent). Submission shows the response (success or error) inline. | +| Detail navigation | `resource.list` + `detailDrawer` slot for `/rooms`. No separate `/rooms/{id}` route in the YAML | Detail-drawer is the v1 admin pattern shadcn provides; deep-linkable detail routes are a follow-on. | +| Registration | Streaming extension registers via the `dashboard.ContractContributorAware` interface (new this slice — small) | Keeps the streaming code unaware of the dashboard's contract registry shape; the dashboard discovers contract contributors at startup the same way it discovers legacy contributors. | +| Tests | 1 unit test per handler + 1 e2e test for the wired-up registration | Same TDD discipline as the pilot. | + +## Scope + +### In scope (this slice) +- `extensions/streaming/contract/` sub-package with: + - `manifest.yaml` declaring 6 routes + ~14 intents. + - `types.go` for the wire shapes the handlers return (mostly `*internal.ManagerStats` etc. directly). + - `handlers/` files grouped by topic: `stats.go`, `connections.go`, `rooms.go`, `channels.go`, `presence.go`, `playground.go`, `config.go`. + - `streaming.go` — `Register(disp, deps) error` entry point that loads the YAML, registers all handlers, and returns. +- A `dashboard.ContractContributorAware` interface in `extensions/dashboard/contributor/` so the dashboard can discover contributors at startup. +- Wire-up in `extensions/streaming/extension.go` so the streaming extension implements `ContractContributorAware` and gets registered automatically. +- Wire-up in `extensions/dashboard/extension.go` to discover and register external contract contributors during dashboard startup. +- Unit tests for each handler. +- E2E test that exercises the registered streaming-contract through the contract HTTP handler. + +### Out of scope (future slices) +- **Slice (g)**: retire the legacy `/dashboard/ext/streaming/*` templ paths once the team has flipped to the contract path in production. +- **Slice (h)**: retire `CoreContributor`'s templ pages (Overview/Health/Metrics/Services/Traces/Extensions). Bigger because every dashboard deployment depends on them. +- Subscriptions for live updates (today's contributor polls; sub-streams are a future optimization). +- Mutable settings (form.edit for config). Today's settings page is read-only and we preserve that. +- Deep-linked `/rooms/{id}` route (detailDrawer covers the immediate use case). + +## Vocabulary mapping + +| Legacy route | Contract route | Intent tree | +|---|---|---| +| `/` | `/streaming-contract/` | `page.shell` → `dashboard.grid` (cols=4) → 7× `metric.counter` (one per ManagerStats field) | +| `/connections` | `/streaming-contract/connections` | `page.shell` → `resource.list` (data: connections.list, columns: connID, userID, rooms, subs, lastActivity, status) | +| `/rooms` | `/streaming-contract/rooms` | `page.shell` → `resource.list` (data: rooms.list, columns: id, name, members, created, private, archived) + `detailDrawer` slot → `resource.detail` (data: rooms.detail using parent.id) + a row-level admin section showing `rooms.members` and `rooms.moderation` | +| `/rooms/{id}` | (covered by detailDrawer) | — | +| `/channels` | `/streaming-contract/channels` | `page.shell` → `resource.list` (data: channels.list, columns: channelID, name, subCount, messageCount, created) | +| `/presence` | `/streaming-contract/presence` | `page.shell` → `resource.list` (data: presence.list, columns: userID, status, lastSeen, rooms) | +| `/playground` | `/streaming-contract/playground` | `page.shell` → `dashboard.grid` (cols=2) → 5× `form.edit` (one per mutation: create-room, delete-room, send-message, set-presence, kick-connection) | +| `/settings` | (settings descriptor unchanged; settings page handled by dashboard's existing settings registry) | The `streaming.config` query intent backs a read-only display; can be embedded in any page later | + +## Intent declarations (YAML preview) + +```yaml +schemaVersion: 1 +contributor: + name: streaming-contract + envelope: { supports: [v1], preferred: v1 } + capabilities: [streaming.read, streaming.write] + +intents: + # Reads + - { name: stats, kind: query, version: 1, capability: read } + - { name: connections.list, kind: query, version: 1, capability: read } + - { name: rooms.list, kind: query, version: 1, capability: read } + - { name: rooms.detail, kind: query, version: 1, capability: read } + - { name: rooms.members, kind: query, version: 1, capability: read } + - { name: rooms.moderation, kind: query, version: 1, capability: read } + - { name: channels.list, kind: query, version: 1, capability: read } + - { name: presence.list, kind: query, version: 1, capability: read } + - { name: config, kind: query, version: 1, capability: read } + + # Mutations + - { name: rooms.create, kind: command, version: 1, capability: write, requires: { all: [scope:streaming.write] } } + - { name: rooms.delete, kind: command, version: 1, capability: write, requires: { all: [scope:streaming.write] } } + - { name: rooms.send-message, kind: command, version: 1, capability: write, requires: { all: [scope:streaming.write] } } + - { name: presence.set, kind: command, version: 1, capability: write, requires: { all: [scope:streaming.write] } } + - { name: connections.kick, kind: command, version: 1, capability: write, requires: { all: [scope:streaming.write], any: [role:admin, role:moderator] } } +``` + +Routes follow under the `graph:` key; full YAML lives at `extensions/streaming/contract/manifest.yaml`. + +## Files Affected + +### New (this slice) + +``` +extensions/streaming/contract/ + doc.go + manifest.yaml + streaming.go # Register(disp, deps) entry point + deps.go # Deps struct {Manager, Config} + types.go # response payloads (ConnectionsList, RoomsList, ...) + stats.go # statsHandler + connections.go # connectionsListHandler, kickConnectionHandler + rooms.go # roomsListHandler, roomDetailHandler, membersHandler, moderationHandler, createRoomHandler, deleteRoomHandler, sendMessageHandler + channels.go # channelsListHandler + presence.go # presenceListHandler, setPresenceHandler + config.go # configHandler + *_test.go # one test file per topic group + e2e_test.go # full HTTP round-trip through the contract handler +``` + +### Modified + +- `extensions/streaming/extension.go` — implement `dashboard.ContractContributorAware` (returns a `pilot.Register`-style function that the dashboard calls during startup). +- `extensions/dashboard/extension.go` — during contributor discovery, also call `ContractContributorAware`-implementing extensions to register their contract handlers + manifest. Same loop that today calls `DashboardAware.DashboardContributor()` gets a sibling branch for `ContractContributorAware`. +- `extensions/dashboard/contributor/types.go` (or similar) — declare the `ContractContributorAware` interface. + +### Reused (do not duplicate) + +- `extensions/streaming/internal.Manager` — every read and write goes through this existing interface. +- `extensions/dashboard/contract/dispatcher.RegisterQuery / RegisterCommand` — the typed wrappers. +- `extensions/dashboard/contract/loader.Load + Validate` — the same YAML loader the pilot uses. +- `pilot.Deps` style adapter pattern — the same shape, scoped to streaming's data sources. + +## Verification + +1. **Unit tests** under `contract/*_test.go`: + - Each handler called with a fixture Manager produces the expected payload. + - Mutation handlers return `&contract.Error{Code: CodeNotFound}` / `CodeBadRequest` for the right error paths. + - The presence-list handler correctly aggregates per-connection presence calls. + +2. **E2E test** (`contract/e2e_test.go`): + - Stand up dispatcher + contract registry + streaming.Register in-process. + - POST `kind=query` for each intent against the contract HTTP handler; assert envelope shape. + - POST `kind=command` for `rooms.create`; assert idempotency-key persistence works (returns the same response on a duplicate). + +3. **Go tests + vet** — entire repo green: `go test ./...`, `go vet ./...`. + +4. **Shell verification** (manual, not automated): + - Run dashboard + streaming, browse to `/dashboard/contract/app/streaming-contract/rooms`. + - Verify the rooms page renders, click a row, see the detail drawer, click "Create Room" in the playground page, see a success result. + +## Out of Scope — Future Slices + +- **(g)** Retire `/dashboard/ext/streaming/*` once the team has cut over. +- **(h)** Migrate `CoreContributor` pages and retire the dashboard's own templ files (~2,266 LOC). +- **(i)** Add subscriptions to streaming-contract for real-time stats / room activity. +- **(j)** Add deep-linked detail routes (e.g., `/rooms/`) once the React shell supports `dataKey` URL parameters. +- **(k)** Mutable settings page using `form.edit`. diff --git a/extensions/dashboard/contract/SLICE_H_DESIGN.md b/extensions/dashboard/contract/SLICE_H_DESIGN.md new file mode 100644 index 00000000..27d91a0f --- /dev/null +++ b/extensions/dashboard/contract/SLICE_H_DESIGN.md @@ -0,0 +1,95 @@ +# Slice (h) — CoreContributor Migration + +> Companion design doc to [DESIGN.md](DESIGN.md). Slice (h) extends the `core-contract` pilot from slice (c) to cover every page the legacy `CoreContributor` serves today, so the dashboard's own pages run on the contract path end-to-end. + +## Context + +The pilot in slice (c) shipped three intents (`extensions.list`, `services.list`/`services.detail`, `metrics.summary` subscription) backed by three routes (`/extensions`, `/services`, `/metrics/live`). Slice (e+) added the React vocabulary that renders them on shadcn/Base UI. Slice (f) migrated the streaming extension as the first external contributor. + +Slice (h) closes the loop on the dashboard's own pages: Overview, Health, Metrics report, Traces, plus tightening Extensions and Services. After this slice lands, every page `CoreContributor` serves via templ has a contract equivalent. The legacy templ paths keep working in parallel; slice (i) is the retirement step. + +## Architecture Decisions (locked in) + +| Decision | Choice | +|---|---| +| Contributor name | Stays `core-contract` (extends the existing pilot rather than creating a new contributor). | +| Sub-package layout | Keep everything under `extensions/dashboard/contract/pilot/`. The "pilot" name is now historical; this slice promotes it to the canonical CoreContributor replacement. | +| New intents | `overview`, `health`, `metrics-report`, `traces.list`, `traces.detail`. All `query` kind, capability `read`. | +| Reused intents | `extensions.list`, `services.list`, `services.detail`, `metrics.summary` (subscription) — unchanged. | +| New routes | `/`, `/health`, `/metrics`, `/traces` added to the manifest. Existing `/extensions`, `/services`, `/metrics/live` unchanged. | +| Trace detail | `resource.list` + `detailDrawer` slot — same admin-tool pattern used for services and rooms. Deep-linked `/traces/{id}` deferred to slice (j). | +| Overview page | `dashboard.grid` of `metric.counter` cards backed by the `overview` query. Mirrors today's templ overview. | +| Health page | `resource.list` of services with status, message, duration columns. Empty state when no services registered. | +| Metrics page | Two-column `dashboard.grid`: left a `resource.list` of collectors, right a `resource.list` of top metrics. Metrics report data flows through one `metrics-report` intent. | +| Settings descriptor | Unchanged — settings are wired through the dashboard's existing settings registry, not the contract. | + +## Scope + +### In scope (this slice) +- Five new query handlers in `extensions/dashboard/contract/pilot/`: + - `overview.go` — overall health summary + service counts. + - `health.go` — health check results per service. + - `metrics_report.go` — collectors + top metrics. + - `traces.go` — `traces.list` + `traces.detail`. +- `pilot.Deps` gains a `Traces TracesProvider` field for the trace store. +- Manifest YAML extended with the four new routes plus their graph trees. +- Unit test per new handler (5 new tests). +- Integration test extension covering one of the new routes end-to-end. + +### Out of scope (future slices) +- **(i)** Retire the legacy templ contributor (`extensions/dashboard/core_contributor.go` + the `extensions/dashboard/ui/*.templ` files) once the contract path is validated in production. +- **(j)** Deep-linked detail routes (`/traces/{id}`, `/services/{name}`). +- **(k)** Mutable settings via `form.edit`. +- Subscription intents for live overview / health (today's pages poll; subscriptions are an optimization). + +## Vocabulary mapping + +| Legacy page | New route | Intent tree | +|---|---|---| +| Overview (`/`) | `/` | `page.shell` → `dashboard.grid` (cols=4) → 6× `metric.counter` (overall health badge, services, healthy services, metrics count, uptime, version) | +| Health (`/health`) | `/health` | `page.shell` → `resource.list` (data: health) with columns: name, status, message, duration, critical | +| Metrics (`/metrics`) | `/metrics` | `page.shell` → `dashboard.grid` (cols=2) → two `resource.list`s (collectors and top metrics) reading from one `metrics-report` query | +| Traces (`/traces`) | `/traces` | `page.shell` → `resource.list` (data: traces.list) + `detailDrawer` → `resource.detail` (data: traces.detail using parent.id) | +| Services (`/services`) | unchanged | already present from slice (c) | +| Extensions (`/extensions`) | unchanged | already present from slice (c) | +| Live Metrics (`/metrics/live`) | unchanged | already present from slice (c) | + +## Files Affected + +### New +``` +extensions/dashboard/contract/pilot/ + overview.go + overview_test.go + health.go + health_test.go + metrics_report.go + metrics_report_test.go + traces.go + traces_test.go +extensions/dashboard/contract/SLICE_H_DESIGN.md +``` + +### Modified +- `extensions/dashboard/contract/pilot/manifest.yaml` — add the four new routes + graph trees + new intent declarations. +- `extensions/dashboard/contract/pilot/pilot.go` — register the new query handlers and wire `Deps.Traces`. +- `extensions/dashboard/contract/pilot/types.go` — add wire shapes (`OverviewResponse`, `HealthList`, `MetricsReportResponse`, `TraceSummary`, `TraceDetailResponse`). +- `extensions/dashboard/extension.go` — pass the existing `e.traceStore` into `pilot.Deps.Traces`. + +### Reused (do not duplicate) +- `collector.DataCollector.CollectOverview / CollectHealth / CollectMetricsReport` +- `collector.TraceStore.ListTraces / GetTrace` +- All slice (e) React vocabulary intents (page.shell, resource.list, resource.detail, dashboard.grid, metric.counter) + +## Verification + +- 5 new unit tests under pilot/ — table-driven, fixture collectors stubbing the data calls. +- Existing 16 pilot tests stay green. +- `go build ./...` clean across the forge module. +- Manual: browse to `/dashboard/contract/app/`, `/health`, `/metrics`, `/traces`. Verify each renders, traces detail drawer opens. + +## After this slice + +The contract has full surface coverage of CoreContributor. Slice (i) retires the templ path: +- Remove `extensions/dashboard/core_contributor.go` +- Remove `extensions/dashboard/ui/*.templ` (~2,266 LOC) +- Remove HTML-fragment proxying in `RemoteContributor` (`Fetch/Post Page/Widget/Settings`) +- Remove the legacy `LocalContributor` rendering interface + +Slice (i) is delete-only — nothing new to design. diff --git a/extensions/dashboard/contract/SLICE_I_DESIGN.md b/extensions/dashboard/contract/SLICE_I_DESIGN.md new file mode 100644 index 00000000..30a1a471 --- /dev/null +++ b/extensions/dashboard/contract/SLICE_I_DESIGN.md @@ -0,0 +1,91 @@ +# Slice (i) — Retire CoreContributor templ pages + +**Status:** Active +**Branch:** `dashboard-contract-slice-a` +**Depends on:** slice (h) — `core-contract` pilot covers Overview/Health/Metrics/Traces/Extensions/Services +**Predecessors:** (a) contract foundation, (b) security+observability, (c) dispatcher+pilot, (d) React shell, (e) shadcn/Base UI vocabulary, (f) streaming contract, (h) CoreContributor migration + +## Why now + +Slice (h) shipped the `core-contract` pilot covering every page CoreContributor served: +`/`, `/health`, `/metrics`, `/traces`, `/extensions`, `/services`. The React shell at +`/dashboard/contract/app/*` renders all of them via the contract envelope. The legacy +templ pages now exist purely as a parallel path with no unique data. + +Two-system coexistence was the right move during slices (b)–(h). Now it's overhead: +duplicate routes, ~2,000 LOC of templ that has to keep compiling, and an active +CoreContributor that hits the same data sources twice. + +## Scope + +**In scope — remove:** +- `extensions/dashboard/core_contributor.go` (CoreContributor type + manifest + RenderPage/Widget/Settings impls) +- The `NewCoreContributor` registration in `extension.go::Register` +- Ten CoreContributor-only templ pages and their `_templ.go` artifacts: + - `overview.templ`, `health.templ`, `metrics.templ`, `metrics_all.templ`, + `metrics_collector_detail.templ`, `metrics_detail.templ`, `services.templ`, + `extensions.templ`, `traces.templ`, `trace_detail.templ` +- The matching `*_helpers.go` files that exist solely to feed those pages: + - `overview_helpers.go`, `health_helpers.go`, `metrics_helpers.go`, + `metrics_detail_helpers.go`, `services_helpers.go`, `traces_helpers.go`, + `extensions_helpers.go`, `chart_helpers.go` +- The ten templ-rendering page methods on `PagesManager` in `pages/pages.go`: + `OverviewPage`, `HealthPage`, `MetricsPage`, `MetricsAllPage`, + `MetricsCollectorDetailPage`, `MetricsDetailPage`, `ServicesPage`, + `ExtensionsPage`, `TracesPage`, `TraceDetailPage` + +**In scope — replace with redirects:** +- `pages.go::RegisterPages` registers the same ten routes as native HTTP 302 handlers + on the underlying `forge.Router` (not as `forgeui.Page` handlers — the redirect must + beat ForgeUI's catch-all). Each redirect points to the equivalent React shell route: + + | Old templ path | Redirect target | + | --- | --- | + | `/dashboard/` | `/dashboard/contract/app/` | + | `/dashboard/health` | `/dashboard/contract/app/health` | + | `/dashboard/metrics` | `/dashboard/contract/app/metrics` | + | `/dashboard/metrics/all` | `/dashboard/contract/app/metrics` | + | `/dashboard/metrics/collectors/:name` | `/dashboard/contract/app/metrics` | + | `/dashboard/metrics/detail/*name` | `/dashboard/contract/app/metrics` | + | `/dashboard/services` | `/dashboard/contract/app/services` | + | `/dashboard/extensions` | `/dashboard/contract/app/extensions` | + | `/dashboard/traces` | `/dashboard/contract/app/traces` | + | `/dashboard/traces/:id` | `/dashboard/contract/app/traces?id=:id` | + + `metrics/all`, `/metrics/collectors/:name`, `/metrics/detail/*name` collapse into the + single `/metrics` shell route — slice (j) will add deep-linked detail routes when we + rebuild those pages on the React side. + +**Out of scope — keep:** +- `ui/shell/*.templ` — sidebar/topbar/breadcrumbs/scripts still feed extension contributors (auth, settings) through `LayoutManager`. +- `layouts/*.templ` — layout chrome for extension contributors. +- `ui/components.templ`, `ui/widgets.templ`, `ui/metrics.templ`, `ui/tables.templ` — shared utility templ widgets used by remaining contributors. +- `ui/pages/error.templ` + `error_templ.go` — `pages.go` still uses `uipages.ErrorPage` for non-CoreContributor error responses (extension page lookups, settings unavailable). +- `pages.go` settings + extension page handlers — extensions still render templ. +- `core-contract` (the contributor name on the contract pilot) — this is the slice-(c)/h thing, *not* the legacy CoreContributor. Different system. Keeps its `core-contract` name. + +## Non-goals + +- Migrating extension contributors (auth, settings sub-pages) off templ. That's a separate stream — extension authors own their UI choice. +- Adding `/metrics/collectors/:name` or `/traces/:id` deep links on the React side. Folded in to slice (j) when we rebuild those pages with proper detail routes. +- Removing `templ` as a Go dependency. Extension contributors still use it. + +## Verification + +- `go build ./...` clean +- `go test ./extensions/dashboard/...` all green (registry tests use `"core"` as a stub name in `registry_test.go` — those don't touch the deleted `core_contributor.go`, just the string `"core"`. They keep passing.) +- Manual smoke: `curl -sIL http://localhost:8080/dashboard/health` returns 302 → `/dashboard/contract/app/health`; the React shell loads at the new URL. +- LOC delta: removes ~2,000 lines of templ + helpers. + +## Risks / mitigations + +- **Bookmarks.** Old links to `/dashboard/health` etc. still work via 302 — no broken bookmarks. +- **CLI/API tooling that scrapes templ HTML.** Anything in that bucket already had to stop in slice (h) when the React shell appeared. If we missed a tool, the 302 is detectable. +- **Extension contributor pages that depended on `pages.go` templ helpers re-exported.** Cross-check: `extensions_helpers.go::IsCore` references `name == "core"` for badge rendering. With CoreContributor gone, the registry has no `"core"` entry, so the bool is always false — harmless. But that file gets deleted as part of this slice anyway. +- **Test breakage in `contributor/registry_test.go`.** Those tests build their own `newStub("core", …)` instances; they don't reference `core_contributor.go` or any deleted templ. No change required. + +## Out of this slice (j) follow-on + +- Deep-link routes on the React side for trace/metric detail (`/contract/app/traces/:id`, `/contract/app/metrics/:name`). +- Mutable settings via the contract write path (slice (k)). +- Replace the in-memory idempotency store with Redis when running multi-replica. diff --git a/extensions/dashboard/contract/SLICE_J_DESIGN.md b/extensions/dashboard/contract/SLICE_J_DESIGN.md new file mode 100644 index 00000000..8fb11b70 --- /dev/null +++ b/extensions/dashboard/contract/SLICE_J_DESIGN.md @@ -0,0 +1,141 @@ +# Slice (j) — Wire the graph endpoint, add deep-link detail routes + +**Status:** Active +**Branch:** `dashboard-contract-slice-a` +**Predecessors:** (a)–(i) + +## What this slice fixes + +Slice (j) addresses two problems uncovered while planning detail routes: + +1. **The graph endpoint never actually returned graphs.** `POST /api/dashboard/v1` with `kind: "graph", intent: "page.shell"` 404s with `intent page.shell not registered`. The transport handler's intent-table lookup runs for every kind, but `page.shell` is a vocabulary marker for slot validation — never declared in `intents:` and never registered with the dispatcher. The React shell's smoke tests passed because they mock the response. Production had no working graph fetch. + +2. **No deep-link routes for detail pages.** Slice (i) collapsed `/dashboard/traces/:id` to `/contract/app/traces?id=…` because the React shell only had a `*` catch-all route and the manifest only had list routes. The contract manifest already speaks `:id`-style placeholders in extension targets, but `MergedGraph` matches routes with literal string equality. + +Both gaps are interlinked: deep-link routes need the graph endpoint to actually work, and the graph endpoint needs route-param matching to honor `/traces/:id` style paths. + +## Approach + +### 1. Special-case `KindGraph` in transport handler + +`transport/http.go::ServeHTTP` currently treats every kind identically — same intent-table lookup, same dispatcher hop. Add a graph branch *before* the intent lookup: + +```go +if req.Kind == contract.KindGraph { + h.serveGraph(w, r, req) + return +} +``` + +`serveGraph` reads the route from `req.Payload.route`, calls `GraphBuilder.Build(reg, wardens).Build(ctx, contributor, route, principal)`, JSON-marshals the result, and writes the standard envelope. No CSRF, no idempotency, no command rules — graph is a read concern. The principal still feeds the `visibleWhen` filter. + +The handler needs the warden registry it already holds (line 56–57). Constructing the builder per-request is fine — it's a thin wrapper over the registry. + +### 2. Add route-param matching to `MergedGraph` + +Today's matcher (registry.go:149): + +```go +for i := range g { + if g[i].Route == route { + return &g[i], true + } +} +``` + +Change to a two-pass match: +- Exact match wins. +- Otherwise, walk routes that contain `:`-segments and try to match against the request URL. On a hit, extract `name → value` for each `:name` placeholder. + +`MergedGraph` returns a single bool today; extend the signature so callers receive the params: + +```go +MergedGraph(contributor, route string) (*GraphNode, map[string]string, bool) +``` + +Test fixtures and `slots_test.go` get a third return value. `GraphBuilder.Build` propagates the params through. + +Cardinality: top-level routes only. We do not support `/foo/:a/bar/:b` deep matches in a single segment — the slot system handles that via inline params and `parent.X`. One layer of `:id`-segments is enough for slice (j). + +### 3. Surface route params on the wire + +Extend `ResponseMeta` (or include a parallel `routeParams`) so the client can read the matched params back. Concretely: + +```go +type ResponseMeta struct { + IntentVersion int `json:"intentVersion,omitempty"` + RouteParams map[string]string `json:"routeParams,omitempty"` + // ...existing +} +``` + +`serveGraph` populates `RouteParams` from step 2. The graph response payload remains the GraphNode tree (the manifest tree, with no inline param substitution — substitution happens client-side). + +### 4. Wire React shell to use route params + +`bindings.ts::resolvePath` already accepts `route.X`. Today nothing populates `ctx.route`. The hook layer needs to: + +- `useContractGraph` returns both `data` (the GraphNode) and `routeParams` (from response meta). +- `App.tsx::PageRoute` exposes the params via React context (or threads them through GraphRenderer). +- `GraphRenderer` includes `route` in the `BindingContext` it builds for child intent components. +- The intent components (`resource.detail`, `form.edit`, `action.button`) already call `resolvePayload(node.payload, ctx)`. They get `ctx.route` for free once the renderer threads it. + +### 5. Add `/traces/:id` to the pilot manifest + +```yaml +- route: /traces/:id + intent: resource.detail + data: + intent: traces.detail + params: { id: { from: route.id } } + title: Trace + props: + fields: [traceID, root_span, span_count, duration, status, start_time, protocol] +``` + +The slice-(h) `traces.detail` query handler already exists. The drawer-fed-from-list pattern from slice (h) keeps working in parallel — the deep-link route is additive, not replacing. + +`/metrics/collectors/:name` and `/metrics/:name` deep links: out of scope for slice (j). Those would need new `metrics.collector` and `metrics.detail` query handlers; the slice (i) redirect collapsing them onto `/metrics` is fine for now. Add as slice (j2) when the React shell grows dedicated detail components for them. + +### 6. Update slice (i) redirect + +```go +pm.fuiApp.Page("/traces/:id").Handler(redirectTo(shellBase + "/traces/:id"))... +``` + +Have to interpolate `:id` from the request URL — small adjustment to `redirectTraceDetail`. + +## Files + +- `extensions/dashboard/contract/transport/http.go` — add `serveGraph` branch, take a graph builder factory or wreg dep. +- `extensions/dashboard/contract/registry.go` — `MergedGraph` returns params; route matcher with `:`-segments. +- `extensions/dashboard/contract/graph.go` — `Build` returns params; thread through `filter`. +- `extensions/dashboard/contract/envelope.go` — `ResponseMeta.RouteParams`. +- `extensions/dashboard/contract/pilot/manifest.yaml` — add `/traces/:id` route. +- `extensions/dashboard/contract/shell/src/contract/types.ts` — `GraphResponse` shape with `routeParams`. +- `extensions/dashboard/contract/shell/src/contract/client.ts` — `graph()` returns `{ node, routeParams }`. +- `extensions/dashboard/contract/shell/src/contract/hooks.ts` — `useContractGraph` returns the same. +- `extensions/dashboard/contract/shell/src/App.tsx` — pass routeParams down. +- `extensions/dashboard/contract/shell/src/runtime/renderer.tsx` — accept route context, weave into BindingContext. +- `extensions/dashboard/contract/shell/src/runtime/context.tsx` (new) — `RouteParamsProvider`. +- `extensions/dashboard/pages/pages.go` — fix `/traces/:id` redirect to interpolate. + +## Tests + +- **Transport:** `TestGraphHandler_ReturnsGraphForRoute` — POST kind=graph for `/health` returns the right node tree. +- **Transport:** `TestGraphHandler_ParamRouteExtractsID` — POST for `/traces/abc123` returns the `/traces/:id` node + meta.routeParams `{id:abc123}`. +- **Transport:** `TestGraphHandler_NotFoundUnknownRoute` — `/nothere` returns CodeNotFound. +- **Registry:** `TestMergedGraph_ExactBeforeParam` — exact route wins over a sibling param route. +- **Registry:** `TestMergedGraph_ParamMatch_ExtractsValue`. +- **React shell:** smoke test that renders a `/traces/:id` graph and confirms the inner `resource.detail` intent receives `route.id` in its bindings (no real fetch — mocked). + +## Out of scope + +- Multi-segment params (`/a/:x/b/:y`) — single-segment is enough for slice (j). +- Optional/glob route segments (`*filepath`). +- New detail intents for metrics collectors / individual metrics. +- Cache key changes for `/traces/:id` graph fetches — slice (b)'s graph cache treats route as opaque, so `/traces/abc` and `/traces/def` get separate keys naturally. + +## Why fold the graph fix into slice (j) + +I considered a separate `slice-j-fix-graph` followed by `slice-j-detail-routes`. Decided against: the fix is small (~30 LOC in transport.go), the detail-route work *needs* the fix, and the test that proves the fix is the same test that exercises detail routing. Splitting wastes a commit. diff --git a/extensions/dashboard/contract/SLICE_K_DESIGN.md b/extensions/dashboard/contract/SLICE_K_DESIGN.md new file mode 100644 index 00000000..ee543e93 --- /dev/null +++ b/extensions/dashboard/contract/SLICE_K_DESIGN.md @@ -0,0 +1,127 @@ +# Slice (k) — Audit storage + live `audit.tail` subscription + +**Status:** Active +**Branch:** `dashboard-contract-slice-a` +**Predecessors:** (a)–(j) + +## Why + +The audit emitter (slice b) writes to stdout/Logger via `LogAuditEmitter` and disappears. The vocabulary intent `audit.tail` (slice e) has had a React component that subscribes to a server intent named `audit.tail`, but that intent was never registered — clicking the audit widget would silently produce nothing. Slice (k) closes both gaps: + +1. **Persistent (in-memory) audit storage** — every command run is stored in a ring buffer the dashboard can query. +2. **`audit.list` query intent** — paginated history of recent commands. +3. **`audit.tail` subscription intent** — live append-mode stream of new audit records, which is what the React `AuditTail` component is already wired to consume. +4. **`/audit` route** — top-level page in the manifest that combines history + live tail. + +This unlocks the audit widget that's been a no-op since slice (e) and gives the dashboard real command observability without external infrastructure. + +## Approach + +### 1. `AuditStore` interface + in-memory impl + +```go +type AuditStore interface { + Append(rec AuditRecord) + List(filter AuditFilter) []AuditRecord // newest first, capped by Limit + Subscribe() (<-chan AuditRecord, func()) +} +``` + +In-memory implementation: +- Ring buffer (default cap 1000) protected by RWMutex. +- `List(filter)` walks newest→oldest, applying optional `User`, `Contributor`, `Intent`, `Result` filters; returns up to `filter.Limit` (default 200, max 1000). +- `Subscribe()` registers a fan-out channel; `Append()` non-blocking-sends to all subscribers (drops on slow consumers — telemetry, not authoritative). + +The store lives in `extensions/dashboard/contract` next to `audit.go`. + +### 2. `RecordingAuditEmitter` + +Wraps any inner `AuditEmitter` and adds a store side-effect: + +```go +func NewRecordingAuditEmitter(inner AuditEmitter, store AuditStore) AuditEmitter +``` + +Slice (b) wired `dispatcher.NewLoggerAuditEmitter(...)` into the extension. After slice (k) the wiring becomes `NewRecordingAuditEmitter(NewLoggerAuditEmitter(...), e.auditStore)` so log-line semantics stay and the store fills. + +### 3. `audit.list` query handler + +Lives in pilot at `extensions/dashboard/contract/pilot/audit.go`: + +```go +type AuditListInput struct { + Limit int `json:"limit,omitempty"` + Contributor string `json:"contributor,omitempty"` + Intent string `json:"intent,omitempty"` + User string `json:"user,omitempty"` + Result string `json:"result,omitempty"` +} +type AuditListResponse struct { + Records []AuditRecordDTO `json:"records"` + Total int `json:"total"` +} +``` + +Records are projected to a wire-friendly DTO with RFC3339Nano timestamps and the same fields the React component renders. + +### 4. `audit.tail` subscription handler + +A `dispatcher.SubscriptionHandler` that calls `store.Subscribe()`, fans events out as `StreamEvent{Mode: ModeAppend, Payload: AuditRecordDTO}`. Cancellation closes the subscriber channel. + +### 5. Manifest entry + +Add to `pilot/manifest.yaml`: + +```yaml +intents: + - { name: audit.list, kind: query, version: 1, capability: read } + - { name: audit.tail, kind: subscription, version: 1, capability: read, mode: append } + +queries: + auditList: + intent: audit.list + cache: { staleTime: 5s } + +graph: + - route: /audit + intent: page.shell + title: Audit + nav: { group: Operations, icon: history, priority: 23 } + slots: + main: + - intent: audit.tail + title: Live audit + data: + intent: audit.tail + props: + bufferSize: 200 +``` + +This is the first time `audit.tail` appears in `page.shell.main`. Slot vocabulary needs an update: today `page.shell.main` accepts `[resource.list, resource.detail, dashboard.grid, form.edit, custom, iframe]` — extend with `audit.tail`. + +The `audit.list` query stays available for future history-style pages; v1 of the audit page only ships the live tail because the React `AuditTail` component is already built around subscriptions and history-styled rendering would need a new component. + +### 6. Wiring in `extension.go` + +- `NewExtension` constructs `e.auditStore = contract.NewInMemoryAuditStore()` +- `e.auditEmitter = contract.NewRecordingAuditEmitter(, e.auditStore)` after the existing `auditEmitter` selection block +- Pilot `Deps` gets a new `Audit AuditProvider` field carrying the store; `Register()` registers the two handlers when non-nil + +## Tests + +- **Store:** `TestAuditStore_AppendList` (round-trip), `TestAuditStore_Filter`, `TestAuditStore_RingTruncates`, `TestAuditStore_SubscribeBroadcasts`, `TestAuditStore_DropsSlowSubscriber`. +- **RecordingEmitter:** chains to inner + writes to store. +- **Pilot audit.list handler:** happy path, projection (timestamps formatted), CodeUnavailable when nil store, filter behavior. +- **Pilot audit.tail handler:** subscribes, emits events when store appends, cancellation cleans up. +- **Manifest:** loads with new intents, validates with new vocabulary entry. + +## Out of scope + +- Persistent storage backends (Postgres, SQLite). The interface is shaped to allow them; in-memory is the slice-(k) impl. +- Pagination cursors for `audit.list` (limit/offset would need a stable order key — slice (k2) when audit becomes a real history page). +- Per-tenant scoping. The store is global; multi-tenant filtering happens in handlers when we add tenant resolution to AuditRecord (slice l, separate concern). +- A history-rendering React component for `audit.list`. Slice (k) ships the live tail (`audit.tail`); a `resource.list`-style audit history page is a follow-on. + +## Why not split + +`audit.list` and `audit.tail` share the store and the projection helpers. The vocabulary update + manifest route benefits from being one PR with a single test pass. ~250 LOC total. diff --git a/extensions/dashboard/contract/SLICE_M_DESIGN.md b/extensions/dashboard/contract/SLICE_M_DESIGN.md new file mode 100644 index 00000000..c5c310cc --- /dev/null +++ b/extensions/dashboard/contract/SLICE_M_DESIGN.md @@ -0,0 +1,179 @@ +# Slice (m) — Remote contract contributors + +**Status:** Active +**Branch:** `dashboard-contract-slice-a` (continuing the stack — slices a/b/c/d/e/f/h/i/j/k/l already pushed) + +## Context + +The legacy templ-based dashboard supports remote contributors: services tagged +`forge-dashboard-contributor` are discovered via Forge's service registry, their +manifest is fetched from `GET /_forge/dashboard/manifest`, and the dashboard +proxies page/widget/settings render requests through to the upstream over HTTP +(`extensions/dashboard/contributor/remote.go`, `extensions/dashboard/proxy/`). +This is what lets a single dashboard surface UI contributed by 5+ separate +microservices. + +The new contract path is in-memory only. The dispatcher's handler table is a +`map[handlerKey]Handler` of in-process Go functions; the registry stores +manifests received via `Registry.Register()` from extensions running inside +the same binary. A remote service has no way to advertise its contract +contributor to the dashboard. + +This slice adds remote contributor support to the contract path. Same shape +as the templ flow: a remote service hosts its manifest + a dispatch endpoint; +the dashboard registers the remote, routes intent requests to the upstream, +returns the response transparently. Auto-discovery via the service registry +rides on the same plumbing in a follow-on. + +## Recommended Approach + +### 1. Registry — remote tracking + +Extend `contract.Registry` to track remotes alongside locals. The merged-graph +layer already handles per-contributor lookups; we just need to record the +upstream endpoint so the dispatcher can find it. + +```go +type Registry interface { + // existing methods... + RegisterRemote(m *ContractManifest, endpoint RemoteEndpoint) error + IsRemote(contributor string) bool + Remote(contributor string) (RemoteEndpoint, bool) +} + +type RemoteEndpoint struct { + BaseURL string // e.g. https://svc.internal:8443/svc + APIKey string // optional, sent as Authorization: Bearer + Client *http.Client // optional; nil = default http.Client with 5s timeout +} +``` + +`RegisterRemote` calls the same validate/merge path `Register` does so remote +manifests participate in `MergedGraph` / `MatchRoute` exactly like local ones +(the React shell's graph endpoint for `/contributor-x/route` resolves a remote +manifest with no special-casing). + +### 2. Dispatcher — remote forwarding + +The dispatcher's local handler map keeps working for in-process contributors. +Add a remote-fallback hook: + +```go +type RemoteDispatcher interface { + Dispatch(ctx context.Context, req contract.Request, p contract.Principal) (json.RawMessage, contract.ResponseMeta, error) + Subscribe(ctx context.Context, ... ) (<-chan contract.StreamEvent, func(), error) +} + +func (d *Dispatcher) SetRemoteDispatcher(rd RemoteDispatcher) +``` + +The existing `Dispatch` flow: +1. Look up `req.Contributor + req.Intent + req.IntentVersion` in the local + handler map. +2. If not found and a `RemoteDispatcher` is set, ask it to dispatch. +3. Else return `CodeNotFound` as today. + +Subscriptions get the same treatment via `SetRemoteDispatcher` so SSE works +for remote subscription intents. + +### 3. `contract/remote` — HTTP forwarding client + +New package implements `RemoteDispatcher` by POSTing the envelope to the +remote service. + +```go +type ForwardingDispatcher struct { + reg contract.Registry // for looking up RemoteEndpoint per contributor + headers HeaderForwardFunc // optional; defaults to nil (no auth forwarding) +} +``` + +- For each request, looks up the contributor's `RemoteEndpoint` from the + registry. Falls through to `CodeNotFound` when the contributor isn't + registered as a remote. +- POSTs the verbatim envelope to `/_forge/contract/dispatch`. +- Forwards `Authorization` and `Cookie` headers from the inbound request + (read via `dashauth.RequestFromContext`) so the upstream sees the same + caller identity — mirrors `WithForwardedHeaders` in the legacy + `RemoteContributor`. +- Unmarshals the wire envelope back into `(data, meta, err)`. + +Subscriptions are a follow-on: the upstream-stream multiplexing needs more +plumbing than a single POST and is out of slice (m). + +### 4. `contract/server` — helper for non-dashboard services + +A remote contract contributor is typically a service that *isn't* mounting +the full dashboard — it just wants to advertise some intents. Provide a +thin server helper that exposes the two endpoints the dashboard expects: + +```go +package server + +func New(reg contract.Registry, wreg contract.WardenRegistry, disp transport.Dispatcher, audit contract.AuditEmitter) *Server + +func (s *Server) RegisterRoutes(r forge.Router, prefix string) +``` + +`prefix` defaults to `/_forge/contract`. Mounts: +- `GET /manifest?contributor=` — returns the contributor's + manifest as JSON. Without `?contributor`, returns all registered manifests. +- `POST /dispatch` — accepts the contract envelope, dispatches via + the supplied dispatcher, returns the response envelope. Uses the same + CSRF/idempotency rules as the dashboard's own `/api/dashboard/v1`. + +### 5. Dashboard extension — manual registration API + +Add `Extension.RegisterRemoteContractContributor(ctx, baseURL, apiKey) error` +that: +- Fetches `GET /_forge/contract/manifest` +- Validates + registers via `Registry.RegisterRemote` +- Stitches a `ForwardingDispatcher` into the dispatcher if one isn't already + there. + +The dashboard's own `/api/dashboard/v1` endpoint stays unchanged: requests +for remote contributors hit the dispatcher, which forwards via the +`ForwardingDispatcher` set up at registration time. + +### 6. Discovery — pluggable, deferred for auto-detection + +A `discovery.Watcher` interface lets deployments wire any of: +- The existing forge service-discovery package (poll for tag, register) +- A static config (list of `{baseURL, apiKey}` from YAML) +- A bespoke watcher (e.g. Kubernetes endpoints, mTLS service mesh) + +Ship a static-config watcher and a no-op default this slice. Forge-discovery +integration is its own follow-on (slice m2) since it requires wiring the +discovery client into the contract registry and reusing the templ-path +discovery package machinery. + +## Files + +### New +- `extensions/dashboard/contract/remote/client.go` +- `extensions/dashboard/contract/remote/client_test.go` +- `extensions/dashboard/contract/remote/manifest.go` (helper to fetch + parse remote manifest) +- `extensions/dashboard/contract/server/server.go` +- `extensions/dashboard/contract/server/server_test.go` + +### Modified +- `extensions/dashboard/contract/registry.go` — `RegisterRemote`, `IsRemote`, `Remote` +- `extensions/dashboard/contract/dispatcher/dispatcher.go` — `SetRemoteDispatcher`, fallback in `Dispatch` +- `extensions/dashboard/extension.go` — `RegisterRemoteContractContributor` API, default forwarding dispatcher wiring + +## Tests + +- **Registry:** registering a remote populates `IsRemote` + `Remote` + `MergedGraph`. +- **Forwarding dispatcher:** dispatches local handlers locally; falls through to remote when not registered locally; CodeNotFound when neither knows. +- **HTTP round-trip:** `httptest.Server` hosting a server.New(...) → host dispatcher with `ForwardingDispatcher` → query + command both round-trip end to end including error envelopes. +- **Manifest fetcher:** unmarshals JSON manifest, surfaces errors clearly. +- **Auth forwarding:** Authorization header on the inbound request appears on the outbound to the upstream. + +## Out of scope (slice m2) + +- Auto-discovery via the forge service registry. The legacy templ flow + already does this; mirror it once the static-config path is solid. +- Subscription forwarding (SSE multiplexing across services). +- Remote-side caching / stale-on-error fallback. +- Per-contributor warden authorization on the dispatch path + (the upstream service applies its own warden; the host doesn't double-check). 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_store.go b/extensions/dashboard/contract/audit_store.go new file mode 100644 index 00000000..0b800b46 --- /dev/null +++ b/extensions/dashboard/contract/audit_store.go @@ -0,0 +1,146 @@ +package contract + +import ( + "context" + "sync" +) + +// AuditStore is the persistent (process-local for slice (k)) view of audit +// records. It exists to back the audit.list query and audit.tail subscription +// the dashboard exposes; production deployments swap the in-memory impl for a +// durable backend when one is wired. +type AuditStore interface { + Append(rec AuditRecord) + List(filter AuditFilter) []AuditRecord + // Subscribe returns a channel that receives every Append from now on, plus + // a cancel func that closes the channel and unregisters the subscriber. + // Slow subscribers drop events rather than block writers — audit is + // telemetry, not the source of truth. + Subscribe() (<-chan AuditRecord, func()) +} + +// AuditFilter narrows audit.list results. All fields are optional. Limit is +// clamped to [1, 1000]; zero defaults to 200. +type AuditFilter struct { + Limit int + Contributor string + Intent string + User string + Result string +} + +const ( + defaultAuditListLimit = 200 + maxAuditListLimit = 1000 +) + +// NewInMemoryAuditStore returns a store that keeps the most recent `cap` +// records in a ring buffer. cap <= 0 defaults to 1000. +func NewInMemoryAuditStore(cap int) AuditStore { + if cap <= 0 { + cap = 1000 + } + return &memAuditStore{ + buf: make([]AuditRecord, 0, cap), + cap: cap, + } +} + +type memAuditStore struct { + mu sync.RWMutex + buf []AuditRecord + cap int + subs []chan AuditRecord + subSeq int // monotonic id for a future remove-by-id; not exposed yet + subsCleanup []chan AuditRecord +} + +func (s *memAuditStore) Append(rec AuditRecord) { + s.mu.Lock() + if len(s.buf) >= s.cap { + // drop oldest + copy(s.buf, s.buf[1:]) + s.buf = s.buf[:len(s.buf)-1] + } + s.buf = append(s.buf, rec) + subs := append([]chan AuditRecord(nil), s.subs...) + s.mu.Unlock() + // non-blocking fan-out — slow subscribers drop events + for _, ch := range subs { + select { + case ch <- rec: + default: + } + } +} + +func (s *memAuditStore) List(filter AuditFilter) []AuditRecord { + limit := filter.Limit + if limit <= 0 { + limit = defaultAuditListLimit + } + if limit > maxAuditListLimit { + limit = maxAuditListLimit + } + s.mu.RLock() + defer s.mu.RUnlock() + out := make([]AuditRecord, 0, limit) + // walk newest -> oldest + for i := len(s.buf) - 1; i >= 0 && len(out) < limit; i-- { + rec := s.buf[i] + if filter.Contributor != "" && rec.Contributor != filter.Contributor { + continue + } + if filter.Intent != "" && rec.Intent != filter.Intent { + continue + } + if filter.User != "" && rec.User != filter.User { + continue + } + if filter.Result != "" && rec.Result != filter.Result { + continue + } + out = append(out, rec) + } + return out +} + +func (s *memAuditStore) Subscribe() (<-chan AuditRecord, func()) { + ch := make(chan AuditRecord, 32) + s.mu.Lock() + s.subs = append(s.subs, ch) + s.mu.Unlock() + cancel := func() { + s.mu.Lock() + for i, c := range s.subs { + if c == ch { + s.subs = append(s.subs[:i], s.subs[i+1:]...) + break + } + } + s.mu.Unlock() + close(ch) + } + return ch, cancel +} + +// NewRecordingAuditEmitter returns an emitter that fans out to inner (typically +// the log emitter) and also persists to store. Either may be nil; both nil is +// a noop. +func NewRecordingAuditEmitter(inner AuditEmitter, store AuditStore) AuditEmitter { + return &recordingEmitter{inner: inner, store: store} +} + +type recordingEmitter struct { + inner AuditEmitter + store AuditStore +} + +func (e *recordingEmitter) Emit(ctx context.Context, rec AuditRecord) { + if e.store != nil { + e.store.Append(rec) + } + if e.inner != nil { + e.inner.Emit(ctx, rec) + } +} diff --git a/extensions/dashboard/contract/audit_store_test.go b/extensions/dashboard/contract/audit_store_test.go new file mode 100644 index 00000000..7d2e010b --- /dev/null +++ b/extensions/dashboard/contract/audit_store_test.go @@ -0,0 +1,121 @@ +package contract + +import ( + "context" + "testing" + "time" +) + +func mkRec(intent string, t time.Time) AuditRecord { + return AuditRecord{ + Time: t, Contributor: "core-contract", Intent: intent, Result: "ok", + } +} + +func TestAuditStore_AppendList(t *testing.T) { + s := NewInMemoryAuditStore(0) + now := time.Now() + s.Append(mkRec("a", now.Add(-1*time.Second))) + s.Append(mkRec("b", now)) + got := s.List(AuditFilter{}) + if len(got) != 2 { + t.Fatalf("len = %d", len(got)) + } + if got[0].Intent != "b" || got[1].Intent != "a" { + t.Errorf("expected newest-first ordering, got %+v", got) + } +} + +func TestAuditStore_FilterIntent(t *testing.T) { + s := NewInMemoryAuditStore(0) + s.Append(mkRec("a", time.Now())) + s.Append(mkRec("b", time.Now())) + s.Append(mkRec("a", time.Now())) + got := s.List(AuditFilter{Intent: "a"}) + if len(got) != 2 { + t.Errorf("expected 2 intent=a records, got %d", len(got)) + } +} + +func TestAuditStore_LimitClamping(t *testing.T) { + s := NewInMemoryAuditStore(0) + for i := 0; i < 50; i++ { + s.Append(mkRec("x", time.Now())) + } + if got := s.List(AuditFilter{Limit: 5}); len(got) != 5 { + t.Errorf("limit=5 -> %d records", len(got)) + } + if got := s.List(AuditFilter{Limit: -1}); len(got) != 50 { + t.Errorf("limit=-1 -> %d records (want default 50 since cap < default)", len(got)) + } +} + +func TestAuditStore_RingTruncates(t *testing.T) { + s := NewInMemoryAuditStore(3) + for i := 0; i < 5; i++ { + s.Append(AuditRecord{Time: time.Now(), Intent: "x", User: string(rune('a' + i))}) + } + got := s.List(AuditFilter{}) + if len(got) != 3 { + t.Fatalf("expected 3 (cap), got %d", len(got)) + } + // Newest is "e", oldest kept is "c"; "a" and "b" dropped. + if got[0].User != "e" || got[2].User != "c" { + t.Errorf("ring kept wrong records: %+v", got) + } +} + +func TestAuditStore_SubscribeBroadcasts(t *testing.T) { + s := NewInMemoryAuditStore(0) + ch, cancel := s.Subscribe() + defer cancel() + go s.Append(mkRec("hello", time.Now())) + select { + case rec := <-ch: + if rec.Intent != "hello" { + t.Errorf("got %+v", rec) + } + case <-time.After(time.Second): + t.Fatal("no event received") + } +} + +func TestAuditStore_CancelClosesChannel(t *testing.T) { + s := NewInMemoryAuditStore(0) + ch, cancel := s.Subscribe() + cancel() + select { + case _, ok := <-ch: + if ok { + t.Errorf("expected closed channel") + } + case <-time.After(time.Second): + t.Fatal("channel did not close") + } +} + +func TestRecordingAuditEmitter_FansOut(t *testing.T) { + store := NewInMemoryAuditStore(0) + captured := []AuditRecord{} + innerCalled := false + inner := auditEmitterFunc(func(_ context.Context, rec AuditRecord) { + innerCalled = true + captured = append(captured, rec) + }) + em := NewRecordingAuditEmitter(inner, store) + em.Emit(context.Background(), mkRec("x", time.Now())) + if !innerCalled { + t.Errorf("inner emitter not called") + } + if len(captured) != 1 { + t.Errorf("inner saw %d records", len(captured)) + } + if got := store.List(AuditFilter{}); len(got) != 1 { + t.Errorf("store has %d records", len(got)) + } +} + +// auditEmitterFunc is a tiny test-only adapter from func to AuditEmitter. +type auditEmitterFunc func(ctx context.Context, rec AuditRecord) + +func (f auditEmitterFunc) Emit(ctx context.Context, rec AuditRecord) { f(ctx, rec) } 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 +} 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") + } +} 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, + }) +} 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{}) +} 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) + } + } +} diff --git a/extensions/dashboard/contract/dispatcher/dispatcher.go b/extensions/dashboard/contract/dispatcher/dispatcher.go new file mode 100644 index 00000000..926fbf37 --- /dev/null +++ b/extensions/dashboard/contract/dispatcher/dispatcher.go @@ -0,0 +1,302 @@ +package dispatcher + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "log" + "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" +) + +// 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 + tracer trace.Tracer // optional; nil = no tracing + store IdempotencyStore // optional; nil = no command dedup + + mu sync.RWMutex + handlers map[handlerKey]Handler + subscriptions map[handlerKey]SubscriptionHandler + // remote is consulted by Dispatch when no local handler exists. Slice (m) + // added this so contributors hosted in other services can serve queries + // and commands over HTTP without the host knowing they're remote at the + // transport layer. + remote RemoteDispatcher +} + +// RemoteDispatcher is the fallback Dispatcher consults when no local handler +// is registered for an envelope. The dispatcher passes the verbatim request +// through; implementations typically POST it to a peer service. Slice (m) +// added this so dashboards can aggregate contributors from multiple +// upstreams without baking forwarding into the dispatcher itself. +type RemoteDispatcher interface { + Dispatch(ctx context.Context, req contract.Request, p contract.Principal) (json.RawMessage, contract.ResponseMeta, error) +} + +type handlerKey struct { + Contributor string + Intent string + 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{} + } + d := &Dispatcher{ + metrics: metrics, + handlers: map[handlerKey]Handler{}, + subscriptions: map[handlerKey]SubscriptionHandler{}, + } + for _, opt := range opts { + opt(d) + } + return d +} + +// SetRemoteDispatcher installs (or clears, with nil) the fallback consulted +// when no local handler matches a request. Idempotent — the dispatcher's +// forwarding plumbing typically calls this once during wire-up. +func (d *Dispatcher) SetRemoteDispatcher(rd RemoteDispatcher) { + d.mu.Lock() + defer d.mu.Unlock() + d.remote = rd +} + +// 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. 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] + remote := d.remote + d.mu.RUnlock() + if !ok { + // Slice (m): no local handler — fall through to the remote dispatcher + // if wired. Forwarding inherits the dispatcher's metrics + dedup + // pipeline; latency is measured around the remote call too so the + // host sees the round-trip cost. + if remote != nil { + t0 := time.Now() + data, meta, rErr := remote.Dispatch(ctx, req, p) + latency := time.Since(t0) + errCode := contract.ErrorCode("") + if rErr != nil { + var ce *contract.Error + if errors.As(rErr, &ce) { + errCode = ce.Code + } + d.metrics.RecordDispatch(ctx, req.Contributor, req.Intent, req.IntentVersion, req.Kind, latency, errCode) + return nil, contract.ResponseMeta{}, rErr + } + d.metrics.RecordDispatch(ctx, req.Contributor, req.Intent, req.IntentVersion, req.Kind, latency, errCode) + return data, meta, nil + } + 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 + } + + var ( + data json.RawMessage + meta contract.ResponseMeta + ) + if res == nil { + // Allow nil result to mean {data: null} explicitly. + 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 + } + + // 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, + }) + } + + 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 user + ":" + intent +} + +// 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..09658f13 --- /dev/null +++ b/extensions/dashboard/contract/dispatcher/dispatcher_test.go @@ -0,0 +1,205 @@ +package dispatcher + +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) { + 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") + } +} + +// 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) + } +} 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/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) + } +} 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 +} 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) { +} 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. +} 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) + } +} diff --git a/extensions/dashboard/contract/dispatcher/remote_test.go b/extensions/dashboard/contract/dispatcher/remote_test.go new file mode 100644 index 00000000..53662133 --- /dev/null +++ b/extensions/dashboard/contract/dispatcher/remote_test.go @@ -0,0 +1,93 @@ +package dispatcher + +import ( + "context" + "encoding/json" + "testing" + + "github.com/xraph/forge/extensions/dashboard/contract" +) + +// stubRemoteDispatcher captures every call so the test can assert routing +// decisions (local vs remote) without touching HTTP. +type stubRemoteDispatcher struct { + called int + lastIntent string + respondWith json.RawMessage + respondErr error +} + +func (s *stubRemoteDispatcher) Dispatch(_ context.Context, req contract.Request, _ contract.Principal) (json.RawMessage, contract.ResponseMeta, error) { + s.called++ + s.lastIntent = req.Intent + if s.respondErr != nil { + return nil, contract.ResponseMeta{}, s.respondErr + } + return s.respondWith, contract.ResponseMeta{}, nil +} + +func TestDispatch_PrefersLocalHandlerOverRemote(t *testing.T) { + d := New(NoopMetricsEmitter{}) + if err := RegisterQuery(d, "x", "x.list", 1, + func(_ context.Context, _ struct{}, _ contract.Principal) (struct{ V int }, error) { + return struct{ V int }{V: 1}, nil + }, + ); err != nil { + t.Fatalf("register: %v", err) + } + rem := &stubRemoteDispatcher{respondWith: json.RawMessage(`{"v":99}`)} + d.SetRemoteDispatcher(rem) + _, _, err := d.Dispatch(context.Background(), contract.Request{ + Kind: contract.KindQuery, Contributor: "x", Intent: "x.list", IntentVersion: 1, + }, contract.Principal{}) + if err != nil { + t.Fatalf("dispatch: %v", err) + } + if rem.called != 0 { + t.Errorf("remote called %d times for a locally-handled intent", rem.called) + } +} + +func TestDispatch_FallsThroughToRemoteWhenLocalMissing(t *testing.T) { + d := New(NoopMetricsEmitter{}) + rem := &stubRemoteDispatcher{respondWith: json.RawMessage(`{"ok":true}`)} + d.SetRemoteDispatcher(rem) + data, _, err := d.Dispatch(context.Background(), contract.Request{ + Kind: contract.KindQuery, Contributor: "x", Intent: "x.missing", IntentVersion: 1, + }, contract.Principal{}) + if err != nil { + t.Fatalf("dispatch: %v", err) + } + if rem.called != 1 { + t.Errorf("remote should be called once, got %d", rem.called) + } + if string(data) != `{"ok":true}` { + t.Errorf("data = %s", data) + } +} + +func TestDispatch_RemoteErrorIsSurfacedVerbatim(t *testing.T) { + d := New(NoopMetricsEmitter{}) + d.SetRemoteDispatcher(&stubRemoteDispatcher{ + respondErr: &contract.Error{Code: contract.CodePermissionDenied, Message: "nope"}, + }) + _, _, err := d.Dispatch(context.Background(), contract.Request{ + Kind: contract.KindQuery, Contributor: "x", Intent: "x.thing", IntentVersion: 1, + }, contract.Principal{}) + ce, ok := err.(*contract.Error) + if !ok || ce.Code != contract.CodePermissionDenied { + t.Errorf("expected CodePermissionDenied surfaced, got %v", err) + } +} + +func TestDispatch_NotFoundWhenNeitherLocalNorRemoteKnows(t *testing.T) { + d := New(NoopMetricsEmitter{}) + // No SetRemoteDispatcher — expect the pre-slice-(m) behaviour. + _, _, err := d.Dispatch(context.Background(), contract.Request{ + Kind: contract.KindQuery, Contributor: "x", Intent: "x.missing", IntentVersion: 1, + }, contract.Principal{}) + ce, ok := err.(*contract.Error) + if !ok || ce.Code != contract.CodeNotFound { + t.Errorf("expected CodeNotFound, got %v", err) + } +} 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") + } +} 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) + } +} 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/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/envelope.go b/extensions/dashboard/contract/envelope.go new file mode 100644 index 00000000..206aae1c --- /dev/null +++ b/extensions/dashboard/contract/envelope.go @@ -0,0 +1,93 @@ +// 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). +// +// RouteParams is populated by graph responses for routes that contain :name +// placeholders (e.g. /traces/:id). The map is keyed by placeholder name and +// holds the matched URL value. Slice (j) introduced this so the shell can +// resolve `route.` in payload bindings. +type ResponseMeta struct { + IntentVersion int `json:"intentVersion,omitempty"` + Deprecation *Deprecation `json:"deprecation,omitempty"` + CacheControl *CacheHint `json:"cacheControl,omitempty"` + Invalidates []string `json:"invalidates,omitempty"` + RouteParams map[string]string `json:"routeParams,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..a295c768 --- /dev/null +++ b/extensions/dashboard/contract/envelope_test.go @@ -0,0 +1,83 @@ +// envelope_test.go +package contract + +import ( + "bytes" + "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") + } + } +} + +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) + } + } +} 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..66b68f66 --- /dev/null +++ b/extensions/dashboard/contract/errors_test.go @@ -0,0 +1,54 @@ +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) { + codes := []ErrorCode{ + CodeBadRequest, CodeUnauthenticated, CodePermissionDenied, + CodeNotFound, CodeConflict, CodeRateLimited, + CodeUnsupportedVersion, CodeUnavailable, CodeInternal, + } + 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 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") + } + } +} diff --git a/extensions/dashboard/contract/graph.go b/extensions/dashboard/contract/graph.go new file mode 100644 index 00000000..1308cb6e --- /dev/null +++ b/extensions/dashboard/contract/graph.go @@ -0,0 +1,97 @@ +// 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) { + n, _, err := b.BuildWithParams(ctx, contributor, route, p) + return n, err +} + +// BuildWithParams is Build plus the route-pattern params extracted from the +// matched route. For exact-route matches the map is empty (non-nil); for +// :name-style routes it carries the placeholder values. Slice (j) added this +// so the transport handler can surface params in ResponseMeta. +func (b *GraphBuilder) BuildWithParams(ctx context.Context, contributor, route string, p Principal) (*GraphNode, map[string]string, error) { + root, params, ok := b.registry.MatchRoute(contributor, route) + if !ok { + return nil, nil, fmt.Errorf("%w: contributor=%s route=%s", ErrNotFound, contributor, route) + } + filtered, err := b.filter(ctx, *root, p) + if err != nil { + return nil, nil, err + } + if filtered == nil { + return nil, nil, fmt.Errorf("%w: route filtered for principal", ErrPermissionDenied) + } + return filtered, params, 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") + } +} 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/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() +} 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") + } +} 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 +} 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") + } +} diff --git a/extensions/dashboard/contract/manifest.go b/extensions/dashboard/contract/manifest.go new file mode 100644 index 00000000..53cffaa2 --- /dev/null +++ b/extensions/dashboard/contract/manifest.go @@ -0,0 +1,196 @@ +// 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 + +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 +} + +// 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"` +} + +// 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"` +} + +// 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 { + 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..63785828 --- /dev/null +++ b/extensions/dashboard/contract/manifest_test.go @@ -0,0 +1,131 @@ +// 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) + } +} + +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) + } +} + +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) + } +} diff --git a/extensions/dashboard/contract/pilot/audit.go b/extensions/dashboard/contract/pilot/audit.go new file mode 100644 index 00000000..297f0649 --- /dev/null +++ b/extensions/dashboard/contract/pilot/audit.go @@ -0,0 +1,115 @@ +package pilot + +import ( + "context" + "time" + + "github.com/xraph/forge/extensions/dashboard/contract" +) + +// AuditProvider is the slice of contract.AuditStore the audit handlers need. +// Defined here (not as a type alias) so tests can supply a stub without +// importing the full contract package surface. +type AuditProvider interface { + List(filter contract.AuditFilter) []contract.AuditRecord + Subscribe() (<-chan contract.AuditRecord, func()) +} + +// AuditRecordDTO is the wire shape for one audit record. Timestamps are +// RFC3339Nano strings and the payload field is omitted (slice (k) keeps the +// store free of payload data; per-intent redaction is a slice (l) concern). +type AuditRecordDTO struct { + Time string `json:"time"` + Contributor string `json:"contributor"` + Intent string `json:"intent"` + IntentVersion int `json:"intentVersion,omitempty"` + Subject string `json:"subject,omitempty"` + User string `json:"user,omitempty"` + Result string `json:"result"` + LatencyMs int64 `json:"latencyMs"` + CorrelationID string `json:"correlationID,omitempty"` +} + +func projectRecord(r contract.AuditRecord) AuditRecordDTO { + return AuditRecordDTO{ + Time: r.Time.UTC().Format(time.RFC3339Nano), + Contributor: r.Contributor, + Intent: r.Intent, + IntentVersion: r.IntentVersion, + Subject: r.Subject, + User: r.User, + Result: r.Result, + LatencyMs: r.LatencyMs, + CorrelationID: r.CorrelationID, + } +} + +// AuditListInput is the wire input for the audit.list query. +type AuditListInput struct { + Limit int `json:"limit,omitempty"` + Contributor string `json:"contributor,omitempty"` + Intent string `json:"intent,omitempty"` + User string `json:"user,omitempty"` + Result string `json:"result,omitempty"` +} + +// AuditListResponse is the wire output of audit.list. +type AuditListResponse struct { + Records []AuditRecordDTO `json:"records"` + Total int `json:"total"` +} + +func auditListHandler(p AuditProvider) func(ctx context.Context, in AuditListInput, _ contract.Principal) (AuditListResponse, error) { + return func(_ context.Context, in AuditListInput, _ contract.Principal) (AuditListResponse, error) { + if p == nil { + return AuditListResponse{}, &contract.Error{Code: contract.CodeUnavailable, Message: "audit store not configured"} + } + recs := p.List(contract.AuditFilter{ + Limit: in.Limit, + Contributor: in.Contributor, + Intent: in.Intent, + User: in.User, + Result: in.Result, + }) + out := make([]AuditRecordDTO, 0, len(recs)) + for _, r := range recs { + out = append(out, projectRecord(r)) + } + return AuditListResponse{Records: out, Total: len(out)}, nil + } +} + +// auditTailSub returns a subscription handler that streams every Append from +// the store as an append-mode StreamEvent. Cancellation tears down the +// store-side subscriber. nil provider yields CodeUnavailable on subscribe. +func auditTailSub(p AuditProvider) func(ctx context.Context, _ struct{}, _ contract.Principal) (<-chan AuditRecordDTO, func(), error) { + return func(ctx context.Context, _ struct{}, _ contract.Principal) (<-chan AuditRecordDTO, func(), error) { + if p == nil { + return nil, nil, &contract.Error{Code: contract.CodeUnavailable, Message: "audit store not configured"} + } + src, cancel := p.Subscribe() + out := make(chan AuditRecordDTO, 16) + stop := func() { + cancel() + } + go func() { + defer close(out) + for { + select { + case <-ctx.Done(): + return + case rec, ok := <-src: + if !ok { + return + } + select { + case out <- projectRecord(rec): + case <-ctx.Done(): + return + } + } + } + }() + return out, stop, nil + } +} diff --git a/extensions/dashboard/contract/pilot/audit_test.go b/extensions/dashboard/contract/pilot/audit_test.go new file mode 100644 index 00000000..b09dcc9e --- /dev/null +++ b/extensions/dashboard/contract/pilot/audit_test.go @@ -0,0 +1,86 @@ +package pilot + +import ( + "context" + "testing" + "time" + + "github.com/xraph/forge/extensions/dashboard/contract" +) + +type fakeAuditProvider struct { + listed []contract.AuditRecord + subs []chan contract.AuditRecord +} + +func (f *fakeAuditProvider) List(_ contract.AuditFilter) []contract.AuditRecord { + return f.listed +} +func (f *fakeAuditProvider) Subscribe() (<-chan contract.AuditRecord, func()) { + ch := make(chan contract.AuditRecord, 8) + f.subs = append(f.subs, ch) + return ch, func() { close(ch) } +} + +func TestAuditListHandler_Projects(t *testing.T) { + now := time.Date(2026, 5, 10, 10, 0, 0, 0, time.UTC) + p := &fakeAuditProvider{listed: []contract.AuditRecord{ + {Time: now, Contributor: "core-contract", Intent: "x.do", Result: "ok", LatencyMs: 12}, + }} + h := auditListHandler(p) + got, err := h(context.Background(), AuditListInput{Limit: 50}, contract.Principal{}) + if err != nil { + t.Fatalf("audit.list: %v", err) + } + if got.Total != 1 || len(got.Records) != 1 { + t.Fatalf("expected 1 record, got %+v", got) + } + if got.Records[0].Time != now.Format(time.RFC3339Nano) { + t.Errorf("time projection wrong: %q", got.Records[0].Time) + } + if got.Records[0].Intent != "x.do" || got.Records[0].LatencyMs != 12 { + t.Errorf("record projection wrong: %+v", got.Records[0]) + } +} + +func TestAuditListHandler_NilProviderUnavailable(t *testing.T) { + _, err := auditListHandler(nil)(context.Background(), AuditListInput{}, contract.Principal{}) + ce, ok := err.(*contract.Error) + if !ok || ce.Code != contract.CodeUnavailable { + t.Errorf("expected CodeUnavailable, got %v", err) + } +} + +func TestAuditTailSub_StreamsAppends(t *testing.T) { + store := contract.NewInMemoryAuditStore(0) + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + out, stop, err := auditTailSub(adaptStore(store))(ctx, struct{}{}, contract.Principal{}) + if err != nil { + t.Fatalf("subscribe: %v", err) + } + defer stop() + + store.Append(contract.AuditRecord{Time: time.Now(), Intent: "live"}) + select { + case ev := <-out: + if ev.Intent != "live" { + t.Errorf("intent = %q", ev.Intent) + } + case <-time.After(time.Second): + t.Fatal("no event") + } +} + +func TestAuditTailSub_NilProviderUnavailable(t *testing.T) { + _, _, err := auditTailSub(nil)(context.Background(), struct{}{}, contract.Principal{}) + ce, ok := err.(*contract.Error) + if !ok || ce.Code != contract.CodeUnavailable { + t.Errorf("expected CodeUnavailable, got %v", err) + } +} + +// adaptStore exposes the contract.AuditStore as the AuditProvider the pilot +// handler expects. The two interfaces share a method set; this is a +// compile-time check. +func adaptStore(s contract.AuditStore) AuditProvider { return s } 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/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/graph_test.go b/extensions/dashboard/contract/pilot/graph_test.go new file mode 100644 index 00000000..384386ad --- /dev/null +++ b/extensions/dashboard/contract/pilot/graph_test.go @@ -0,0 +1,131 @@ +package pilot + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "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" +) + +// graphEnv is the smallest setup that registers the slice-(c)/(h) pilot and +// exposes the transport handler so tests can POST kind=graph envelopes. +func graphEnv(t *testing.T) http.Handler { + t.Helper() + d := dispatcher.New(dispatcher.NoopMetricsEmitter{}) + reg := contract.NewRegistry() + wreg := contract.NewWardenRegistry() + deps := Deps{ + ExtensionsRegistry: newRegistryWith(t), + Services: &stubServices{list: []collector.ServiceInfo{}}, + Metrics: &stubMetrics{data: &collector.MetricsData{}}, + MetricsInterval: 50 * time.Millisecond, + } + if err := Register(d, reg, wreg, deps); err != nil { + t.Fatalf("pilot register: %v", err) + } + return transport.NewHandler(reg, wreg, d, contract.NoopAuditEmitter{}) +} + +func postGraph(t *testing.T, h http.Handler, route string) *httptest.ResponseRecorder { + t.Helper() + body, _ := json.Marshal(contract.Request{ + Envelope: "v1", + Kind: contract.KindGraph, + Contributor: "core-contract", + Intent: "page.shell", + Payload: json.RawMessage(`{"route":"` + route + `"}`), + }) + req := httptest.NewRequest(http.MethodPost, "/api/dashboard/v1", bytes.NewReader(body)) + w := httptest.NewRecorder() + h.ServeHTTP(w, req) + return w +} + +func TestGraphHandler_ReturnsGraphForExactRoute(t *testing.T) { + h := graphEnv(t) + w := postGraph(t, h, "/health") + 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.Fatalf("ok = false body=%s", w.Body) + } + var node contract.GraphNode + if err := json.Unmarshal(resp.Data, &node); err != nil { + t.Fatalf("data unmarshal: %v", err) + } + if node.Route != "/health" || node.Intent != "page.shell" { + t.Errorf("unexpected node: route=%s intent=%s", node.Route, node.Intent) + } + if len(resp.Meta.RouteParams) != 0 { + t.Errorf("expected empty params for exact match, got %v", resp.Meta.RouteParams) + } +} + +func TestGraphHandler_ParamRouteExtractsID(t *testing.T) { + h := graphEnv(t) + w := postGraph(t, h, "/traces/abc123") + 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.Fatalf("ok = false body=%s", w.Body) + } + var node contract.GraphNode + if err := json.Unmarshal(resp.Data, &node); err != nil { + t.Fatalf("data unmarshal: %v", err) + } + if node.Route != "/traces/:id" { + t.Errorf("expected /traces/:id, got %q", node.Route) + } + if got := resp.Meta.RouteParams["id"]; got != "abc123" { + t.Errorf("route params id = %q, want abc123 (full = %v)", got, resp.Meta.RouteParams) + } +} + +func TestGraphHandler_NotFoundForUnknownRoute(t *testing.T) { + h := graphEnv(t) + w := postGraph(t, h, "/no/such/route") + if w.Code != http.StatusNotFound { + t.Fatalf("expected 404, got %d body=%s", w.Code, w.Body) + } + var er contract.ErrorResponse + if err := json.Unmarshal(w.Body.Bytes(), &er); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if er.OK || er.Error == nil || er.Error.Code != contract.CodeNotFound { + t.Errorf("expected NOT_FOUND envelope, got %+v", er) + } +} + +func TestGraphHandler_PrefersExactOverParam(t *testing.T) { + h := graphEnv(t) + w := postGraph(t, h, "/traces") + var resp contract.Response + if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { + t.Fatalf("unmarshal: %v", err) + } + var node contract.GraphNode + _ = json.Unmarshal(resp.Data, &node) + if node.Route != "/traces" { + t.Errorf("expected exact /traces, got %q", node.Route) + } + if len(resp.Meta.RouteParams) != 0 { + t.Errorf("exact match should have no params, got %v", resp.Meta.RouteParams) + } +} diff --git a/extensions/dashboard/contract/pilot/health.go b/extensions/dashboard/contract/pilot/health.go new file mode 100644 index 00000000..4f31adb7 --- /dev/null +++ b/extensions/dashboard/contract/pilot/health.go @@ -0,0 +1,41 @@ +package pilot + +import ( + "context" + + "github.com/xraph/forge/extensions/dashboard/collector" + "github.com/xraph/forge/extensions/dashboard/contract" +) + +// HealthProvider is the slice of DataCollector health.go reads. +type HealthProvider interface { + CollectHealth(ctx context.Context) *collector.HealthData +} + +func healthHandler(p HealthProvider) func(ctx context.Context, _ struct{}, _ contract.Principal) (HealthList, error) { + return func(ctx context.Context, _ struct{}, _ contract.Principal) (HealthList, error) { + if p == nil { + return HealthList{}, &contract.Error{Code: contract.CodeUnavailable, Message: "health provider not configured"} + } + data := p.CollectHealth(ctx) + if data == nil { + return HealthList{}, nil + } + entries := make([]HealthEntry, 0, len(data.Services)) + for _, svc := range data.Services { + entries = append(entries, HealthEntry{ + Name: svc.Name, + Status: svc.Status, + Message: svc.Message, + DurationMs: svc.Duration.Milliseconds(), + Critical: svc.Critical, + }) + } + return HealthList{ + OverallStatus: data.OverallStatus, + HealthySummary: data.Summary.Healthy, + Total: data.Summary.Total, + Services: entries, + }, nil + } +} diff --git a/extensions/dashboard/contract/pilot/manifest.yaml b/extensions/dashboard/contract/pilot/manifest.yaml new file mode 100644 index 00000000..8c370a22 --- /dev/null +++ b/extensions/dashboard/contract/pilot/manifest.yaml @@ -0,0 +1,218 @@ +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 } + overview: + intent: overview + cache: { staleTime: 5s } + health: + intent: health + cache: { staleTime: 5s } + metricsReport: + intent: metrics-report + cache: { staleTime: 10s } + tracesList: + intent: traces.list + cache: { staleTime: 5s } + auditList: + intent: audit.list + cache: { staleTime: 5s } + navigation: + intent: navigation + cache: { staleTime: 60s } + +intents: + # Slice (c) original + - { 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 } + + # Slice (h) additions + - { name: overview, kind: query, version: 1, capability: read } + - { name: health, kind: query, version: 1, capability: read } + - { name: metrics-report, kind: query, version: 1, capability: read } + - { name: traces.list, kind: query, version: 1, capability: read } + - { name: traces.detail, kind: query, version: 1, capability: read } + + # Slice (k) audit + - { name: audit.list, kind: query, version: 1, capability: read } + - { name: audit.tail, kind: subscription, version: 1, capability: read, mode: append } + + # Slice (l) navigation + - { name: navigation, kind: query, version: 1, capability: read } + +graph: + - route: / + intent: page.shell + title: Overview + nav: { group: Overview, icon: home, priority: 0 } + slots: + main: + - intent: dashboard.grid + props: { columns: 4 } + slots: + widgets: + - intent: metric.counter + title: Overall Health + data: queries.overview + props: { title: Overall Health, field: overallHealth } + - intent: metric.counter + title: Total Services + data: queries.overview + props: { title: Total Services, field: totalServices } + - intent: metric.counter + title: Healthy Services + data: queries.overview + props: { title: Healthy Services, field: healthyServices } + - intent: metric.counter + title: Total Metrics + data: queries.overview + props: { title: Total Metrics, field: totalMetrics } + - intent: metric.counter + title: Uptime (s) + data: queries.overview + props: { title: Uptime (s), field: uptimeSeconds } + - intent: metric.counter + title: Version + data: queries.overview + props: { title: Version, field: version } + + - route: /health + intent: page.shell + title: Health + nav: { group: Overview, icon: heart-pulse, priority: 1 } + slots: + main: + - intent: resource.list + data: queries.health + props: + columns: [name, status, message, durationMs, critical] + emptyMessage: "No health checks reported." + + - route: /metrics + intent: page.shell + title: Metrics + nav: { group: Overview, icon: chart-bar, priority: 2 } + slots: + main: + - intent: resource.list + data: queries.metricsReport + title: Collectors + props: + columns: [name, type, metricsCount, status] + emptyMessage: "No collectors registered." + field: collectors + - intent: resource.list + data: queries.metricsReport + title: Top Metrics + props: + columns: [name, type, value] + emptyMessage: "No metrics yet." + field: topMetrics + + - route: /traces + intent: page.shell + title: Traces + nav: { group: Overview, icon: scan-search, priority: 3 } + slots: + main: + - intent: resource.list + data: queries.tracesList + props: + columns: [traceID, rootSpanName, spanCount, durationMs, status, protocol] + emptyMessage: "No traces captured." + detailTitle: Trace + slots: + detailDrawer: + - intent: resource.detail + data: + intent: traces.detail + params: { id: { from: parent.traceID } } + props: + fields: [traceID, root_span, span_count, duration, status, start_time, protocol] + + # Slice (j) deep-link detail route. Direct navigation to /traces/ + # renders a full-page trace detail. The drawer-from-list pattern above stays + # for in-flow inspection; this route covers shareable URLs and the slice (i) + # redirect from /dashboard/traces/:id. + - route: /traces/:id + intent: page.shell + title: Trace + slots: + main: + - intent: resource.detail + data: + intent: traces.detail + params: { id: { from: route.id } } + props: + fields: [traceID, root_span, span_count, duration, status, start_time, protocol] + + - 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 + + # Slice (k) audit page. Live tail of every audited command run anywhere in + # the dashboard contract path. Slice (k2) adds a history view backed by + # audit.list when we ship a `resource.list`-style audit page. + - route: /audit + intent: page.shell + title: Audit + nav: { group: Operations, icon: history, priority: 23 } + slots: + main: + - intent: audit.tail + title: Live audit + data: + intent: audit.tail + props: + bufferSize: 200 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_report.go b/extensions/dashboard/contract/pilot/metrics_report.go new file mode 100644 index 00000000..699d75e5 --- /dev/null +++ b/extensions/dashboard/contract/pilot/metrics_report.go @@ -0,0 +1,57 @@ +package pilot + +import ( + "context" + "time" + + "github.com/xraph/forge/extensions/dashboard/collector" + "github.com/xraph/forge/extensions/dashboard/contract" +) + +// MetricsReportProvider is the slice of DataCollector metrics_report.go reads. +type MetricsReportProvider interface { + CollectMetricsReport(ctx context.Context) *collector.MetricsReport +} + +func metricsReportHandler(p MetricsReportProvider) func(ctx context.Context, _ struct{}, _ contract.Principal) (MetricsReportResponse, error) { + return func(ctx context.Context, _ struct{}, _ contract.Principal) (MetricsReportResponse, error) { + if p == nil { + return MetricsReportResponse{}, &contract.Error{Code: contract.CodeUnavailable, Message: "metrics report provider not configured"} + } + report := p.CollectMetricsReport(ctx) + if report == nil { + return MetricsReportResponse{}, nil + } + collectors := make([]CollectorEntry, 0, len(report.Collectors)) + for _, c := range report.Collectors { + collectors = append(collectors, CollectorEntry{ + Name: c.Name, + Type: c.Type, + MetricsCount: c.MetricsCount, + Status: c.Status, + LastCollection: formatTime(c.LastCollection), + }) + } + topMetrics := make([]MetricEntryDTO, 0, len(report.TopMetrics)) + for _, m := range report.TopMetrics { + topMetrics = append(topMetrics, MetricEntryDTO{ + Name: m.Name, + Type: m.Type, + Value: m.Value, + }) + } + return MetricsReportResponse{ + TotalMetrics: report.TotalMetrics, + MetricsByType: report.MetricsByType, + Collectors: collectors, + TopMetrics: topMetrics, + }, nil + } +} + +func formatTime(t time.Time) string { + if t.IsZero() { + return "" + } + return t.UTC().Format(time.RFC3339) +} 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") + } + } +} diff --git a/extensions/dashboard/contract/pilot/navigation.go b/extensions/dashboard/contract/pilot/navigation.go new file mode 100644 index 00000000..1292d11d --- /dev/null +++ b/extensions/dashboard/contract/pilot/navigation.go @@ -0,0 +1,106 @@ +package pilot + +import ( + "context" + "sort" + + "github.com/xraph/forge/extensions/dashboard/contract" +) + +// NavItem is one renderable sidebar entry projected from a graph route's +// NavConfig. The shell joins href with its own basePath via NavLink so we +// emit the route as-is (no /dashboard prefix bound at server time). +type NavItem struct { + Label string `json:"label"` + Href string `json:"href"` + Icon string `json:"icon,omitempty"` + Badge string `json:"badge,omitempty"` + Priority int `json:"priority"` +} + +// NavGroup bundles items contributed under the same Nav.Group, sorted by +// priority. Group order follows navGroupOrder below; unknown groups go last. +type NavGroup struct { + Group string `json:"group"` + Priority int `json:"priority"` + Items []NavItem `json:"items"` +} + +// NavigationResponse is the wire shape of the navigation query. +type NavigationResponse struct { + Groups []NavGroup `json:"groups"` +} + +// navGroupOrder mirrors contributor/registry.go's groupOrder so the contract +// sidebar matches the legacy templ sidebar's group order. Kept in-package +// rather than imported because the contributor map is unexported. +var navGroupOrder = map[string]int{ + "Overview": 0, + "Identity": 1, + "Security": 2, + "AI": 3, + "Platform": 4, + "Configuration": 5, + "Operations": 6, + "Infrastructure": 7, + "Plugins": 8, +} + +const navUnknownGroupBase = 1000 + +func navigationHandler(reg contract.Registry) func(ctx context.Context, _ struct{}, _ contract.Principal) (NavigationResponse, error) { + return func(_ context.Context, _ struct{}, _ contract.Principal) (NavigationResponse, error) { + if reg == nil { + return NavigationResponse{}, &contract.Error{Code: contract.CodeUnavailable, Message: "registry not configured"} + } + groupMap := map[string]*NavGroup{} + for _, m := range reg.All() { + for _, n := range m.Graph { + if n.Nav == nil { + continue + } + label := n.Title + if label == "" { + label = n.Route + } + groupName := n.Nav.Group + if groupName == "" { + groupName = "Other" + } + g, ok := groupMap[groupName] + if !ok { + prio, known := navGroupOrder[groupName] + if !known { + prio = navUnknownGroupBase + len(groupMap) + } + g = &NavGroup{Group: groupName, Priority: prio} + groupMap[groupName] = g + } + g.Items = append(g.Items, NavItem{ + Label: label, + Href: n.Route, + Icon: n.Nav.Icon, + Badge: n.Nav.Badge, + Priority: n.Nav.Priority, + }) + } + } + groups := make([]NavGroup, 0, len(groupMap)) + for _, g := range groupMap { + sort.SliceStable(g.Items, func(i, j int) bool { + if g.Items[i].Priority != g.Items[j].Priority { + return g.Items[i].Priority < g.Items[j].Priority + } + return g.Items[i].Label < g.Items[j].Label + }) + groups = append(groups, *g) + } + sort.SliceStable(groups, func(i, j int) bool { + if groups[i].Priority != groups[j].Priority { + return groups[i].Priority < groups[j].Priority + } + return groups[i].Group < groups[j].Group + }) + return NavigationResponse{Groups: groups}, nil + } +} diff --git a/extensions/dashboard/contract/pilot/navigation_test.go b/extensions/dashboard/contract/pilot/navigation_test.go new file mode 100644 index 00000000..4681c52c --- /dev/null +++ b/extensions/dashboard/contract/pilot/navigation_test.go @@ -0,0 +1,86 @@ +package pilot + +import ( + "context" + "strings" + "testing" + + "github.com/xraph/forge/extensions/dashboard/contract" + "github.com/xraph/forge/extensions/dashboard/contract/loader" +) + +func loadPilotIntoRegistry(t *testing.T) contract.Registry { + t.Helper() + reg := contract.NewRegistry() + wreg := contract.NewWardenRegistry() + m, err := loader.Load(strings.NewReader(string(manifestYAML)), "pilot/manifest.yaml") + if err != nil { + t.Fatalf("load: %v", err) + } + if err := loader.Validate(m, wreg); err != nil { + t.Fatalf("validate: %v", err) + } + if err := reg.Register(m); err != nil { + t.Fatalf("register: %v", err) + } + return reg +} + +func TestNavigationHandler_GroupsAndSorts(t *testing.T) { + reg := loadPilotIntoRegistry(t) + got, err := navigationHandler(reg)(context.Background(), struct{}{}, contract.Principal{}) + if err != nil { + t.Fatalf("navigation: %v", err) + } + if len(got.Groups) == 0 { + t.Fatal("expected at least one group") + } + // Overview should come before Operations (priority 0 vs 6). + overviewIdx, opsIdx := -1, -1 + for i, g := range got.Groups { + if g.Group == "Overview" { + overviewIdx = i + } + if g.Group == "Operations" { + opsIdx = i + } + } + if overviewIdx < 0 || opsIdx < 0 { + t.Fatalf("expected Overview + Operations groups, got %+v", got.Groups) + } + if overviewIdx >= opsIdx { + t.Errorf("Overview (%d) should come before Operations (%d)", overviewIdx, opsIdx) + } + // Within Overview, items should be priority-sorted. + for _, g := range got.Groups { + if g.Group != "Overview" { + continue + } + for i := 1; i < len(g.Items); i++ { + if g.Items[i].Priority < g.Items[i-1].Priority { + t.Errorf("Overview items not priority-sorted: %+v", g.Items) + } + } + } +} + +func TestNavigationHandler_SkipsRoutesWithoutNav(t *testing.T) { + reg := loadPilotIntoRegistry(t) + got, _ := navigationHandler(reg)(context.Background(), struct{}{}, contract.Principal{}) + // /traces/:id has no Nav (slice j detail route) — make sure it doesn't appear. + for _, g := range got.Groups { + for _, item := range g.Items { + if item.Href == "/traces/:id" { + t.Errorf("nav included :id detail route: %+v", item) + } + } + } +} + +func TestNavigationHandler_NilRegistryUnavailable(t *testing.T) { + _, err := navigationHandler(nil)(context.Background(), struct{}{}, contract.Principal{}) + ce, ok := err.(*contract.Error) + if !ok || ce.Code != contract.CodeUnavailable { + t.Errorf("expected CodeUnavailable, got %v", err) + } +} diff --git a/extensions/dashboard/contract/pilot/overview.go b/extensions/dashboard/contract/pilot/overview.go new file mode 100644 index 00000000..1cc86888 --- /dev/null +++ b/extensions/dashboard/contract/pilot/overview.go @@ -0,0 +1,37 @@ +package pilot + +import ( + "context" + + "github.com/xraph/forge/extensions/dashboard/collector" + "github.com/xraph/forge/extensions/dashboard/contract" +) + +// OverviewProvider is the slice of DataCollector overview.go reads. Splitting +// the surface keeps tests fixture-friendly without dragging in the full +// DataCollector wiring. +type OverviewProvider interface { + CollectOverview(ctx context.Context) *collector.OverviewData +} + +func overviewHandler(p OverviewProvider) func(ctx context.Context, _ struct{}, _ contract.Principal) (OverviewResponse, error) { + return func(ctx context.Context, _ struct{}, _ contract.Principal) (OverviewResponse, error) { + if p == nil { + return OverviewResponse{}, &contract.Error{Code: contract.CodeUnavailable, Message: "overview provider not configured"} + } + data := p.CollectOverview(ctx) + if data == nil { + return OverviewResponse{}, nil + } + return OverviewResponse{ + OverallHealth: data.OverallHealth, + TotalServices: data.TotalServices, + HealthyServices: data.HealthyServices, + TotalMetrics: data.TotalMetrics, + UptimeSeconds: int64(data.Uptime.Seconds()), + Version: data.Version, + Environment: data.Environment, + Summary: data.Summary, + }, nil + } +} diff --git a/extensions/dashboard/contract/pilot/pilot.go b/extensions/dashboard/contract/pilot/pilot.go new file mode 100644 index 00000000..a9f24efc --- /dev/null +++ b/extensions/dashboard/contract/pilot/pilot.go @@ -0,0 +1,120 @@ +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. +// +// Slice (c) introduced ExtensionsRegistry / Services / Metrics. Slice (h) +// adds Overview / Health / MetricsReport / Traces so the pilot covers every +// page CoreContributor serves today; nil providers are tolerated and the +// corresponding handlers return CodeUnavailable. +type Deps struct { + ExtensionsRegistry *contributor.ContributorRegistry + Services ServicesProvider + Metrics MetricsProvider + Overview OverviewProvider + Health HealthProvider + MetricsReport MetricsReportProvider + Traces TracesProvider + // Audit is the slice (k) audit store. nil yields CodeUnavailable on + // audit.list / audit.tail; the rest of the pilot stays functional. + Audit AuditProvider + // 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 + } + + // Slice (h) registrations. Provider nil-checks happen inside each handler + // (returning CodeUnavailable), so partial wiring during a rollout is OK. + if err := dispatcher.RegisterQuery(d, c, "overview", 1, overviewHandler(deps.Overview)); err != nil { + return err + } + if err := dispatcher.RegisterQuery(d, c, "health", 1, healthHandler(deps.Health)); err != nil { + return err + } + if err := dispatcher.RegisterQuery(d, c, "metrics-report", 1, metricsReportHandler(deps.MetricsReport)); err != nil { + return err + } + if err := dispatcher.RegisterQuery(d, c, "traces.list", 1, tracesListHandler(deps.Traces)); err != nil { + return err + } + if err := dispatcher.RegisterQuery(d, c, "traces.detail", 1, traceDetailHandler(deps.Traces)); err != nil { + return err + } + + // Slice (k) audit. nil-tolerant: handlers return CodeUnavailable if the + // store wasn't wired (e.g. tests of unrelated handlers). + if err := dispatcher.RegisterQuery(d, c, "audit.list", 1, auditListHandler(deps.Audit)); err != nil { + return err + } + if err := dispatcher.RegisterSubscription(d, c, "audit.tail", 1, auditTailSub(deps.Audit)); err != nil { + return err + } + // Slice (l) navigation. Walks the merged contract registry to produce the + // pre-grouped sidebar payload the React shell renders. + if err := dispatcher.RegisterQuery(d, c, "navigation", 1, navigationHandler(contractReg)); err != nil { + return err + } + return nil +} 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) + } +} 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/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) + } +} diff --git a/extensions/dashboard/contract/pilot/slice_h_test.go b/extensions/dashboard/contract/pilot/slice_h_test.go new file mode 100644 index 00000000..293cb0ce --- /dev/null +++ b/extensions/dashboard/contract/pilot/slice_h_test.go @@ -0,0 +1,167 @@ +package pilot + +import ( + "context" + "testing" + "time" + + "github.com/xraph/forge/extensions/dashboard/collector" + "github.com/xraph/forge/extensions/dashboard/contract" +) + +// --- stub providers -------------------------------------------------------- + +type stubOverview struct{ data *collector.OverviewData } + +func (s *stubOverview) CollectOverview(_ context.Context) *collector.OverviewData { return s.data } + +type stubHealth struct{ data *collector.HealthData } + +func (s *stubHealth) CollectHealth(_ context.Context) *collector.HealthData { return s.data } + +type stubMetricsReport struct{ data *collector.MetricsReport } + +func (s *stubMetricsReport) CollectMetricsReport(_ context.Context) *collector.MetricsReport { + return s.data +} + +type stubTraces struct { + list []collector.TraceSummary + stats collector.TraceStats + byID map[string]*collector.TraceDetail +} + +func (s *stubTraces) ListTraces(_ collector.TraceFilter) ([]collector.TraceSummary, collector.TraceStats) { + return s.list, s.stats +} +func (s *stubTraces) GetTrace(id string) *collector.TraceDetail { return s.byID[id] } + +// --- tests ----------------------------------------------------------------- + +func TestOverviewHandler(t *testing.T) { + p := &stubOverview{data: &collector.OverviewData{ + OverallHealth: "healthy", TotalServices: 5, HealthyServices: 4, + TotalMetrics: 42, Uptime: 90 * time.Second, + Version: "v1", Environment: "dev", + }} + h := overviewHandler(p) + got, err := h(context.Background(), struct{}{}, contract.Principal{}) + if err != nil { + t.Fatalf("overview: %v", err) + } + if got.OverallHealth != "healthy" || got.TotalServices != 5 || got.HealthyServices != 4 || got.UptimeSeconds != 90 { + t.Errorf("projection wrong: %+v", got) + } +} + +func TestOverviewHandler_NilProviderIsUnavailable(t *testing.T) { + _, err := overviewHandler(nil)(context.Background(), struct{}{}, contract.Principal{}) + if ce, ok := err.(*contract.Error); !ok || ce.Code != contract.CodeUnavailable { + t.Errorf("expected CodeUnavailable, got %v", err) + } +} + +func TestHealthHandler_FlattensServicesMap(t *testing.T) { + p := &stubHealth{data: &collector.HealthData{ + OverallStatus: "degraded", + Services: map[string]collector.ServiceHealth{ + "db": {Name: "db", Status: "healthy", Duration: 12 * time.Millisecond}, + "cache": {Name: "cache", Status: "degraded", Message: "slow", Duration: 200 * time.Millisecond, Critical: true}, + }, + Summary: collector.HealthSummary{Healthy: 1, Degraded: 1, Total: 2}, + }} + h := healthHandler(p) + got, err := h(context.Background(), struct{}{}, contract.Principal{}) + if err != nil { + t.Fatalf("health: %v", err) + } + if got.OverallStatus != "degraded" || got.Total != 2 || got.HealthySummary != 1 { + t.Errorf("summary wrong: %+v", got) + } + if len(got.Services) != 2 { + t.Fatalf("expected 2 entries, got %d", len(got.Services)) + } + for _, e := range got.Services { + if e.Name == "cache" { + if e.DurationMs != 200 || !e.Critical || e.Message != "slow" { + t.Errorf("cache entry projected wrong: %+v", e) + } + } + } +} + +func TestMetricsReportHandler(t *testing.T) { + p := &stubMetricsReport{data: &collector.MetricsReport{ + TotalMetrics: 3, + MetricsByType: map[string]int{"counter": 2, "gauge": 1}, + Collectors: []collector.CollectorInfo{ + {Name: "default", Type: "internal", MetricsCount: 3, Status: "active", LastCollection: time.Now()}, + }, + TopMetrics: []collector.MetricEntry{ + {Name: "requests", Type: "counter", Value: 100}, + }, + }} + h := metricsReportHandler(p) + got, err := h(context.Background(), struct{}{}, contract.Principal{}) + if err != nil { + t.Fatalf("metrics-report: %v", err) + } + if got.TotalMetrics != 3 || len(got.Collectors) != 1 || len(got.TopMetrics) != 1 { + t.Errorf("projection wrong: %+v", got) + } + if got.Collectors[0].LastCollection == "" { + t.Errorf("LastCollection should be RFC3339 formatted, got empty") + } +} + +func TestTracesListHandler(t *testing.T) { + p := &stubTraces{ + list: []collector.TraceSummary{ + {TraceID: "t1", RootSpanName: "GET /x", SpanCount: 5, Duration: 25 * time.Millisecond, Protocol: "REST"}, + {TraceID: "t2", RootSpanName: "ws.connect", SpanCount: 2, Duration: 10 * time.Millisecond, Protocol: "WS"}, + }, + stats: collector.TraceStats{TotalTraces: 2}, + } + h := tracesListHandler(p) + got, err := h(context.Background(), struct{}{}, contract.Principal{}) + if err != nil { + t.Fatalf("traces.list: %v", err) + } + if len(got.Traces) != 2 || got.Total != 2 { + t.Errorf("projection wrong: %+v", got) + } + if got.Traces[0].DurationMs != 25 { + t.Errorf("duration projection wrong: %+v", got.Traces[0]) + } +} + +func TestTraceDetailHandler_Found(t *testing.T) { + p := &stubTraces{byID: map[string]*collector.TraceDetail{ + "t1": {TraceID: "t1", SpanCount: 3}, + }} + h := traceDetailHandler(p) + got, err := h(context.Background(), TraceDetailInput{ID: "t1"}, contract.Principal{}) + if err != nil { + t.Fatalf("trace.detail: %v", err) + } + if got == nil || got.TraceID != "t1" { + t.Errorf("expected detail for t1, got %+v", got) + } +} + +func TestTraceDetailHandler_NotFound(t *testing.T) { + p := &stubTraces{byID: map[string]*collector.TraceDetail{}} + h := traceDetailHandler(p) + _, err := h(context.Background(), TraceDetailInput{ID: "missing"}, contract.Principal{}) + if ce, ok := err.(*contract.Error); !ok || ce.Code != contract.CodeNotFound { + t.Errorf("expected CodeNotFound, got %v", err) + } +} + +func TestTraceDetailHandler_BadRequestOnEmptyID(t *testing.T) { + p := &stubTraces{} + _, err := traceDetailHandler(p)(context.Background(), TraceDetailInput{ID: ""}, contract.Principal{}) + if ce, ok := err.(*contract.Error); !ok || ce.Code != contract.CodeBadRequest { + t.Errorf("expected CodeBadRequest, got %v", err) + } +} diff --git a/extensions/dashboard/contract/pilot/traces.go b/extensions/dashboard/contract/pilot/traces.go new file mode 100644 index 00000000..91acf44c --- /dev/null +++ b/extensions/dashboard/contract/pilot/traces.go @@ -0,0 +1,64 @@ +package pilot + +import ( + "context" + "time" + + "github.com/xraph/forge/extensions/dashboard/collector" + "github.com/xraph/forge/extensions/dashboard/contract" +) + +// TracesProvider is the slice of TraceStore traces.go reads. +type TracesProvider interface { + ListTraces(filter collector.TraceFilter) ([]collector.TraceSummary, collector.TraceStats) + GetTrace(traceID string) *collector.TraceDetail +} + +func spanStatusString(s collector.SpanStatus) string { + switch s { + case collector.SpanStatusOK: + return "ok" + case collector.SpanStatusError: + return "error" + default: + return "unset" + } +} + +func tracesListHandler(p TracesProvider) func(ctx context.Context, _ struct{}, _ contract.Principal) (TracesList, error) { + return func(_ context.Context, _ struct{}, _ contract.Principal) (TracesList, error) { + if p == nil { + return TracesList{}, &contract.Error{Code: contract.CodeUnavailable, Message: "traces provider not configured"} + } + summaries, stats := p.ListTraces(collector.TraceFilter{Limit: 200}) + out := make([]TraceSummaryDTO, 0, len(summaries)) + for _, s := range summaries { + out = append(out, TraceSummaryDTO{ + TraceID: s.TraceID, + RootSpanName: s.RootSpanName, + SpanCount: s.SpanCount, + DurationMs: s.Duration.Milliseconds(), + Status: spanStatusString(s.Status), + StartTime: s.StartTime.UTC().Format(time.RFC3339Nano), + Protocol: s.Protocol, + }) + } + return TracesList{Traces: out, Total: stats.TotalTraces}, nil + } +} + +func traceDetailHandler(p TracesProvider) func(ctx context.Context, in TraceDetailInput, _ contract.Principal) (*TraceDetailResponse, error) { + return func(_ context.Context, in TraceDetailInput, _ contract.Principal) (*TraceDetailResponse, error) { + if in.ID == "" { + return nil, &contract.Error{Code: contract.CodeBadRequest, Message: "id is required"} + } + if p == nil { + return nil, &contract.Error{Code: contract.CodeUnavailable, Message: "traces provider not configured"} + } + detail := p.GetTrace(in.ID) + if detail == nil { + return nil, &contract.Error{Code: contract.CodeNotFound, Message: "trace " + in.ID + " not found"} + } + return detail, nil + } +} diff --git a/extensions/dashboard/contract/pilot/types.go b/extensions/dashboard/contract/pilot/types.go new file mode 100644 index 00000000..6c026f68 --- /dev/null +++ b/extensions/dashboard/contract/pilot/types.go @@ -0,0 +1,124 @@ +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 +} + +// --- slice (h) wire shapes --- + +// OverviewResponse is the wire shape for the overview query. +type OverviewResponse struct { + OverallHealth string `json:"overallHealth"` + TotalServices int `json:"totalServices"` + HealthyServices int `json:"healthyServices"` + TotalMetrics int `json:"totalMetrics"` + UptimeSeconds int64 `json:"uptimeSeconds"` + Version string `json:"version"` + Environment string `json:"environment"` + Summary map[string]any `json:"summary,omitempty"` +} + +// HealthEntry is one row of the health.list query — flattened from the per- +// service map the collector returns so resource.list can render it. +type HealthEntry struct { + Name string `json:"name"` + Status string `json:"status"` + Message string `json:"message,omitempty"` + DurationMs int64 `json:"durationMs"` + Critical bool `json:"critical"` +} + +// HealthList is the response payload for the health query. +type HealthList struct { + OverallStatus string `json:"overallStatus"` + HealthySummary int `json:"healthySummary"` + Total int `json:"total"` + Services []HealthEntry `json:"services"` +} + +// CollectorEntry is one row of the metrics-report collectors list. +type CollectorEntry struct { + Name string `json:"name"` + Type string `json:"type"` + MetricsCount int `json:"metricsCount"` + Status string `json:"status"` + LastCollection string `json:"lastCollection,omitempty"` +} + +// MetricEntryDTO is one row of the metrics-report top metrics list. +type MetricEntryDTO struct { + Name string `json:"name"` + Type string `json:"type"` + Value any `json:"value,omitempty"` +} + +// MetricsReportResponse is the wire shape for the metrics-report query. +type MetricsReportResponse struct { + TotalMetrics int `json:"totalMetrics"` + MetricsByType map[string]int `json:"metricsByType"` + Collectors []CollectorEntry `json:"collectors"` + TopMetrics []MetricEntryDTO `json:"topMetrics"` +} + +// TraceSummaryDTO is one row of traces.list. +type TraceSummaryDTO struct { + TraceID string `json:"traceID"` + RootSpanName string `json:"rootSpanName"` + SpanCount int `json:"spanCount"` + DurationMs int64 `json:"durationMs"` + Status string `json:"status"` + StartTime string `json:"startTime"` + Protocol string `json:"protocol"` +} + +// TracesList is the response for traces.list. +type TracesList struct { + Traces []TraceSummaryDTO `json:"traces"` + Total int `json:"total"` +} + +// TraceDetailInput is the input for traces.detail. +type TraceDetailInput struct { + ID string `json:"id"` +} + +// TraceDetailResponse is the wire shape for traces.detail. We reuse the +// collector's TraceDetail because its JSON tags are stable. +type TraceDetailResponse = collector.TraceDetail diff --git a/extensions/dashboard/contract/pilot/types_test.go b/extensions/dashboard/contract/pilot/types_test.go new file mode 100644 index 00000000..f270d18e --- /dev/null +++ b/extensions/dashboard/contract/pilot/types_test.go @@ -0,0 +1,69 @@ +package pilot + +import ( + "encoding/json" + "strings" + "testing" + + "github.com/xraph/forge/extensions/dashboard/contract" + "github.com/xraph/forge/extensions/dashboard/contract/loader" +) + +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)) + } +} + +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 != 12 { + t.Errorf("intents = %d, want 12", got) + } + if got := len(m.Graph); got != 9 { + t.Errorf("graph routes = %d, want 9", 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) + } +} 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/registry.go b/extensions/dashboard/contract/registry.go new file mode 100644 index 00000000..c51f0850 --- /dev/null +++ b/extensions/dashboard/contract/registry.go @@ -0,0 +1,342 @@ +// registry.go +package contract + +import ( + "fmt" + "net/http" + "strings" + "sync" +) + +// RemoteEndpoint describes how to reach a contract contributor that lives in +// another service. Slice (m) introduced this so the dispatcher's +// forwarding layer knows where to send envelopes for a contributor whose +// handlers are out-of-process. +type RemoteEndpoint struct { + // BaseURL is the upstream service's root, including any path prefix + // (e.g. https://svc.internal:8443 or /proxied/svc). The forwarding + // client appends "/_forge/contract/dispatch" for envelope POSTs and + // "/_forge/contract/manifest" for manifest fetches. + BaseURL string + + // APIKey, when non-empty, is sent as Authorization: Bearer on + // every forwarded envelope so the upstream can authenticate the + // dashboard. Inbound user headers (Authorization, Cookie) are still + // forwarded so the upstream sees the end-user identity too — the + // API key authenticates the dashboard itself; user identity flows in + // parallel. + APIKey string + + // Client overrides the http.Client used to talk to this remote. + // nil = a default client with a 10s timeout. + Client *http.Client +} + +// 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) + // MatchRoute is MergedGraph plus :name-style placeholder matching. On a + // match the returned map carries the extracted segment values keyed by + // placeholder name. Exact route matches return an empty (non-nil) map. + // Slice (j) added this for deep-link detail routes; MergedGraph remains + // for callers that don't care about params. + MatchRoute(contributor, route string) (*GraphNode, map[string]string, bool) + + // RegisterRemote records a contributor whose handlers live in another + // service. The manifest is registered identically to a local one so the + // graph endpoint and capabilities listing work uniformly; the endpoint + // is what the dispatcher's forwarding layer reads to know where to send + // envelopes. Slice (m) added this. + RegisterRemote(m *ContractManifest, endpoint RemoteEndpoint) error + + // IsRemote reports whether the named contributor was registered via + // RegisterRemote. + IsRemote(contributor string) bool + + // Remote returns the upstream endpoint for a contributor previously + // registered via RegisterRemote. ok is false for local contributors. + Remote(contributor string) (RemoteEndpoint, bool) + + // Unregister removes a contributor and all its intents + merged graph. + // Used by discovery loops to clean up offline remotes; safe to call + // for unknown names. + Unregister(contributor string) +} + +// 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{}, + remotes: map[string]RemoteEndpoint{}, + } +} + +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 + remotes map[string]RemoteEndpoint +} + +func (r *registry) Register(m *ContractManifest) error { + r.mu.Lock() + defer r.mu.Unlock() + return r.registerLocked(m) +} + +// registerLocked is the merge body shared by Register and RegisterRemote. +// Caller must hold r.mu for writes. +func (r *registry) registerLocked(m *ContractManifest) error { + if m == nil { + return fmt.Errorf("nil manifest") + } + name := m.Contributor.Name + if name == "" { + return fmt.Errorf("manifest missing contributor.name") + } + 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 +} + +// RegisterRemote registers a contributor whose handlers live in another +// service. The manifest is merged identically to a local Register call so +// the graph endpoint and capabilities listing surface the remote uniformly; +// the endpoint is recorded separately for the dispatcher's forwarding +// layer to look up at dispatch time. Slice (m) added this. +func (r *registry) RegisterRemote(m *ContractManifest, endpoint RemoteEndpoint) error { + if m == nil { + return fmt.Errorf("authsome/contract: nil remote manifest") + } + if endpoint.BaseURL == "" { + return fmt.Errorf("authsome/contract: RemoteEndpoint.BaseURL is required") + } + r.mu.Lock() + defer r.mu.Unlock() + if err := r.registerLocked(m); err != nil { + return err + } + r.remotes[m.Contributor.Name] = endpoint + return nil +} + +// IsRemote reports whether the named contributor was registered via +// RegisterRemote. +func (r *registry) IsRemote(contributor string) bool { + r.mu.RLock() + defer r.mu.RUnlock() + _, ok := r.remotes[contributor] + return ok +} + +// Remote returns the endpoint for a contributor previously registered via +// RegisterRemote. +func (r *registry) Remote(contributor string) (RemoteEndpoint, bool) { + r.mu.RLock() + defer r.mu.RUnlock() + ep, ok := r.remotes[contributor] + return ep, ok +} + +// Unregister removes a contributor and all derived state (intents, highest +// version map, merged graph, remote endpoint). Used by discovery loops when +// a remote goes offline. Safe for unknown names. +func (r *registry) Unregister(contributor string) { + r.mu.Lock() + defer r.mu.Unlock() + delete(r.contributors, contributor) + delete(r.mergedGraphs, contributor) + delete(r.remotes, contributor) + for k := range r.intents { + if k.contributor == contributor { + delete(r.intents, k) + } + } + prefix := contributor + ":" + for hk := range r.highest { + if strings.HasPrefix(hk, prefix) { + delete(r.highest, hk) + } + } +} + +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) { + n, _, ok := r.MatchRoute(contributor, route) + return n, ok +} + +// MatchRoute performs the same lookup as MergedGraph plus :name-style +// placeholder matching. Exact matches win over param matches. The returned +// map is non-nil on a successful match (empty for exact, populated for +// param-route matches). +func (r *registry) MatchRoute(contributor, route string) (*GraphNode, map[string]string, bool) { + r.mu.RLock() + defer r.mu.RUnlock() + g, ok := r.mergedGraphs[contributor] + if !ok { + return nil, nil, false + } + if route == "" { + if len(g) > 0 { + return &g[0], map[string]string{}, true + } + return nil, nil, false + } + // Pass 1: exact match wins. + for i := range g { + if g[i].Route == route { + return &g[i], map[string]string{}, true + } + } + // Pass 2: pattern match against routes that contain :placeholders. + for i := range g { + if !strings.Contains(g[i].Route, ":") { + continue + } + if params, matched := matchRoutePattern(g[i].Route, route); matched { + return &g[i], params, true + } + } + return nil, nil, false +} + +// matchRoutePattern matches a request URL against a route template containing +// :name placeholders. Returns the extracted name->value map and true on match. +// Both inputs are matched segment-by-segment after splitting on '/'; segment +// counts must agree and each :name segment captures exactly one URL segment. +// +// /traces/:id matches /traces/abc123 (params: id=abc123). +// /traces/:id does not match /traces or /traces/abc/extra. +func matchRoutePattern(pattern, path string) (map[string]string, bool) { + pSegs := splitRoute(pattern) + uSegs := splitRoute(path) + if len(pSegs) != len(uSegs) { + return nil, false + } + out := map[string]string{} + for i, ps := range pSegs { + us := uSegs[i] + if len(ps) > 0 && ps[0] == ':' { + if us == "" { + return nil, false + } + out[ps[1:]] = us + continue + } + if ps != us { + return nil, false + } + } + return out, true +} + +// splitRoute breaks a route on '/' and drops empty leading/trailing segments +// so /a/b and a/b/ both yield [a b]. +func splitRoute(s string) []string { + if s == "" { + return nil + } + parts := strings.Split(s, "/") + out := make([]string, 0, len(parts)) + for _, p := range parts { + if p == "" { + continue + } + out = append(out, p) + } + return out +} diff --git a/extensions/dashboard/contract/registry_remote_test.go b/extensions/dashboard/contract/registry_remote_test.go new file mode 100644 index 00000000..589e6b69 --- /dev/null +++ b/extensions/dashboard/contract/registry_remote_test.go @@ -0,0 +1,93 @@ +package contract_test + +import ( + "strings" + "testing" + + "github.com/xraph/forge/extensions/dashboard/contract" + "github.com/xraph/forge/extensions/dashboard/contract/loader" +) + +func tinyManifest(t *testing.T, name string) *contract.ContractManifest { + t.Helper() + yaml := ` +schemaVersion: 1 +contributor: { name: ` + name + `, envelope: { supports: [v1], preferred: v1 } } +intents: + - { name: ` + name + `.list, kind: query, version: 1, capability: read } +` + m, err := loader.Load(strings.NewReader(yaml), "test.yaml") + if err != nil { + t.Fatalf("load: %v", err) + } + return m +} + +func TestRegisterRemote_RecordsEndpoint(t *testing.T) { + r := contract.NewRegistry() + m := tinyManifest(t, "things") + ep := contract.RemoteEndpoint{BaseURL: "http://svc:8080", APIKey: "secret"} + if err := r.RegisterRemote(m, ep); err != nil { + t.Fatalf("register: %v", err) + } + if !r.IsRemote("things") { + t.Errorf("IsRemote(things) = false") + } + got, ok := r.Remote("things") + if !ok || got.BaseURL != "http://svc:8080" || got.APIKey != "secret" { + t.Errorf("endpoint round-trip wrong: %+v ok=%v", got, ok) + } + // Verify the manifest is registered like a local one — intents stay + // queryable so the dispatcher's lookup logic isn't bypassed. + if _, ok := r.Intent("things", "things.list", 1); !ok { + t.Errorf("intent lookup failed for remote") + } +} + +func TestRegisterRemote_LocalsAreNotRemote(t *testing.T) { + r := contract.NewRegistry() + m := tinyManifest(t, "things") + if err := r.Register(m); err != nil { + t.Fatalf("register: %v", err) + } + if r.IsRemote("things") { + t.Errorf("local contributor reported as remote") + } + if _, ok := r.Remote("things"); ok { + t.Errorf("Remote(local) returned ok=true") + } +} + +func TestRegisterRemote_RequiresBaseURL(t *testing.T) { + r := contract.NewRegistry() + m := tinyManifest(t, "things") + if err := r.RegisterRemote(m, contract.RemoteEndpoint{}); err == nil { + t.Errorf("expected error when BaseURL empty") + } +} + +func TestUnregister_ClearsAllState(t *testing.T) { + r := contract.NewRegistry() + m := tinyManifest(t, "things") + if err := r.RegisterRemote(m, contract.RemoteEndpoint{BaseURL: "http://x"}); err != nil { + t.Fatalf("register: %v", err) + } + r.Unregister("things") + if r.IsRemote("things") { + t.Errorf("IsRemote still true after Unregister") + } + if _, ok := r.Contributor("things"); ok { + t.Errorf("Contributor still returns ok after Unregister") + } + if _, ok := r.Intent("things", "things.list", 1); ok { + t.Errorf("Intent still resolvable after Unregister") + } + if _, ok := r.HighestVersion("things", "things.list"); ok { + t.Errorf("HighestVersion still present after Unregister") + } +} + +func TestUnregister_UnknownNameIsNoop(t *testing.T) { + r := contract.NewRegistry() + r.Unregister("nobody") // must not panic +} 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/remote/client.go b/extensions/dashboard/contract/remote/client.go new file mode 100644 index 00000000..22c50cd6 --- /dev/null +++ b/extensions/dashboard/contract/remote/client.go @@ -0,0 +1,154 @@ +// Package remote implements the contract dispatcher's HTTP forwarding layer. +// When a contributor's handlers live in another service, the host dashboard +// records its RemoteEndpoint via Registry.RegisterRemote; the +// ForwardingDispatcher reads that endpoint at dispatch time and forwards +// the verbatim envelope over HTTP. Slice (m) introduced this so a single +// dashboard can aggregate contributors from multiple upstream services +// without the transport layer caring whether a handler is local or remote. +package remote + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "strings" + "time" + + dashauth "github.com/xraph/forge/extensions/dashboard/auth" + "github.com/xraph/forge/extensions/dashboard/contract" +) + +// DefaultDispatchPath is appended to a RemoteEndpoint.BaseURL when forwarding +// a request envelope. +const DefaultDispatchPath = "/_forge/contract/dispatch" + +// DefaultManifestPath is appended to a RemoteEndpoint.BaseURL when fetching +// a contributor manifest during registration. +const DefaultManifestPath = "/_forge/contract/manifest" + +// DefaultTimeout is the HTTP client timeout used when RemoteEndpoint.Client +// is nil. Chosen to stay well below typical proxy / load-balancer idle cuts +// while leaving room for cold-path handlers. +const DefaultTimeout = 10 * time.Second + +// ForwardingDispatcher implements dispatcher.RemoteDispatcher by POSTing +// the verbatim contract envelope to the contributor's RemoteEndpoint. +// +// It looks the endpoint up via Registry.Remote, so registering a contributor +// (Registry.RegisterRemote) and wiring this dispatcher into the dispatcher +// (Dispatcher.SetRemoteDispatcher) are the two halves of the integration — +// nothing further is needed at the transport layer. +type ForwardingDispatcher struct { + reg contract.Registry + // dispatchPath overrides DefaultDispatchPath; primarily for tests. + dispatchPath string +} + +// Option configures a ForwardingDispatcher. +type Option func(*ForwardingDispatcher) + +// WithDispatchPath overrides the path appended to a RemoteEndpoint.BaseURL +// when forwarding envelopes. Useful when the upstream service mounts the +// contract server at a non-default location. +func WithDispatchPath(path string) Option { + return func(f *ForwardingDispatcher) { f.dispatchPath = path } +} + +// NewForwardingDispatcher returns a dispatcher that reads endpoints from +// the supplied registry. The registry is the source of truth for which +// contributors are remote — local contributors fall through with +// CodeNotFound so the host dispatcher's own NotFound handling kicks in. +func NewForwardingDispatcher(reg contract.Registry, opts ...Option) *ForwardingDispatcher { + f := &ForwardingDispatcher{reg: reg, dispatchPath: DefaultDispatchPath} + for _, o := range opts { + o(f) + } + return f +} + +// Dispatch implements dispatcher.RemoteDispatcher. Returns CodeNotFound +// when the contributor is not registered as a remote (the host dispatcher +// then surfaces its own NotFound). Network and decode failures are +// reported as CodeInternal; upstream-returned error envelopes are +// surfaced verbatim with their original code/message. +func (f *ForwardingDispatcher) Dispatch( + ctx context.Context, + req contract.Request, + _ contract.Principal, +) (json.RawMessage, contract.ResponseMeta, error) { + if f.reg == nil { + return nil, contract.ResponseMeta{}, &contract.Error{Code: contract.CodeInternal, Message: "forwarding dispatcher: nil registry"} + } + endpoint, ok := f.reg.Remote(req.Contributor) + if !ok { + return nil, contract.ResponseMeta{}, &contract.Error{ + Code: contract.CodeNotFound, + Message: fmt.Sprintf("contributor %q has no remote endpoint", req.Contributor), + } + } + + body, err := json.Marshal(req) + if err != nil { + return nil, contract.ResponseMeta{}, &contract.Error{Code: contract.CodeInternal, Message: "encode envelope: " + err.Error()} + } + + url := strings.TrimRight(endpoint.BaseURL, "/") + f.dispatchPath + hr, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body)) + if err != nil { + return nil, contract.ResponseMeta{}, &contract.Error{Code: contract.CodeInternal, Message: "build request: " + err.Error()} + } + hr.Header.Set("Content-Type", "application/json") + hr.Header.Set("Accept", "application/json") + + // Forward inbound auth headers so the upstream sees the end-user + // identity. Mirrors the legacy RemoteContributor.WithForwardedHeaders. + if in := dashauth.RequestFromContext(ctx); in != nil { + if auth := in.Header.Get("Authorization"); auth != "" { + hr.Header.Set("X-Forwarded-Authorization", auth) + } + if cookie := in.Header.Get("Cookie"); cookie != "" { + hr.Header.Set("X-Forwarded-Cookie", cookie) + } + } + // The dashboard's own credential authenticates the dashboard-to-service + // hop. Upstream identity goes in X-Forwarded-Authorization above. + if endpoint.APIKey != "" { + hr.Header.Set("Authorization", "Bearer "+endpoint.APIKey) + } + + client := endpoint.Client + if client == nil { + client = &http.Client{Timeout: DefaultTimeout} + } + + resp, err := client.Do(hr) + if err != nil { + return nil, contract.ResponseMeta{}, &contract.Error{Code: contract.CodeUnavailable, Message: "forward to " + req.Contributor + ": " + err.Error()} + } + defer resp.Body.Close() + + rb, err := io.ReadAll(resp.Body) + if err != nil { + return nil, contract.ResponseMeta{}, &contract.Error{Code: contract.CodeInternal, Message: "read upstream response: " + err.Error()} + } + + // First try the success envelope. On failure fall back to the error + // envelope shape — the upstream may return either with the same status + // per the contract. + var ok2 contract.Response + if jsonErr := json.Unmarshal(rb, &ok2); jsonErr == nil && ok2.OK { + return ok2.Data, ok2.Meta, nil + } + var er contract.ErrorResponse + if jsonErr := json.Unmarshal(rb, &er); jsonErr == nil && er.Error != nil { + // Surface upstream error code/message verbatim. + return nil, contract.ResponseMeta{}, er.Error + } + return nil, contract.ResponseMeta{}, &contract.Error{ + Code: contract.CodeInternal, + Message: fmt.Sprintf("upstream %s returned %d with undecodable body", req.Contributor, resp.StatusCode), + } +} diff --git a/extensions/dashboard/contract/remote/client_test.go b/extensions/dashboard/contract/remote/client_test.go new file mode 100644 index 00000000..3cee9852 --- /dev/null +++ b/extensions/dashboard/contract/remote/client_test.go @@ -0,0 +1,193 @@ +package remote + +import ( + "context" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + + dashauth "github.com/xraph/forge/extensions/dashboard/auth" + "github.com/xraph/forge/extensions/dashboard/contract" +) + +// remoteRegistry is a hand-rolled minimal Registry just for the dispatcher +// tests so we don't have to spin up the full registry.go merging logic +// when all we care about is the Remote() lookup. +type remoteRegistry struct { + contract.Registry + endpoints map[string]contract.RemoteEndpoint +} + +func (r *remoteRegistry) Remote(name string) (contract.RemoteEndpoint, bool) { + ep, ok := r.endpoints[name] + return ep, ok +} + +func TestForwardingDispatcher_NotFoundWhenNoRemote(t *testing.T) { + reg := &remoteRegistry{endpoints: map[string]contract.RemoteEndpoint{}} + f := NewForwardingDispatcher(reg) + _, _, err := f.Dispatch(context.Background(), contract.Request{Contributor: "missing"}, contract.Principal{}) + ce, ok := err.(*contract.Error) + if !ok || ce.Code != contract.CodeNotFound { + t.Errorf("expected CodeNotFound, got %v", err) + } +} + +func TestForwardingDispatcher_RoundTripsSuccessEnvelope(t *testing.T) { + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var req contract.Request + body, _ := io.ReadAll(r.Body) + _ = json.Unmarshal(body, &req) + if req.Intent != "things.list" { + t.Errorf("upstream got intent=%q, want things.list", req.Intent) + } + _ = json.NewEncoder(w).Encode(contract.Response{ + OK: true, + Envelope: "v1", + Kind: contract.KindQuery, + Data: json.RawMessage(`{"items":["a","b"]}`), + Meta: contract.ResponseMeta{IntentVersion: 1}, + }) + })) + defer upstream.Close() + + reg := &remoteRegistry{endpoints: map[string]contract.RemoteEndpoint{ + "things": {BaseURL: upstream.URL}, + }} + f := NewForwardingDispatcher(reg) + + data, meta, err := f.Dispatch(context.Background(), contract.Request{ + Envelope: "v1", + Kind: contract.KindQuery, + Contributor: "things", + Intent: "things.list", + IntentVersion: 1, + }, contract.Principal{}) + if err != nil { + t.Fatalf("dispatch: %v", err) + } + if string(data) != `{"items":["a","b"]}` { + t.Errorf("data = %s", data) + } + if meta.IntentVersion != 1 { + t.Errorf("meta.IntentVersion = %d", meta.IntentVersion) + } +} + +func TestForwardingDispatcher_SurfacesUpstreamError(t *testing.T) { + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _ = json.NewEncoder(w).Encode(contract.ErrorResponse{ + OK: false, + Envelope: "v1", + Error: &contract.Error{Code: contract.CodeBadRequest, Message: "upstream-said-no"}, + }) + })) + defer upstream.Close() + + reg := &remoteRegistry{endpoints: map[string]contract.RemoteEndpoint{ + "things": {BaseURL: upstream.URL}, + }} + f := NewForwardingDispatcher(reg) + _, _, err := f.Dispatch(context.Background(), contract.Request{Contributor: "things", Intent: "x"}, contract.Principal{}) + ce, ok := err.(*contract.Error) + if !ok || ce.Code != contract.CodeBadRequest || !strings.Contains(ce.Message, "upstream-said-no") { + t.Errorf("expected upstream error to surface verbatim, got %v", err) + } +} + +func TestForwardingDispatcher_ForwardsAuthHeaders(t *testing.T) { + var sawAuthz, sawCookie, sawAPIKey string + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + sawAuthz = r.Header.Get("X-Forwarded-Authorization") + sawCookie = r.Header.Get("X-Forwarded-Cookie") + sawAPIKey = r.Header.Get("Authorization") + _ = json.NewEncoder(w).Encode(contract.Response{OK: true, Envelope: "v1", Kind: contract.KindQuery, Data: json.RawMessage(`{}`)}) + })) + defer upstream.Close() + + reg := &remoteRegistry{endpoints: map[string]contract.RemoteEndpoint{ + "things": {BaseURL: upstream.URL, APIKey: "dashboard-secret"}, + }} + f := NewForwardingDispatcher(reg) + + // Build an inbound request carrying user auth + cookie. + inbound := httptest.NewRequest(http.MethodPost, "/api/dashboard/v1", nil) + inbound.Header.Set("Authorization", "Bearer user-token") + inbound.Header.Set("Cookie", "session=abc") + ctx := dashauth.WithHTTP(context.Background(), httptest.NewRecorder(), inbound) + + _, _, err := f.Dispatch(ctx, contract.Request{Contributor: "things", Intent: "x"}, contract.Principal{}) + if err != nil { + t.Fatalf("dispatch: %v", err) + } + if sawAuthz != "Bearer user-token" { + t.Errorf("upstream did not see forwarded user auth, got %q", sawAuthz) + } + if sawCookie != "session=abc" { + t.Errorf("upstream did not see forwarded cookie, got %q", sawCookie) + } + if sawAPIKey != "Bearer dashboard-secret" { + t.Errorf("upstream did not see dashboard API key, got %q", sawAPIKey) + } +} + +func TestForwardingDispatcher_NetworkErrorMapsToCodeUnavailable(t *testing.T) { + reg := &remoteRegistry{endpoints: map[string]contract.RemoteEndpoint{ + // Reserved-for-documentation network: every dial will fail fast. + "things": {BaseURL: "http://198.51.100.1:65535"}, + }} + f := NewForwardingDispatcher(reg) + ctx, cancel := context.WithTimeout(context.Background(), 50*1000*1000) // 50ms + defer cancel() + _, _, err := f.Dispatch(ctx, contract.Request{Contributor: "things", Intent: "x"}, contract.Principal{}) + ce, ok := err.(*contract.Error) + if !ok || ce.Code != contract.CodeUnavailable { + t.Errorf("expected CodeUnavailable, got %v", err) + } +} + +func TestFetchManifest_DecodesJSON(t *testing.T) { + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + _ = json.NewEncoder(w).Encode(contract.ContractManifest{ + SchemaVersion: 1, + Contributor: contract.Contributor{Name: "things"}, + }) + })) + defer upstream.Close() + + m, err := FetchManifest(context.Background(), upstream.URL, "", nil) + if err != nil { + t.Fatalf("fetch: %v", err) + } + if m.Contributor.Name != "things" { + t.Errorf("manifest name = %q", m.Contributor.Name) + } +} + +func TestFetchManifest_Non2xx(t *testing.T) { + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusNotFound) + _, _ = w.Write([]byte("no manifest here")) + })) + defer upstream.Close() + _, err := FetchManifest(context.Background(), upstream.URL, "", nil) + if err == nil || !strings.Contains(err.Error(), "404") { + t.Errorf("expected 404 surfaced in error, got %v", err) + } +} + +func TestFetchManifest_MissingContributorName(t *testing.T) { + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + _ = json.NewEncoder(w).Encode(contract.ContractManifest{SchemaVersion: 1}) + })) + defer upstream.Close() + _, err := FetchManifest(context.Background(), upstream.URL, "", nil) + if err == nil || !strings.Contains(err.Error(), "missing contributor.name") { + t.Errorf("expected missing-name error, got %v", err) + } +} diff --git a/extensions/dashboard/contract/remote/manifest.go b/extensions/dashboard/contract/remote/manifest.go new file mode 100644 index 00000000..9b87806f --- /dev/null +++ b/extensions/dashboard/contract/remote/manifest.go @@ -0,0 +1,76 @@ +package remote + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "strings" + "time" + + "github.com/xraph/forge/extensions/dashboard/contract" +) + +// FetchManifest pulls a contributor's contract manifest from the upstream +// service's manifest endpoint. The caller typically follows this with +// Registry.RegisterRemote(manifest, RemoteEndpoint{BaseURL: baseURL, ...}). +// +// baseURL is the service root (e.g. https://svc:8443/svc); the function +// appends DefaultManifestPath. apiKey, when non-empty, is sent as +// Authorization: Bearer. A nil client falls back to a 10s-timeout client. +// +// On non-2xx the upstream's status + body excerpt are surfaced so config +// mistakes (wrong port, wrong path) are diagnosable. +func FetchManifest(ctx context.Context, baseURL, apiKey string, client *http.Client) (*contract.ContractManifest, error) { + if baseURL == "" { + return nil, fmt.Errorf("remote: baseURL is required") + } + if client == nil { + client = &http.Client{Timeout: DefaultTimeout} + } + url := strings.TrimRight(baseURL, "/") + DefaultManifestPath + hr, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return nil, fmt.Errorf("remote: build manifest request: %w", err) + } + hr.Header.Set("Accept", "application/json") + if apiKey != "" { + hr.Header.Set("Authorization", "Bearer "+apiKey) + } + resp, err := client.Do(hr) + if err != nil { + return nil, fmt.Errorf("remote: fetch manifest from %s: %w", url, err) + } + defer resp.Body.Close() + body, _ := io.ReadAll(io.LimitReader(resp.Body, 64*1024)) + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + excerpt := strings.TrimSpace(string(body)) + if len(excerpt) > 200 { + excerpt = excerpt[:200] + "…" + } + return nil, fmt.Errorf("remote: manifest endpoint %s returned %d: %s", url, resp.StatusCode, excerpt) + } + var m contract.ContractManifest + if err := json.Unmarshal(body, &m); err != nil { + return nil, fmt.Errorf("remote: decode manifest from %s: %w", url, err) + } + if m.Contributor.Name == "" { + return nil, fmt.Errorf("remote: manifest from %s is missing contributor.name", url) + } + return &m, nil +} + +// fetchWithDeadline is a small helper used by callers that want a per-call +// timeout shorter than the client's. Returns a derived context whose cancel +// must be called by the caller. +func fetchWithDeadline(ctx context.Context, d time.Duration) (context.Context, context.CancelFunc) { + if d <= 0 { + return ctx, func() {} + } + return context.WithTimeout(ctx, d) +} + +// Keep the helper referenced so unused-symbol lint doesn't strip it; callers +// outside this package can opt in via WithDeadline-style wrappers later. +var _ = fetchWithDeadline diff --git a/extensions/dashboard/contract/server/server.go b/extensions/dashboard/contract/server/server.go new file mode 100644 index 00000000..56b1d007 --- /dev/null +++ b/extensions/dashboard/contract/server/server.go @@ -0,0 +1,141 @@ +// Package server exposes the two HTTP endpoints a non-dashboard service +// needs to advertise itself as a contract contributor that other dashboards +// can discover + dispatch into. +// +// The dashboard extension already serves /api/dashboard/v1 for its own +// React shell. A service that doesn't host the dashboard — but wants to +// contribute intents into one — uses this helper to mount the equivalent +// surface at /_forge/contract/{manifest,dispatch}. Slice (m) introduced it +// so multiple microservices can feed a single dashboard, mirroring the +// legacy templ-based dashboard's remote contributor model. +package server + +import ( + "encoding/json" + "net/http" + "strings" + + "github.com/xraph/forge/extensions/dashboard/contract" + "github.com/xraph/forge/extensions/dashboard/contract/transport" +) + +// DefaultPrefix is the path the dashboard's RemoteContractContributor +// client expects to find a peer's contract endpoints under. +const DefaultPrefix = "/_forge/contract" + +// Server bundles the manifest endpoint and the dispatch endpoint into a +// single http.Handler. Mount it on any net/http mux (or forge.Router via +// the wrapper helper below). The dispatch endpoint reuses the contract's +// existing transport.NewHandler so request semantics — envelope parsing, +// CSRF, idempotency, kind/capability matching, warden checks, audit — +// stay identical between a dashboard's own /api/dashboard/v1 and a +// remote service's /_forge/contract/dispatch. +type Server struct { + reg contract.Registry + dispatch http.Handler + prefix string +} + +// Option configures a Server. +type Option func(*Server) + +// WithPrefix overrides DefaultPrefix. Useful when the service mounts the +// contract endpoints under a custom path (proxy compatibility, etc.). +func WithPrefix(p string) Option { + return func(s *Server) { + s.prefix = strings.TrimRight(p, "/") + if s.prefix == "" { + s.prefix = DefaultPrefix + } + } +} + +// New returns a Server configured to serve the registry + dispatcher +// passed in. The supplied audit emitter is plumbed through to the +// dispatch handler; pass contract.NoopAuditEmitter{} when not needed. +func New( + reg contract.Registry, + wreg contract.WardenRegistry, + disp transport.Dispatcher, + audit contract.AuditEmitter, + opts ...Option, +) *Server { + s := &Server{ + reg: reg, + dispatch: transport.NewHandler(reg, wreg, disp, audit), + prefix: DefaultPrefix, + } + for _, o := range opts { + o(s) + } + return s +} + +// Prefix returns the configured URL prefix (DefaultPrefix unless overridden). +func (s *Server) Prefix() string { return s.prefix } + +// ManifestPath returns the absolute path to the manifest endpoint. +func (s *Server) ManifestPath() string { return s.prefix + "/manifest" } + +// DispatchPath returns the absolute path to the dispatch endpoint. +func (s *Server) DispatchPath() string { return s.prefix + "/dispatch" } + +// ServeHTTP routes inbound requests to the manifest or dispatch handler +// based on the suffix beneath Prefix(). 404 for anything else. +func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) { + path := r.URL.Path + switch path { + case s.ManifestPath(): + s.handleManifest(w, r) + case s.DispatchPath(): + s.dispatch.ServeHTTP(w, r) + default: + http.NotFound(w, r) + } +} + +// HandleManifest is exported so callers wiring the manifest endpoint +// onto a custom router (without going through ServeHTTP) can mount it +// directly. +func (s *Server) HandleManifest(w http.ResponseWriter, r *http.Request) { + s.handleManifest(w, r) +} + +// HandleDispatch exposes the dispatch handler for the same reason. +func (s *Server) HandleDispatch(w http.ResponseWriter, r *http.Request) { + s.dispatch.ServeHTTP(w, r) +} + +func (s *Server) handleManifest(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + http.Error(w, "GET required", http.StatusMethodNotAllowed) + return + } + contributor := r.URL.Query().Get("contributor") + w.Header().Set("Content-Type", "application/json") + w.Header().Set("Cache-Control", "no-cache") + if contributor != "" { + m, ok := s.reg.Contributor(contributor) + if !ok { + http.Error(w, "contributor not registered", http.StatusNotFound) + return + } + _ = json.NewEncoder(w).Encode(m) + return + } + // No ?contributor: when the service hosts exactly one contributor, + // return that manifest directly so simple single-contributor services + // match the legacy /_forge/dashboard/manifest contract. With multiple + // contributors, return the catalog so callers can pick. + all := s.reg.All() + switch len(all) { + case 0: + http.Error(w, "no contributors registered", http.StatusNotFound) + case 1: + _ = json.NewEncoder(w).Encode(all[0]) + default: + _ = json.NewEncoder(w).Encode(struct { + Manifests []*contract.ContractManifest `json:"manifests"` + }{Manifests: all}) + } +} diff --git a/extensions/dashboard/contract/server/server_test.go b/extensions/dashboard/contract/server/server_test.go new file mode 100644 index 00000000..c0fc719c --- /dev/null +++ b/extensions/dashboard/contract/server/server_test.go @@ -0,0 +1,170 @@ +package server + +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/loader" + "github.com/xraph/forge/extensions/dashboard/contract/remote" +) + +// upstreamEnv builds a complete remote-service stack: a registry holding +// a manifest, a dispatcher with a query handler bound, and a Server +// exposing /_forge/contract/manifest and /_forge/contract/dispatch via +// httptest. Mirrors the smallest possible "service exposing contract +// intents" wire-up. +func upstreamEnv(t *testing.T) (*httptest.Server, contract.Registry) { + t.Helper() + reg := contract.NewRegistry() + wreg := contract.NewWardenRegistry() + yaml := ` +schemaVersion: 1 +contributor: { name: things, envelope: { supports: [v1], preferred: v1 } } +intents: + - { name: things.list, kind: query, version: 1, capability: read } +` + m, err := loader.Load(strings.NewReader(yaml), "test.yaml") + if err != nil { + t.Fatalf("load: %v", err) + } + if err := loader.Validate(m, wreg); err != nil { + t.Fatalf("validate: %v", err) + } + if err := reg.Register(m); err != nil { + t.Fatalf("register: %v", err) + } + + d := dispatcher.New(dispatcher.NoopMetricsEmitter{}) + type out struct { + Items []string `json:"items"` + } + if err := dispatcher.RegisterQuery(d, "things", "things.list", 1, + func(_ context.Context, _ struct{}, _ contract.Principal) (out, error) { + return out{Items: []string{"a", "b"}}, nil + }, + ); err != nil { + t.Fatalf("register handler: %v", err) + } + + s := New(reg, wreg, d, contract.NoopAuditEmitter{}) + srv := httptest.NewServer(s) + t.Cleanup(srv.Close) + return srv, reg +} + +func TestServer_ManifestEndpoint(t *testing.T) { + srv, _ := upstreamEnv(t) + res, err := http.Get(srv.URL + "/_forge/contract/manifest?contributor=things") + if err != nil { + t.Fatalf("get: %v", err) + } + defer res.Body.Close() + if res.StatusCode != http.StatusOK { + t.Fatalf("status = %d", res.StatusCode) + } + var m contract.ContractManifest + _ = json.NewDecoder(res.Body).Decode(&m) + if m.Contributor.Name != "things" { + t.Errorf("contributor = %q", m.Contributor.Name) + } +} + +func TestServer_SingleContributorReturnedDirectly(t *testing.T) { + // When exactly one contributor is registered, /manifest with no + // ?contributor query returns the single manifest verbatim. This is the + // shape the legacy templ flow's /_forge/dashboard/manifest serves and + // what remote.FetchManifest expects by default. + srv, _ := upstreamEnv(t) + res, err := http.Get(srv.URL + "/_forge/contract/manifest") + if err != nil { + t.Fatalf("get: %v", err) + } + defer res.Body.Close() + var m contract.ContractManifest + _ = json.NewDecoder(res.Body).Decode(&m) + if m.Contributor.Name != "things" { + t.Errorf("single-manifest shape = %+v", m) + } +} + +func TestServer_DispatchEndpoint(t *testing.T) { + srv, _ := upstreamEnv(t) + body, _ := json.Marshal(contract.Request{ + Envelope: "v1", Kind: contract.KindQuery, + Contributor: "things", Intent: "things.list", IntentVersion: 1, + }) + res, err := http.Post(srv.URL+"/_forge/contract/dispatch", "application/json", bytes.NewReader(body)) + if err != nil { + t.Fatalf("post: %v", err) + } + defer res.Body.Close() + if res.StatusCode != http.StatusOK { + t.Fatalf("status = %d", res.StatusCode) + } + var resp contract.Response + _ = json.NewDecoder(res.Body).Decode(&resp) + if !resp.OK { + t.Errorf("ok = false") + } +} + +func TestServer_UnknownPath_404(t *testing.T) { + srv, _ := upstreamEnv(t) + res, err := http.Get(srv.URL + "/_forge/contract/nope") + if err != nil { + t.Fatalf("get: %v", err) + } + defer res.Body.Close() + if res.StatusCode != http.StatusNotFound { + t.Errorf("status = %d, want 404", res.StatusCode) + } +} + +// TestRoundTrip_HostToUpstream wires a host dispatcher with the forwarding +// dispatcher pointing at the upstream Server. The host's own registry +// records the upstream manifest as a remote so the forwarder can find it. +// A dispatch through the host should round-trip to the upstream and back. +func TestRoundTrip_HostToUpstream(t *testing.T) { + upstream, upstreamReg := upstreamEnv(t) + _ = upstreamReg // referenced for the upstream side; host side below + + // Host: fetch manifest, register remote, install forwarding dispatcher. + hostReg := contract.NewRegistry() + hostWreg := contract.NewWardenRegistry() + m, err := remote.FetchManifest(context.Background(), upstream.URL, "", nil) + if err != nil { + t.Fatalf("fetch: %v", err) + } + if err := loader.Validate(m, hostWreg); err != nil { + t.Fatalf("validate: %v", err) + } + if err := hostReg.RegisterRemote(m, contract.RemoteEndpoint{BaseURL: upstream.URL}); err != nil { + t.Fatalf("register remote: %v", err) + } + + hostDisp := dispatcher.New(dispatcher.NoopMetricsEmitter{}) + hostDisp.SetRemoteDispatcher(remote.NewForwardingDispatcher(hostReg)) + + // No local handler for things.list — it should forward to the upstream. + data, _, err := hostDisp.Dispatch(context.Background(), contract.Request{ + Envelope: "v1", Kind: contract.KindQuery, + Contributor: "things", Intent: "things.list", IntentVersion: 1, + }, contract.Principal{}) + if err != nil { + t.Fatalf("dispatch: %v", err) + } + var got struct { + Items []string `json:"items"` + } + _ = json.Unmarshal(data, &got) + if len(got.Items) != 2 || got.Items[0] != "a" { + t.Errorf("round trip data = %+v", got) + } +} diff --git a/extensions/dashboard/contract/shell/.gitignore b/extensions/dashboard/contract/shell/.gitignore new file mode 100644 index 00000000..bfa54be7 --- /dev/null +++ b/extensions/dashboard/contract/shell/.gitignore @@ -0,0 +1,21 @@ +node_modules/ +dist/ +.vite/ +*.log +.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/ARCHITECTURE.md b/extensions/dashboard/contract/shell/ARCHITECTURE.md new file mode 100644 index 00000000..55d60125 --- /dev/null +++ b/extensions/dashboard/contract/shell/ARCHITECTURE.md @@ -0,0 +1,206 @@ +# Architecture: Dashboard Contract Shell + +The runtime that turns a Forge dashboard contract YAML into a working React UI. + +## Pipeline at a glance + +``` +┌──────────────┐ POST /api/dashboard/v1 ┌──────────────┐ +│ React Router │ ──────────────────────────▶ │ Go contract │ +│ PageRoute │ { kind: graph, route } │ registry │ +└──────┬───────┘ └──────┬───────┘ + │ │ filtered + │ GraphNode tree (typed) │ by user perms + ▼ ▼ +┌──────────────┐ (server-side) +│GraphRenderer │ +│ walks │ +└──────┬───────┘ + │ for each node + ▼ +┌──────────────┐ registry.resolve(intent) +│IntentRegistry│ ──────────▶ React component +└──────────────┘ + │ renders with + ▼ +┌─────────────────────────────────────────┐ +│ │ +└──────────────────────────────────────────┘ + │ may call + ▼ +useContractQuery / useContractCommand / useSubscription + │ + ▼ (back through ContractClient or SubscriptionMux) + POST /api/dashboard/v1 | GET /api/dashboard/v1/stream +``` + +The Go side handles auth, permission filtering, dispatch, and audit. The React shell only handles **rendering** — never authorization decisions. + +## Five concepts to know + +### 1. The graph + +The graph is a typed tree of `GraphNode`s. Every node has an `intent` (string), optional `props` (free-form), `data` (a binding to a query intent), and `slots` (named child arrays). The Go server filters out nodes the current user cannot see *before* sending — the shell trusts the response. + +```ts +interface GraphNode { + intent: string; // resolved by IntentRegistry + title?: string; + data?: { intent: string; params?: ... }; // data binding + props?: Record; + slots?: Record; // named child arrays + enabledWhen?: Predicate; // for read-only / disabled UI + op?: string; // for action.* — the command intent + payload?: Record; // ParamSource-shaped +} +``` + +### 2. The IntentRegistry + +A `Map` keyed by intent name. `App.tsx` builds it once via `buildIntentRegistry()` and threads it through React context. + +```ts +const reg = new IntentRegistry(); +reg.register("metric.counter", MetricCounter); +reg.register("page.shell", PageShell); +// ... +``` + +The `GraphRenderer` looks up `node.intent` in the registry and renders the component, falling back to `` when the entry is missing. Future contributors can register their own intents at runtime; slice (e) registers only the v1 vocabulary. + +### 3. Slots + +Slots are how a parent intent invites children. The renderer never decides where children go — the parent does, by calling `` somewhere in its JSX. + +```tsx +function PageShell({ slots }: IntentComponentProps) { + return ( + <> +
...
+
+ +
+ + ); +} +``` + +The contract registry (Go side) validates at registration time that contributors only fill slots their parent intent declares — no surprises in production. + +### 4. ContributorContext + ParentContext + +Two React contexts thread the data leaf intents need to issue commands and resolve bindings: + +- **`useContributor()`** — the contributor name owning the current graph subtree (e.g., `"users"`). `action.button` posts its `op` to this contributor by default. `App.tsx` wraps `` in `` once per route. +- **`useParent()`** — the nearest enclosing record (a row from `resource.list`, the loaded record from `form.edit`, etc.). Lets payload bindings like `{ from: 'parent.id' }` resolve without a centralized state machine. `resource.list` and `resource.detail` are responsible for setting it via `` when rendering their children. + +### 5. Bindings (`resolvePayload`) + +Manifest payload values may be literals, `{ value: X }`, or `{ from: "scope.path" }`. `resolvePayload` walks a payload map and returns concrete JS values, pulling from `parent`, `session`, and `route` contexts as needed. Used by `action.button`, `action.menu`, `form.edit`, and (for `data.params`) `resource.list` / `form.edit`. + +## Authoring a new intent + +Three files. ~50 lines of code for a typical CRUD-shaped widget. + +### Step 1: write the component + +```tsx +// src/intents/users.profile-card.tsx +import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card"; +import { useContractQuery } from "../contract/hooks"; +import { useContributor, useParent } from "../runtime/context"; +import { LoadingNode, ErrorNode } from "../runtime/fallbacks"; +import type { IntentComponentProps } from "../runtime/registry"; + +interface ProfileCardProps { + size?: "sm" | "md"; +} + +interface ProfileData { + email: string; + joinedAt: string; + // ... +} + +export function ProfileCard({ node, props }: IntentComponentProps) { + const contributor = useContributor(); + const parent = useParent(); + const userId = parent?.id; + const query = useContractQuery(contributor, "user.profile", undefined, { id: userId }); + + if (query.isLoading) return ; + if (query.error) return ; + if (!query.data) return null; + + return ( + + {node.title ?? "Profile"} + +

{query.data.email}

+

Joined {query.data.joinedAt}

+
+
+ ); +} +``` + +### Step 2: register it + +```ts +// src/intents/register.ts +import { ProfileCard } from "./users.profile-card"; +// ... +reg.register("users.profile-card", ProfileCard as unknown as IntentComponent); +``` + +### Step 3: smoke test + +```tsx +// test/users.profile-card.test.tsx +// ...standard MSW + render setup, see test/resource.test.tsx for a template +``` + +That's it. The Go server's contract registry doesn't need to know about your component — it just emits the manifest, and the shell's React tree picks it up by name. + +## Adding a shadcn primitive + +If you need a new shadcn component (e.g., `Tooltip`): + +```bash +pnpm add @base-ui-components/react-tooltip +``` + +Then drop the component file into `src/components/ui/tooltip.tsx`. Use the existing primitives in `src/components/ui/` as templates — they all follow the same pattern (forwardRef + cn() for class merging + tailwind-merge for variant combos). + +## Testing strategy + +- **Unit / integration:** Vitest + RTL + MSW. Mount the component, intercept the contract endpoint, assert. +- **Test setup polyfills:** [test/setup.ts](./test/setup.ts) provides `ResizeObserver`, `EventSource`, and pointer-capture stubs that Base UI primitives need under jsdom. +- **No browser E2E yet.** Playwright is on the roadmap. For now, the smoke test (`test/smoke.test.tsx`) gives end-to-end coverage through the runtime. + +## Performance budget + +- **JS:** ≤ 300KB gzipped initial. Currently ~120KB (44KB index + 13KB query-vendor + 49KB react-vendor + 14KB Base UI primitives spread across chunks). +- **CSS:** ≤ 10KB gzipped. Currently ~5KB. +- **Cold load:** target < 1s on a 3G connection (most admin tools live on faster networks but this keeps the budget honest). + +The Vite config splits `react-vendor` and `query-vendor` into their own chunks so they cache across deploys. + +## Known limitations / future work + +- **`resource.list`** does client-side filtering only. Server-side pagination + sort is slice (f). +- **`form.edit`** uses controlled state, not react-hook-form. zod-based validation can layer on later if forms grow complex. +- **Custom column cells:** `customCell.` slot is designed in slice (a) but not implemented; today, all cells go through the default renderer. +- **iframe escape hatch:** designed but no concrete component until a contributor needs one. +- **Action error handling:** `action.button` swallows command errors silently for v1. A toast pattern is the natural follow-on. +- **Per-event subscription tracing:** explicitly punted (cardinality concerns). + +## Why these choices + +- **shadcn/ui (vendored)** — accessible Base UI primitives styled with Tailwind and copied into our tree, so we own the components and can adjust without upstream churn. Standard for React+TS admin tools. +- **TanStack Query** — pairs naturally with the contract's per-intent `staleTime` declarations and the `meta.invalidates` hint commands return. +- **Zustand for local state** — small (~1KB), hooks-native, no Provider boilerplate. Used only for transient UI state (theme, principal); server state lives in TanStack Query. +- **CSS variables for theming** — same pattern shadcn ships with; allows runtime theme overrides without rebuilding. +- **Vendored components, not npm package** — shadcn's defining philosophy. Component code lives in `src/components/ui/` so we control everything. +- **No custom design system** — we adopt shadcn's defaults rather than build a parallel system. Slice (f) or a follow-on can fork specific components if an admin-tool need demands it. diff --git a/extensions/dashboard/contract/shell/README.md b/extensions/dashboard/contract/shell/README.md new file mode 100644 index 00000000..7ba5e176 --- /dev/null +++ b/extensions/dashboard/contract/shell/README.md @@ -0,0 +1,146 @@ +# Dashboard Contract Shell + +The React/TypeScript runtime that consumes the Forge dashboard contract. It fetches a graph from the Go side, walks each intent through a registered React component, and renders the result. shadcn/ui (Base UI + Tailwind) provides the primitives. + +## At a glance + +- **Stack:** TypeScript 5 strict, React 18, Vite 5, React Router v6 data router, TanStack Query 5, Zustand 4, Tailwind CSS 3, Vitest + MSW. +- **UI primitives:** shadcn/ui (vendored) + lucide-react icons. Light / dark / system theme baked in. +- **Bundle size:** ~120KB gzipped JS + ~5KB CSS for the v1 vocabulary; well within the 350KB budget. +- **Built-in intent vocabulary (v1):** `page.shell`, `metric.counter`, `action.button`, `action.menu`, `action.divider`, `form.edit`, `form.field`, `resource.list`, `resource.detail`, `dashboard.grid`, `audit.tail`. Unknown intents render a graceful fallback. +- **Embedded into the Go binary:** `pnpm build` emits `dist/`, which the dashboard extension serves under `/dashboard/contract/static/*` and `/dashboard/contract/app/*` (SPA fallback). + +For the architecture deep-dive (how the renderer + registry work, how to author new intents), see [ARCHITECTURE.md](./ARCHITECTURE.md). For the design rationale across slices, see [SLICE_D_DESIGN.md](../SLICE_D_DESIGN.md) and [SLICE_E_DESIGN.md](../SLICE_E_DESIGN.md). + +## Development + +```bash +pnpm install +pnpm dev # Vite dev server on :5173, proxies /api/dashboard/* to :8080 +``` + +The dev server expects the dashboard binary running on `localhost:8080`. Start it from the repo root: + +```bash +go run ./cmd/forge ... # whatever your dashboard entrypoint is +``` + +Then browse to `http://localhost:5173/dashboard/contract/app/extensions` (or any other pilot route). + +## Build + +```bash +pnpm build # tsc --noEmit && vite build → dist/ +``` + +Run `pnpm build` whenever you change shell source before `go build` from the repo root, since the Go side embeds `dist/` via `//go:embed`. CI does this automatically. + +## Test + +```bash +pnpm test # vitest run +pnpm test:watch # vitest in watch mode +pnpm lint # tsc --noEmit (strict mode + noUncheckedIndexedAccess) +pnpm format # prettier --write +``` + +All tests use Vitest + React Testing Library. HTTP/SSE is intercepted with MSW; jsdom polyfills (ResizeObserver, EventSource stub, pointer-capture) live in [test/setup.ts](./test/setup.ts). + +## Project structure + +``` +shell/ + src/ + main.tsx React entry; mounts . + App.tsx Providers + React Router. Loads principal + theme on mount. + index.css Tailwind layer + shadcn theme tokens (light + dark CSS variables). + contract/ + types.ts TypeScript mirror of the Go envelope (Request, Response, GraphNode, ...) + client.ts ContractClient — POST envelope sender with auto-CSRF + idempotency. + sse.ts SubscriptionMux — single EventSource, demuxed by SSE event name. + hooks.ts React Query bindings: useContractGraph / useContractQuery / + useContractCommand / useSubscription. + runtime/ + registry.ts IntentRegistry: name → React component map. + context.tsx IntentRegistryProvider, ContributorProvider, ParentProvider. + renderer.tsx GraphRenderer — dispatches a node to its registered component. + slots.tsx SlotRenderer — recursively renders children of a named slot. + fallbacks.tsx UnknownIntent / LoadingNode / ErrorNode (shadcn Alert + Skeleton). + bindings.ts resolvePayload / resolveValue — turns ParamSource references like + { from: 'parent.id' } into concrete JS values. + auth/ + principal.ts Zustand store for the current user (loads /api/dashboard/v1/principal). + lib/ + utils.ts cn() — clsx + tailwind-merge. + theme.ts Zustand theme store (light / dark / system, localStorage-backed). + components/ + ui/ shadcn primitives (Button, Card, Alert, Sheet, Table, Form, ...). + theme-toggle.tsx Sun / Moon / System dropdown built on shadcn DropdownMenu. + intents/ Registered intent components. One file per intent. + register.ts Builds the IntentRegistry consumed by App.tsx. + page.shell.tsx Topbar + main slot wrapper. + metric.counter.tsx Subscribed numeric value in a Card. + action.button.tsx Issues a kind=command with optional confirm dialog. + action.menu.tsx DropdownMenu of actions. + action.divider.tsx Separator (used standalone or inside action.menu). + form.edit.tsx Form container; preloads via query intent, submits via op command. + form.field.tsx Labeled input — text, email, number, password, textarea, checkbox. + resource.list.tsx shadcn Table with rowActions slot + detailDrawer (Sheet) slot. + resource.detail.tsx dl/dt/dd of a record's fields. + dashboard.grid.tsx Responsive widget grid. + audit.tail.tsx Append-mode subscription with sticky-bottom auto-scroll. + test/ + setup.ts MSW + ResizeObserver + EventSource + pointer-capture polyfills. + contract.test.ts ContractClient round-trip tests. + sse.test.ts SubscriptionMux dispatch tests. + renderer.test.tsx Registry, GraphRenderer, SlotRenderer. + smoke.test.tsx Full app mount through the contract endpoint. + actions.test.tsx action.button: dispatch, payload binding, confirm dialog. + form.test.tsx form.edit + form.field: submit, prefill from query. + resource.test.tsx resource.list, resource.detail, dashboard.grid. + embed.go //go:embed dist/* for the Go side. + components.json shadcn config (used if you ever run `shadcn add`). + vite.config.ts Vite build + dev proxy + @ alias. + vitest.config.ts Vitest config (jsdom env, jsdom URL = http://localhost:3000). + tailwind.config.ts Tailwind tokens + animate plugin. + tsconfig.json TS strict mode + noUncheckedIndexedAccess + paths. +``` + +## Theming + +The shell ships shadcn's "slate" defaults across CSS variables in `src/index.css`. Three modes: + +- **light** — default `:root` tokens. +- **dark** — `.dark` class on `` flips the tokens. +- **system** — follows `prefers-color-scheme`. + +User selection persists via localStorage (`forge.dashboard.theme`). The topbar's theme toggle is a `DropdownMenu` of Sun / Moon / Monitor. + +To override colors for a deployment, ship a CSS file that re-declares the variables and load it via the dashboard config — or fork `src/index.css`. + +## Embedding + +The Go side serves the built shell from two route groups (registered in `extensions/dashboard/extension.go`): + +| URL pattern | Purpose | Cache | +|---|---|---| +| `/dashboard/contract/static/*` | Hashed assets (JS, CSS, fonts) from `dist/` | Immutable for `/assets/*`, no-cache otherwise | +| `/dashboard/contract/app[/*]` | SPA index.html — React Router handles client-side routing | no-cache | + +`pnpm build` is required before `go build` so `embed.FS` picks up the latest assets. + +## Adding a new intent + +See [ARCHITECTURE.md](./ARCHITECTURE.md). The TL;DR: + +1. Drop `src/intents/.tsx` with a function component matching `IntentComponentProps`. +2. Register it in `src/intents/register.ts`. +3. Add a Vitest smoke test under `test/`. + +## What's NOT in this slice + +- Server-side filtering / sorting / pagination for `resource.list` (client-side only for v1). +- Custom column rendering (`customCell.` slot designed in slice (a) but no concrete renderer yet). +- iframe escape hatch component. +- Browser E2E (Playwright). +- Internationalization, advanced accessibility audit beyond RTL defaults. 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/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/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 new file mode 100644 index 00000000..4bc4a295 --- /dev/null +++ b/extensions/dashboard/contract/shell/package.json @@ -0,0 +1,47 @@ +{ + "name": "@forge/dashboard-shell", + "private": true, + "version": "0.1.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc --noEmit && vite build", + "preview": "vite preview", + "test": "vitest run --passWithNoTests", + "test:watch": "vitest", + "lint": "tsc --noEmit", + "format": "prettier --write src test" + }, + "dependencies": { + "@base-ui-components/react": "1.0.0-rc.0", + "@hookform/resolvers": "^5.2.2", + "@tanstack/react-query": "^5.40.0", + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", + "lucide-react": "^0.460.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": { + "@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..840267a5 --- /dev/null +++ b/extensions/dashboard/contract/shell/pnpm-lock.yaml @@ -0,0 +1,3226 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + dependencies: + '@base-ui-components/react': + specifier: 1.0.0-rc.0 + version: 1.0.0-rc.0(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@hookform/resolvers': + specifier: ^5.2.2 + version: 5.2.2(react-hook-form@7.75.0(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: ^0.460.0 + version: 0.460.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) + 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'} + + '@base-ui-components/react@1.0.0-rc.0': + resolution: {integrity: sha512-9lhUFbJcbXvc9KulLev1WTFxS/alJRBWDH/ibKSQaNvmDwMFS2gKp1sTeeldYSfKuS/KC1w2MZutc0wHu2hRHQ==} + engines: {node: '>=14.0.0'} + deprecated: Package was renamed to @base-ui/react + peerDependencies: + '@types/react': ^17 || ^18 || ^19 + react: ^17 || ^18 || ^19 + react-dom: ^17 || ^18 || ^19 + peerDependenciesMeta: + '@types/react': + optional: true + + '@base-ui-components/utils@0.2.2': + resolution: {integrity: sha512-rNJCD6TFy3OSRDKVHJDzLpxO3esTV1/drRtWNUpe7rCpPN9HZVHUCuP+6rdDYDGWfXnQHbqi05xOyRP2iZAlkw==} + deprecated: Package was renamed to @base-ui/utils + peerDependencies: + '@types/react': ^17 || ^18 || ^19 + react: ^17 || ^18 || ^19 + react-dom: ^17 || ^18 || ^19 + peerDependenciesMeta: + '@types/react': + optional: true + + '@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] + + '@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'} + + '@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] + + '@standard-schema/utils@0.3.0': + resolution: {integrity: sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==} + + '@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'} + + 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'} + + cliui@8.0.1: + 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'} + + 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==} + + lucide-react@0.460.0: + resolution: {integrity: sha512-BVtq/DykVeIvRTJvRAgCsOwaGL8Un3Bxh8MbDxMhEWlZay3T4IpEKDEpwt5KZ0KJMHzgm6jrltxlT5eXOWXDHg==} + peerDependencies: + 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==} + 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-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==} + + 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'} + + reselect@5.1.1: + resolution: {integrity: sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==} + + 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==} + + tabbable@6.4.0: + resolution: {integrity: sha512-05PUHKSNE8ou2dwIxTngl4EzcnsCDZGJ/iCLtDflR/SHB/ny14rXc+qU5P4mG9JkusiV7EivzY9Mhm55AzAvCg==} + + tagged-tag@1.0.0: + 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'} + 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'} + + 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'} + 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 + + '@base-ui-components/react@1.0.0-rc.0(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@babel/runtime': 7.29.2 + '@base-ui-components/utils': 0.2.2(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@floating-ui/react-dom': 2.1.8(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@floating-ui/utils': 0.2.11 + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + reselect: 5.1.1 + tabbable: 6.4.0 + use-sync-external-store: 1.6.0(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.28 + + '@base-ui-components/utils@0.2.2(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@babel/runtime': 7.29.2 + '@floating-ui/utils': 0.2.11 + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + reselect: 5.1.1 + use-sync-external-store: 1.6.0(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.28 + + '@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 + + '@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': {} + + '@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 + + '@standard-schema/utils@0.3.0': {} + + '@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 + + class-variance-authority@0.7.1: + dependencies: + clsx: 2.1.1 + + 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 + + clsx@2.1.1: {} + + 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 + + lucide-react@0.460.0(react@18.3.1): + dependencies: + react: 18.3.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-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-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: {} + + reselect@5.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: {} + + tabbable@6.4.0: {} + + 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 + 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 + + 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) + optionalDependencies: + '@types/react': 18.3.28 + 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 new file mode 100644 index 00000000..a9818842 --- /dev/null +++ b/extensions/dashboard/contract/shell/src/App.tsx @@ -0,0 +1,91 @@ +import { useEffect } from "react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { BrowserRouter, Route, Routes, useParams } from "react-router-dom"; +import { + ContributorProvider, + IntentRegistryProvider, + RouteParamsProvider, +} 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"; +import { AuthGate } from "./auth/AuthGate"; +import { DashboardLayout } from "./runtime/layout"; +import { useThemeStore } from "@/lib/theme"; +import { shellBase } from "./runtime/config"; + +const DEFAULT_CONTRIBUTOR = "core-contract"; + +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(DEFAULT_CONTRIBUTOR, route); + + if (isLoading) { + return ( + + + + ); + } + if (error) { + return ( + + + + ); + } + if (!data) { + return ( + + + + ); + } + return ( + + + + + + + + ); +} + +export function App() { + const loadPrincipal = usePrincipalStore((s) => s.load); + const initTheme = useThemeStore((s) => s.init); + useEffect(() => { + initTheme(); + void loadPrincipal(); + }, [loadPrincipal, initTheme]); + + return ( + + + + + + } /> + + + + + + ); +} diff --git a/extensions/dashboard/contract/shell/src/auth/AuthGate.tsx b/extensions/dashboard/contract/shell/src/auth/AuthGate.tsx new file mode 100644 index 00000000..86e0823e --- /dev/null +++ b/extensions/dashboard/contract/shell/src/auth/AuthGate.tsx @@ -0,0 +1,99 @@ +import * as React from "react"; +import { ShieldAlert } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { usePrincipalStore } from "./principal"; +import { LoginScreen } from "./LoginScreen"; +import { LoadingNode } from "../runtime/fallbacks"; +import { GraphRenderer } from "../runtime/renderer"; +import { ContributorProvider, RouteParamsProvider } from "../runtime/context"; +import { useContractGraph } from "../contract/hooks"; +import { ContractClientError } from "../contract/client"; +import { loginContributor } from "../runtime/config"; + +interface AuthGateProps { + children: React.ReactNode; +} + +const LOGIN_ROUTE = "/login"; + +/** + * AuthGate sits between the router and the dashboard layout. While the + * principal is loading the gate renders a spinner; once loaded it either + * passes through (auth disabled or user authenticated) or replaces the tree + * with a login UI. + * + * Slice (l) login UI sourcing — preferred path is the auth extension's + * contract `/login` graph route under its contributor (default `auth`). + * The gate fetches it; if the contributor or route is missing the gate + * falls back to the built-in `LoginScreen` so the shell still works + * out-of-the-box. This means authsome (or any auth extension) owns the + * login UI by registering one graph node, no React code required. + */ +export function AuthGate({ children }: AuthGateProps) { + const loaded = usePrincipalStore((s) => s.loaded); + const authRequired = usePrincipalStore((s) => s.authRequired); + const accessDenied = usePrincipalStore((s) => s.accessDenied); + + if (!loaded) return ; + if (authRequired) return ; + if (accessDenied) return ; + return <>{children}; +} + +function AccessDenied() { + const message = usePrincipalStore((s) => s.accessDeniedMessage); + const requiredRoles = usePrincipalStore((s) => s.requiredRoles); + const reload = usePrincipalStore((s) => s.load); + + return ( +
+
+
+ +
+
+

Access denied

+

+ {message ?? "Your account doesn't have permission to access this dashboard."} +

+
+ {requiredRoles.length > 0 ? ( +

+ Required role{requiredRoles.length === 1 ? "" : "s"}: {requiredRoles.join(", ")} +

+ ) : null} + +
+
+ ); +} + +function LoginGate() { + const { data, error, isLoading } = useContractGraph(loginContributor, LOGIN_ROUTE); + + if (isLoading) return ; + + // 404 (no contract /login route registered) → fall back to the built-in + // form. Any other error also falls through; the LoginScreen submission + // surfaces command-level errors of its own. + if (error || !data) { + return ; + } + + // The auth extension registered a /login route — render its graph as the + // login surface. Wrap with the contributor + route-params context the + // GraphRenderer expects so leaf intents (form.edit submitting auth.login) + // resolve correctly. + return ( + + + + + + ); +} + +// Re-export for tests + external callers that need to inspect the error type. +export { ContractClientError }; diff --git a/extensions/dashboard/contract/shell/src/auth/AuthLoginForm.tsx b/extensions/dashboard/contract/shell/src/auth/AuthLoginForm.tsx new file mode 100644 index 00000000..95246f0e --- /dev/null +++ b/extensions/dashboard/contract/shell/src/auth/AuthLoginForm.tsx @@ -0,0 +1,156 @@ +import * as React from "react"; +import { useContractCommand, useContractQuery } from "../contract/hooks"; +import { useContributor } from "../runtime/context"; +import { usePrincipalStore } from "./principal"; +import { LoginCard, type SocialProvider } from "./login-card"; +import { LoadingNode, ErrorNode } from "../runtime/fallbacks"; +import { ContractClient, ContractClientError } from "../contract/client"; +import { loginOp } from "../runtime/config"; +import type { IntentComponentProps } from "../runtime/registry"; + +// AuthLoginForm is the React component registered for the `auth.login.form` +// vocabulary intent. It pulls the per-deployment login configuration +// (brand, password toggle, signup, social providers) from a contract +// query named `auth.config` on the same contributor that owns the route +// and renders shadcn login-04 against it. Authsome's manifest binds the +// query at /login; deployments without authsome use the LoginScreen +// fallback instead which renders the same visual with hardcoded defaults. + +export interface AuthConfigResponse { + brand?: string; + brandLogoURL?: string; + passwordEnabled?: boolean; + signupURL?: string; + signupLabel?: string; + termsURL?: string; + privacyURL?: string; + socialProviders?: SocialProviderConfig[]; +} + +export interface SocialProviderConfig { + id: string; + label: string; + /** Absolute URL the shell POSTs to begin the OAuth flow. The endpoint + * responds with `{auth_url}` which the shell then navigates to. */ + authStartURL: string; +} + +interface AuthLoginFormProps { + /** Override the command op the password form submits. Defaults to runtime + * config's loginOp ("auth.login"). */ + op?: string; + /** Override the contributor data is fetched from. Defaults to the active + * ContributorContext (set by AuthGate when rendering the contract /login). */ + configContributor?: string; + /** Override the configuration source. Useful for tests. */ + config?: AuthConfigResponse; +} + +const CONFIG_INTENT = "auth.config"; + +export function AuthLoginForm({ node, props }: IntentComponentProps) { + const ctxContributor = useContributor(); + const contributor = props.configContributor ?? ctxContributor; + const reloadPrincipal = usePrincipalStore((s) => s.load); + + const cfgQuery = useContractQuery(contributor, CONFIG_INTENT, undefined, undefined); + const passwordCmd = useContractCommand<{ email: string; password: string }, unknown>( + contributor, + props.op ?? loginOp, + ); + + const [errorMsg, setErrorMsg] = React.useState(null); + const [submitting, setSubmitting] = React.useState(false); + + // Allow tests / advanced consumers to inject a static config and skip the + // round-trip. Production always goes through the query. + const cfg = props.config ?? cfgQuery.data ?? {}; + + if (!props.config && cfgQuery.isLoading) { + return ; + } + if (!props.config && cfgQuery.error) { + return ; + } + + const passwordEnabled = cfg.passwordEnabled !== false; + const social: SocialProvider[] = (cfg.socialProviders ?? []).map((p) => ({ + id: p.id, + label: p.label, + onClick: () => { + void beginOAuth(p.authStartURL); + }, + })); + + const handleSubmit = async ({ email, password }: { email: string; password: string }) => { + setErrorMsg(null); + setSubmitting(true); + try { + await passwordCmd.mutateAsync({ email, password }); + await reloadPrincipal(); + } catch (err) { + if (err instanceof ContractClientError) { + setErrorMsg(err.message || err.code); + } else { + setErrorMsg(String(err)); + } + } finally { + setSubmitting(false); + } + }; + + return ( + + ); +} + +// beginOAuth POSTs to the provider's auth-start URL and navigates the +// browser to the returned auth_url. The endpoint conventionally accepts +// `{redirect_url}` indicating where to come back to after the upstream +// callback completes — we point it at the dashboard root so the shell +// reloads with the freshly-set session cookie. +async function beginOAuth(authStartURL: string): Promise { + // The auth-start URL belongs to the auth extension (e.g. authsome's + // /v1/social/ route), not the contract envelope endpoint. + // We use a plain fetch so we don't drag CSRF/idempotency through it — + // the upstream OAuth flow handles its own state token via cookie. + try { + const res = await fetch(authStartURL, { + method: "POST", + credentials: "include", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + redirect_url: typeof window !== "undefined" ? window.location.origin : "", + frontend_url: typeof window !== "undefined" ? window.location.origin : "", + }), + }); + if (!res.ok) { + throw new Error(`oauth start failed (${res.status})`); + } + const body = (await res.json()) as { auth_url?: string }; + if (!body.auth_url) { + throw new Error("oauth start: missing auth_url"); + } + window.location.assign(body.auth_url); + } catch (err) { + // Surface as a console error; the LoginCard doesn't display per-button + // errors — a failed OAuth start is rare and operational, not a UX flow. + // eslint-disable-next-line no-console + console.error("oauth start error", err); + } + // Keep the ContractClient referenced so future callers using it within + // a custom auth flow stay typed. + void ContractClient; +} diff --git a/extensions/dashboard/contract/shell/src/auth/LoginScreen.tsx b/extensions/dashboard/contract/shell/src/auth/LoginScreen.tsx new file mode 100644 index 00000000..97bda0d2 --- /dev/null +++ b/extensions/dashboard/contract/shell/src/auth/LoginScreen.tsx @@ -0,0 +1,62 @@ +import * as React from "react"; +import { ContractClient, ContractClientError } from "../contract/client"; +import { usePrincipalStore } from "./principal"; +import { loginOp } from "../runtime/config"; +import { LoginCard } from "./login-card"; + +const DEFAULT_AUTH_CONTRIBUTOR = "auth"; + +interface LoginScreenProps { + /** Override the contributor that owns the login command. Defaults to "auth". */ + contributor?: string; + /** Override the command op. Defaults to runtime config's loginOp ("auth.login"). */ + op?: string; + /** Override the brand label shown above the form. */ + brand?: string; +} + +/** + * LoginScreen is the built-in fallback rendered when no contract /login + * route is registered (e.g. no auth extension wired or the extension + * doesn't ship a route). Visual style matches shadcn's login-04 block via + * the shared LoginCard so authsome-rendered and fallback flows look + * identical to the user. + */ +export function LoginScreen({ + contributor = DEFAULT_AUTH_CONTRIBUTOR, + op, + brand = "Forge Dashboard", +}: LoginScreenProps) { + const reloadPrincipal = usePrincipalStore((s) => s.load); + const [errorMsg, setErrorMsg] = React.useState(null); + const [submitting, setSubmitting] = React.useState(false); + + const handleSubmit = async ({ email, password }: { email: string; password: string }) => { + setErrorMsg(null); + setSubmitting(true); + try { + const client = new ContractClient(); + await client.command(contributor, op ?? loginOp, { email, password }); + await reloadPrincipal(); + } catch (err) { + if (err instanceof ContractClientError) { + setErrorMsg(err.message || err.code); + } else { + setErrorMsg(String(err)); + } + } finally { + setSubmitting(false); + } + }; + + return ( + + ); +} diff --git a/extensions/dashboard/contract/shell/src/auth/login-card.tsx b/extensions/dashboard/contract/shell/src/auth/login-card.tsx new file mode 100644 index 00000000..024613e3 --- /dev/null +++ b/extensions/dashboard/contract/shell/src/auth/login-card.tsx @@ -0,0 +1,258 @@ +import * as React from "react"; +import { GalleryVerticalEnd, AlertCircle } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { + Field, + FieldDescription, + FieldGroup, + FieldLabel, + FieldSeparator, +} from "@/components/ui/field"; +import { cn } from "@/lib/utils"; + +// Slice (l.5) — visual layer for the dashboard login page. Mirrors +// shadcn's login-04 block: brand lockup, "Welcome to {brand}", optional +// signup link, email + submit, "Or" separator, social provider buttons, +// terms/privacy footer. +// +// Both the contract-driven AuthLoginForm and the built-in LoginScreen +// fallback render this surface so the visual is identical regardless of +// who's contributing the form. Provider buttons are data-driven via +// `socialProviders`; absence collapses the section gracefully. + +export interface SocialProvider { + id: string; + label: string; + /** Click handler — implementations either redirect to the upstream OAuth + * start endpoint (contract path) or throw "not configured" (fallback). */ + onClick: () => void; +} + +export interface LoginCardProps { + brand?: string; + brandLogoURL?: string | null; + /** Optional sub-heading copy; defaults to a short stock line. */ + description?: React.ReactNode; + /** "Don't have an account? Sign up" — pass null to hide. */ + signupHref?: string | null; + signupLabel?: string; + termsURL?: string | null; + privacyURL?: string | null; + /** When false, hides the email/password block (e.g. social-only deployment). */ + passwordEnabled?: boolean; + socialProviders?: SocialProvider[]; + errorMsg?: string | null; + submitting?: boolean; + /** Submit handler invoked with the form's email + password. */ + onSubmit: (input: { email: string; password: string }) => void | Promise; + className?: string; +} + +export function LoginCard({ + brand = "Forge Dashboard", + brandLogoURL, + description, + signupHref, + signupLabel = "Sign up", + termsURL, + privacyURL, + passwordEnabled = true, + socialProviders = [], + errorMsg, + submitting, + onSubmit, + className, +}: LoginCardProps) { + const [email, setEmail] = React.useState(""); + const [password, setPassword] = React.useState(""); + const showPasswordBlock = passwordEnabled; + const showSocialBlock = socialProviders.length > 0; + const showSeparator = showPasswordBlock && showSocialBlock; + const showFooterLegal = Boolean(termsURL || privacyURL); + + const submit = (e: React.FormEvent) => { + e.preventDefault(); + void onSubmit({ email, password }); + }; + + return ( +
+
+
+ + + {showPasswordBlock ? ( + <> + + Email + setEmail(e.target.value)} + /> + + + Password + setPassword(e.target.value)} + /> + + {errorMsg ? ( +
+ + {errorMsg} +
+ ) : null} + + + + + ) : null} + {showSeparator ? Or : null} + {showSocialBlock ? ( + 1 && "sm:grid-cols-2")}> + {socialProviders.map((p) => ( + + ))} + + ) : null} + {description ? ( + {description} + ) : null} +
+
+ {showFooterLegal ? ( + + By clicking continue, you agree to our{" "} + {termsURL ? Terms of Service : Terms of Service} + {" "}and{" "} + {privacyURL ? Privacy Policy : Privacy Policy}. + + ) : null} +
+
+ ); +} + +function BrandLockup({ + brand, + logoURL, + signupHref, + signupLabel, +}: { + brand: string; + logoURL?: string | null; + signupHref?: string | null; + signupLabel: string; +}) { + return ( +
+ + {logoURL ? ( + {brand} + ) : ( +
+ +
+ )} + {brand} +
+

Welcome to {brand}

+ {signupHref ? ( + + Don't have an account? {signupLabel} + + ) : null} +
+ ); +} + +// ProviderIcon resolves a provider id to its brand glyph. Inline SVGs match +// shadcn's login-04 reference; unknown providers fall back to a generic +// circle. Adding a new provider is a one-liner here. +function ProviderIcon({ provider }: { provider: string }) { + switch (provider) { + case "apple": + return ( + + + + ); + case "google": + return ( + + + + ); + case "github": + return ( + + + + ); + case "microsoft": + return ( + + + + ); + case "facebook": + return ( + + + + ); + case "discord": + return ( + + + + ); + default: + return ( + + + + ); + } +} 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..06347be4 --- /dev/null +++ b/extensions/dashboard/contract/shell/src/auth/principal.ts @@ -0,0 +1,140 @@ +import { create } from "zustand"; +import { contractBase } from "../runtime/config"; +import type { Principal } from "../contract/types"; + +interface PrincipalState { + principal: Principal | null; + loaded: boolean; + error: string | null; + // Slice (l): set true when /principal returns 401 with the + // {code:"UNAUTHENTICATED"} envelope. The shell uses this to render the + // built-in LoginScreen (or the auth extension's contract /login route). + // Stays false when auth is disabled server-side (200 anonymous response). + authRequired: boolean; + loginPath: string | null; + // Slice (l.5): set true when /principal returns 403 — the user is signed + // in but lacks the dashboard's required roles. AuthGate renders an + // "access denied" panel instead of letting them through. + accessDenied: boolean; + accessDeniedMessage: string | null; + requiredRoles: string[]; + load: (fetcher?: typeof fetch) => Promise; +} + +interface PrincipalEnvelope { + authenticated: boolean; + subject?: string; + displayName?: string; + email?: string; + roles?: string[]; + scopes?: string[]; +} + +interface UnauthEnvelope { + code?: string; + loginPath?: string; +} + +interface AccessDeniedEnvelope { + code?: string; + message?: string; + requiredRoles?: string[]; +} + +export const usePrincipalStore = create((set) => ({ + principal: null, + loaded: false, + error: null, + authRequired: false, + loginPath: null, + accessDenied: false, + accessDeniedMessage: null, + requiredRoles: [], + async load(fetcher = fetch) { + try { + const res = await fetcher(`${contractBase}/principal`, { credentials: "include" }); + if (res.status === 401) { + const body = (await res.json().catch(() => ({}))) as UnauthEnvelope; + set({ + loaded: true, + error: null, + principal: null, + authRequired: true, + loginPath: body.loginPath ?? null, + accessDenied: false, + accessDeniedMessage: null, + requiredRoles: [], + }); + return; + } + if (res.status === 403) { + const body = (await res.json().catch(() => ({}))) as AccessDeniedEnvelope; + set({ + loaded: true, + error: null, + principal: null, + authRequired: false, + loginPath: null, + accessDenied: true, + accessDeniedMessage: body.message ?? "Access denied.", + requiredRoles: body.requiredRoles ?? [], + }); + return; + } + if (!res.ok) { + set({ + loaded: true, + error: `HTTP ${res.status}`, + principal: null, + authRequired: false, + loginPath: null, + accessDenied: false, + accessDeniedMessage: null, + requiredRoles: [], + }); + return; + } + const env = (await res.json()) as PrincipalEnvelope; + if (!env.authenticated) { + set({ + principal: null, + loaded: true, + error: null, + authRequired: false, + loginPath: null, + accessDenied: false, + accessDeniedMessage: null, + requiredRoles: [], + }); + return; + } + set({ + principal: { + subject: env.subject ?? "", + displayName: env.displayName ?? env.subject ?? "", + email: env.email, + roles: env.roles ?? [], + scopes: env.scopes ?? [], + }, + loaded: true, + error: null, + authRequired: false, + loginPath: null, + accessDenied: false, + accessDeniedMessage: null, + requiredRoles: [], + }); + } catch (err) { + set({ + loaded: true, + error: String(err), + principal: null, + authRequired: false, + loginPath: null, + accessDenied: false, + accessDeniedMessage: null, + requiredRoles: [], + }); + } + }, +})); 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-dialog.tsx b/extensions/dashboard/contract/shell/src/components/ui/alert-dialog.tsx new file mode 100644 index 00000000..d4d41c5d --- /dev/null +++ b/extensions/dashboard/contract/shell/src/components/ui/alert-dialog.tsx @@ -0,0 +1,127 @@ +import * as React from "react"; +import { AlertDialog as BaseAlertDialog } from "@base-ui-components/react/alert-dialog"; +import { cn } from "@/lib/utils"; +import { buttonVariants } from "./button"; + +export const AlertDialog = BaseAlertDialog.Root; + +/** Translates the shadcn `asChild` ergonomic to Base UI's `render` prop. */ +export const AlertDialogTrigger = React.forwardRef< + HTMLButtonElement, + React.ComponentPropsWithoutRef & { asChild?: boolean } +>(({ asChild, children, ...props }, ref) => { + if (asChild && React.isValidElement(children)) { + return ( + >} + {...props} + /> + ); + } + return ( + + {children} + + ); +}); +AlertDialogTrigger.displayName = "AlertDialogTrigger"; + +export const AlertDialogPortal = BaseAlertDialog.Portal; + +export const AlertDialogOverlay = React.forwardRef< + HTMLDivElement, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +AlertDialogOverlay.displayName = "AlertDialogOverlay"; + +export const AlertDialogContent = React.forwardRef< + HTMLDivElement, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + + + + +)); +AlertDialogContent.displayName = "AlertDialogContent"; + +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< + HTMLHeadingElement, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +AlertDialogTitle.displayName = "AlertDialogTitle"; + +export const AlertDialogDescription = React.forwardRef< + HTMLParagraphElement, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +AlertDialogDescription.displayName = "AlertDialogDescription"; + +export const AlertDialogAction = React.forwardRef< + HTMLButtonElement, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +AlertDialogAction.displayName = "AlertDialogAction"; + +export const AlertDialogCancel = React.forwardRef< + HTMLButtonElement, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +AlertDialogCancel.displayName = "AlertDialogCancel"; 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..7f844370 --- /dev/null +++ b/extensions/dashboard/contract/shell/src/components/ui/avatar.tsx @@ -0,0 +1,45 @@ +import * as React from "react"; +import { Avatar as BaseAvatar } from "@base-ui-components/react/avatar"; +import { cn } from "@/lib/utils"; + +export const Avatar = React.forwardRef< + HTMLSpanElement, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +Avatar.displayName = "Avatar"; + +export const AvatarImage = React.forwardRef< + HTMLImageElement, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +AvatarImage.displayName = "AvatarImage"; + +export const AvatarFallback = React.forwardRef< + HTMLSpanElement, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +AvatarFallback.displayName = "AvatarFallback"; diff --git a/extensions/dashboard/contract/shell/src/components/ui/breadcrumb.tsx b/extensions/dashboard/contract/shell/src/components/ui/breadcrumb.tsx new file mode 100644 index 00000000..470e2717 --- /dev/null +++ b/extensions/dashboard/contract/shell/src/components/ui/breadcrumb.tsx @@ -0,0 +1,98 @@ +import * as React from "react"; +import { ChevronRight, MoreHorizontal } from "lucide-react"; +import { cn } from "@/lib/utils"; + +// Slice (l) ports the shadcn Breadcrumb primitives. No external library +// dependency — plain semantic HTML wrapped in cn() helpers, matching the +// shadcn API surface so consumers get the familiar import path. + +export const Breadcrumb = React.forwardRef>( + ({ ...props }, ref) =>