Skip to content

Dashboard contract slice a#29

Open
juicycleff wants to merge 85 commits intomainfrom
dashboard-contract-slice-a
Open

Dashboard contract slice a#29
juicycleff wants to merge 85 commits intomainfrom
dashboard-contract-slice-a

Conversation

@juicycleff
Copy link
Copy Markdown
Contributor

No description provided.

juicycleff added 30 commits May 9, 2026 16:02
…plan

Slice (a) of the new declarative dashboard contract: single-endpoint API
with kind-discriminated envelope, intent + slots schema, server-side
permission filtering, multiplexed SSE for subscriptions, per-contributor
version negotiation. The React shell and contributor migrations are
separate slices.

DESIGN.md captures the locked-in design decisions; IMPLEMENTATION_PLAN.md
breaks the work into 15 phases of TDD-driven tasks.

Adds a .gitignore exception so design docs co-located with the package
are tracked despite the repo-wide **/*.md ignore.
The previous TestCanonicalCodes_AllPresent only verified that constants
are non-empty string literals — it could not fail short of someone
deleting a constant from the list itself. The replacement asserts the
expected count, uniqueness of wire values, and that every Err* sentinel
has a non-empty Code.
…ntKind

Phase 10's POST handler passes req.Kind (wire envelope type) directly into
Action.Kind when invoking a Warden. Action.Kind was inadvertently typed as
IntentKind (the manifest-side enum) which would have caused a type mismatch.
The wire Kind is the right boundary for the Warden — it sees the action as
the caller framed it, not as the registry classifies the intent.
…ss-contributor extensions

Phase 6 of Dashboard Contract slice (a):

- registry.go/registry_test.go: Registry interface (Register, Contributor,
  Intent, HighestVersion, All, MergedGraph). Indexed by (contributor, intent,
  version); HighestVersion tracks the highest non-deprecated version per
  (contributor, intent), falling back to a deprecated version only if no
  active version is registered.

- slots.go/slots_test.go: built-in slot catalog (DefaultSlotCatalog) with
  page.shell, resource.list, dashboard.grid, form.edit. SlotDef carries
  Accepts, Cardinality, and Extensible. MaxSlotDepth = 8. Registration
  runs checkDepth, checkCycle, validateGraphSlots over the manifest's graph.

- Cross-contributor slot extensions: applyExtension walks dotted slot paths
  (e.g. main.detailDrawer.fields), enforces the Extensible flag, and validates
  added intents against the target slot's Accepts list. Each Register call
  deep-copies the manifest's graph into r.mergedGraphs[contributor], then
  applies its extends: directives against the merged graph of the target
  contributor — so extensions never mutate original manifests.
Adds an optional Contract *contract.ContractManifest field to the legacy
contributor.Manifest so a contributor can publish a contract-style
manifest in parallel with the existing templ-based one. Tagged
"contract,omitempty" so legacy contributors that don't opt in keep their
JSON payloads unchanged.

Round-trip + omitempty tests exercise the new field.
Hooks the contract package into the running dashboard extension:

- New Extension fields hold a contract.Registry, contract.WardenRegistry,
  and contract.AuditEmitter, all initialised in NewExtension. The
  streamBroker stays nil in slice (a); slice (c) provides the
  SubscriptionSource needed to instantiate it.
- registerRoutes now mounts POST /api/dashboard/v1 and
  GET /api/dashboard/v1/capabilities alongside the existing JSON API.
  When a streamBroker is present, the SSE stream + control routes also
  register. The stream route uses router.GET (not router.EventStream)
  because StreamBroker.ServeStream owns its own SSE framing as an
  http.HandlerFunc, while router.EventStream's SSEHandler shape
  (func(Context, Stream) error) doesn't match.
- RegisterContributor and the auto-discovery + remote-upsert paths now
  mirror any contract manifest published on the legacy Manifest.Contract
  field into the contract registry, validating against the warden
  registry first via loader.Validate. Explicit-add paths fail closed
  (validation error rolls back the legacy registration); auto-discovery
  logs and continues.

CSRF + idempotency header validation and a real Dispatcher are deferred
to slices (b) and (c); slice (a) wires transport.NilDispatcher so every
intent dispatch returns CodeUnavailable instead of nil-panicking.
…design

Three-tier dispatcher API (function table + contributor interface +
generic typed wrappers), narrow handler signature with optional Result,
subscription handlers via channel + stop, MetricsEmitter interface for
slice-b observability wiring, and a P2 pilot scope: extensions.list,
services.list, services.detail, and a metrics.cpu replace-mode
subscription wired against the existing collector.DataCollector.
Adds form.edit and form.field intents plus the shadcn primitives they
need (Input, Label, Textarea, Checkbox).

- form.edit: optional data-binding to a query intent for prefill,
  fields rendered through the 'fields' slot, gathers field values into
  a record and submits via node.op as a kind=command. Manifest payload
  bindings (e.g., id from parent.id) merge with field values on submit.
  FormStateContext threads values+setter+submitting state to children.
- form.field: branches by props.kind (text/email/number/password/
  textarea/checkbox), labelled with shadcn Label, controlled inputs.
  Reads values and writes back via the form context.

Test setup gains ResizeObserver + pointer-capture stubs that Radix
primitives require under jsdom.

2 new tests cover field-render-and-submit (text + checkbox) and
data-binding prefill from a query intent. Total 19 tests.
…esource.detail, dashboard.grid)

Adds the three resource-shaped intents that turn a query result into
admin-tool UI:

- resource.list: shadcn Table with columns from props.columns (or
  inferred from the first row), rowActions slot rendered per row in
  the trailing column, detailDrawer slot opened in a shadcn Sheet on
  row click. Both slots see the row data via ParentProvider so child
  intents (action.button, resource.detail) can resolve { from:
  parent.<field> } payload bindings without re-fetching. Empty-state
  copy is configurable via props.emptyMessage. Supports three common
  data shapes: array, { items: [] }, or { <key>: [] }.
- resource.detail: dl/dt/dd typography in a shadcn Card. Pulls data
  from node.data when present, otherwise reuses the nearest parent
  context (so list+drawer flows don't re-query). props.fields narrows
  and orders the displayed fields.
- dashboard.grid: responsive Tailwind CSS grid (1 col mobile, props.
  columns at md+). Renders the widgets slot.

Two new shadcn primitives added: Table (with TableHeader/Body/Row/
Head/Cell) and Sheet (Radix Dialog with side variants — right by
default for resource.list's detailDrawer).

Test setup adds an EventSource stub globally so subscription-using
intents (metric.counter, audit.tail) don't generate unhandled
rejections in tests that don't drive SSE events.

4 new tests cover resource.list (rows, columns, empty state),
resource.detail (parent-context binding), and dashboard.grid (slot
rendering). Total 23 tests across 7 files.
Subscribes to a subscription intent declared in node.data, buffers the
last props.bufferSize events (default 200), and renders them in a
scrollable monospace pane inside a Card. Auto-scroll-to-bottom unless
the user has scrolled up — sticky-bottom UX matches admin-tool conventions
for log tails. Two display modes:
- 'json' (default): JSON.stringify the payload
- 'line': pluck the .line field for raw text feeds

Adds shadcn ScrollArea primitive (Radix-based) for the scroll region.
README expanded from quickstart-only to a full developer-facing guide:
project structure with per-file purposes, theming overview, embedding
explanation, and pointer to ARCHITECTURE.md for the deep dive.

ARCHITECTURE.md is the new deep-dive: pipeline diagram, the five core
concepts (graph, registry, slots, contributor/parent contexts,
bindings), step-by-step walkthrough for authoring a new intent (with
real code), guidance on adding shadcn primitives, testing strategy,
performance budget, known limitations, and rationale for the major
tech choices (shadcn vendored, TanStack Query, Zustand, CSS variables).

Adds a .gitignore exception so shell/*.md are tracked alongside the
already-tracked design + plan docs.
…Base UI

Per directive: 'use sadcn baseui and not radix.' shadcn ships parallel
Radix-based and Base UI-based variants of the same components. This
slice swaps the dashboard shell to the Base UI variant
(@base-ui-components/react). Public component imports (@/components/ui/*)
and the v1 vocabulary are unchanged; only the primitive layer
underneath shifted.

Removed: @radix-ui/react-{slot,separator,dropdown-menu,dialog,avatar,
scroll-area,select,checkbox,label,alert-dialog}.
Added: @base-ui-components/react@1.0.0-rc.0.

Rewrites in src/components/ui/ — public component names and prop
shapes preserved by the wrappers:
- button.tsx — local Slot helper for asChild (Base UI has no Slot
  primitive; uses render prop instead).
- separator.tsx — Base UI Separator.
- avatar.tsx — Avatar.Root / .Image / .Fallback.
- dropdown-menu.tsx — Menu façade (Base UI calls it Menu, not
  DropdownMenu). Trigger wrapper translates asChild → render.
- alert-dialog.tsx — AlertDialog with Trigger asChild→render.
- sheet.tsx — Dialog with side variants and Trigger asChild→render.
- scroll-area.tsx — ScrollArea.Root / .Viewport / .Scrollbar / .Thumb.
- checkbox.tsx — Checkbox.Root / .Indicator. onCheckedChange unchanged.
- label.tsx — plain <label> + cva (Base UI doesn't ship Label).

card.tsx, alert.tsx, skeleton.tsx, table.tsx, input.tsx, textarea.tsx
were pure-styling primitives — no change.

Form test updated: Base UI Checkbox renders span[role=checkbox] with
a Base-generated id, so getByLabelText doesn't traverse via htmlFor.
Test now uses getByRole('checkbox').

Bundle: ~140KB gzipped JS (was ~120KB on Radix). Within the 300KB
budget. CSS unchanged at 5KB. All 23 tests + lint + Go suite green.
- Added auto-discovery for ContractContributorAware in the dashboard extension to register contract contributors.
- Introduced new streaming contract handlers for managing channels, connections, rooms, and presence.
- Created manifest.yaml for the streaming contract, defining intents and capabilities.
- Implemented various query and mutation handlers for stats, connections, rooms, and presence management.
- Enhanced the streaming extension to support both legacy and new contract paths during migration.
contract_test.go exercises stats, kick-connection, delete-room,
send-message, set-presence, config, and presence-list handlers via a
stub Manager. Covers the canonical-error mapping (BadRequest,
NotFound, Unavailable) for each mutation.

manifest_test.go loads the embedded manifest.yaml and validates it
against an empty WardenRegistry. Asserts contributor name,
≥14 declared intents (9 reads + 5 mutations), and 6 routes.
… CoreContributor pages

Extends the pilot manifest and handlers to cover the four CoreContributor pages
the templ dashboard serves today: Overview, Health, Metrics report, and Traces.

- types.go: add OverviewResponse, HealthList/Entry, MetricsReportResponse,
  TraceSummaryDTO, TracesList, TraceDetailInput, TraceDetailResponse.
- overview.go, health.go, metrics_report.go, traces.go: query handlers
  projecting collector data into the wire types; nil providers return
  CodeUnavailable; trace.detail returns CodeNotFound on miss and
  CodeBadRequest on empty id.
- manifest.yaml: register five new intents (overview, health, metrics-report,
  traces.list, traces.detail) and four new graph routes (/, /health, /metrics,
  /traces) under the existing core-contract contributor.
- pilot.go: new Deps fields (Overview, Health, MetricsReport, Traces) wired
  into Register; partial wiring tolerated.
- extension.go: pass e.collector and e.traceStore as the new providers.
- slice_h_test.go: unit tests for each handler covering happy path,
  CodeUnavailable, CodeNotFound, and CodeBadRequest error mapping.
…ct to React shell

The contract React shell (slice d) plus the slice-(h) pilot now serve every
page CoreContributor provided. This slice removes the duplicate templ stack
and forwards old paths to /dashboard/contract/app/* via 302 so existing
bookmarks keep working.

Removed:
- extensions/dashboard/core_contributor.go (CoreContributor + manifest +
  RenderPage/RenderWidget/RenderSettings)
- NewCoreContributor registration in extension.go
- Ten CoreContributor-only templ pages and their _templ.go artifacts:
  overview, health, metrics, metrics_all, metrics_collector_detail,
  metrics_detail, services, extensions, traces, trace_detail
- The eight matching *_helpers.go files that fed them
- The ten templ-rendering page methods on PagesManager (~250 LOC)

Added:
- redirectTo / redirectTraceDetail helpers in pages.go that 302 to the
  shell. /metrics/all, /metrics/collectors/:name, /metrics/detail/*name
  collapse onto /contract/app/metrics; slice (j) adds proper deep-link
  routes when those detail pages get rebuilt React-side.
- extensions/dashboard/contract/SLICE_I_DESIGN.md

Kept (still used by extension contributors):
- ui/shell/, layouts/, ui/components.templ, ui/widgets.templ,
  ui/metrics.templ, ui/tables.templ, ui/pages/error.templ.

Net: ~15k LOC of generated templ + helpers removed; one contract pilot
+ one React shell remain. go build ./... + go test ./... clean.
…:id deep-link

The contract React shell never actually fetched live graphs — POST kind=graph
404'd because page.shell isn't a registered intent (it's a vocabulary marker
for slot validation). The slice (d) shell tests passed by mocking the response.
This slice wires the graph endpoint end-to-end and lays the foundation for
deep-link detail routes.

Server:
- transport/http.go: special-case kind=graph in ServeHTTP — bypass the intent
  table and dispatch via GraphBuilder.BuildWithParams. Map ErrNotFound /
  ErrPermissionDenied to wire codes + matching HTTP status. CSRF and
  idempotency stay command-only as before.
- registry.go: new Registry.MatchRoute(contributor, route) that adds :name
  pattern matching alongside existing exact matching. Exact wins when both
  match. Returns extracted name->value params.
- graph.go: GraphBuilder.BuildWithParams threads params through the
  visibleWhen filter; legacy Build() delegates.
- envelope.go: ResponseMeta.RouteParams carries the extracted segments.
- pilot/manifest.yaml: add /traces/:id deep-link route binding traces.detail
  with id from route.id. Slice (h)'s /traces drawer pattern stays.
- pilot/graph_test.go: new HTTP-level tests covering exact match, :id
  extraction, NotFound mapping, and exact-wins-over-param ordering.

Shell:
- contract/client.ts: graph() now returns { node, routeParams }; new
  sendEnvelope() variant returns the full Response so meta is reachable.
- contract/hooks.ts: useContractGraph returns GraphResult.
- runtime/context.tsx: new RouteParamsProvider + useRouteParams.
- App.tsx: wraps the renderer with RouteParamsProvider so :name placeholders
  flow into bindings.
- intents/{resource.detail,action.button,action.menu,form.edit}.tsx: pass
  route into resolvePayload/resolveValue so `{ from: route.id }` payloads
  resolve when the user lands on /traces/abc directly.
- contract.test.ts: extended for new shape; new test covers route param
  surfacing.

Slice (i) cleanup:
- pages.go: redirectTraceDetail now forwards /dashboard/traces/:id to
  /contract/app/traces/:id (path-style) instead of ?id=… query string.

go build ./... + go test ./... + pnpm test (24 React tests) + pnpm build clean.
…ubscription

Closes two related gaps:

1. The audit emitter (slice b) wrote to stdout/Logger and disappeared. There
   was no way for the dashboard to surface audited commands.
2. The audit.tail vocabulary intent + React component (slice e) had no
   matching server-side handler — clicking the audit widget produced
   nothing.

Added:
- contract/audit_store.go:
  - AuditStore interface + AuditFilter (Limit/Contributor/Intent/User/Result).
  - In-memory ring-buffer impl (default cap 1000), fan-out subscriptions,
    non-blocking sends so a slow consumer can't block command writes.
  - RecordingAuditEmitter that wraps an inner emitter (existing log-based
    one) and persists to the store.
- contract/pilot/audit.go:
  - AuditProvider interface + AuditRecordDTO with RFC3339Nano timestamps.
  - audit.list query handler (filters + nil-store -> CodeUnavailable).
  - audit.tail subscription handler (streams Append events; cancellation
    tears down the subscriber cleanly).
- pilot/manifest.yaml:
  - Two new intents (audit.list query, audit.tail subscription/append).
  - auditList named query with 5s staleTime cache.
  - New /audit route under Operations nav rendering audit.tail.
- contract/slots.go: extend page.shell.main Accepts to include audit.tail
  so the new top-level route validates.
- extension.go:
  - Construct e.auditStore at NewExtension time.
  - Wrap the chosen audit emitter (log/structured) with RecordingAuditEmitter
    so commands flow into both log lines AND the store.
  - Pass e.auditStore as pilot.Deps.Audit.

Tests:
- audit_store_test.go: append/list ordering, intent filter, limit clamping,
  ring truncation, subscribe broadcast, cancel-closes-channel,
  RecordingEmitter fan-out.
- pilot/audit_test.go: handler projection (timestamp formatting),
  CodeUnavailable on nil provider, subscription streams Appends,
  CodeUnavailable from subscription handler.
- pilot/types_test.go: bumped manifest counts (intents 9->11, routes 8->9).

go build + go test ./... clean (37 packages green).
…o non-default base paths work

Reported: hosting Forge on port 7901 with the dashboard mounted at a
non-default base produced 404s on /api/dashboard/v1 because the React shell
hardcoded both that path and the React Router basename to /dashboard.

Server: extension.go's makeShellSPAHandler now buffers index.html and
injects a small <script> just before </head> that exposes
window.__FORGE_DASHBOARD__ = { basePath, contractBase, shellBase }, derived
from e.config.BasePath. Shell:

- runtime/config.ts (new): reads the injected globals at module load and
  exports basePath / contractBase / shellBase. Falls back to /dashboard so
  Vite dev mode + unit tests + direct module imports still resolve.
- contract/client.ts, contract/sse.ts: default baseURL now comes from
  contractBase instead of the hardcoded literal.
- auth/principal.ts: /principal fetch is `${contractBase}/principal`.
- App.tsx: BrowserRouter basename comes from shellBase.

Test setup pins __FORGE_DASHBOARD__ to /api/dashboard/v1 so the existing
MSW handlers keep matching.

24 React tests + 37 Go packages green; pnpm lint and pnpm build clean.
…l login gate

Replaces the bare GraphRenderer mount with a proper shadcn-style dashboard
layout (sidebar + topbar + content) and adds an optional login gate that
auth extensions like authsome can plug into without writing React.

## Layout

- Vendored shadcn's `<Sidebar>` block on the existing Base UI primitives:
  new `components/ui/sidebar.tsx` exposing the canonical SidebarProvider /
  Sidebar / SidebarMenu / SidebarTrigger / SidebarInset / useSidebar API,
  built on Sheet (mobile drawer), local Slot helper (asChild), cookie
  persistence, and Cmd+B shortcut. Plus `components/ui/breadcrumb.tsx`.
- New `runtime/layout.tsx::DashboardLayout` composes the chrome: sidebar
  with nav groups, topbar with breadcrumb + theme toggle, sidebar footer
  with user badge.
- `runtime/navigation.ts::useNavigation` calls a new contract query.
- `App.tsx::PageRoute` wraps the renderer in DashboardLayout; `page.shell`
  intent simplifies to render only its main slot (chrome moved up).
- Tailwind + index.css gain the shadcn `--sidebar-*` tokens (light + dark).
- jsdom test setup polyfills `matchMedia` for the new mobile-breakpoint
  hook.

## Navigation pilot query

- `contract/pilot/navigation.go` walks `Registry.All()`, projects each
  contributor's top-level routes whose graph node carries Nav metadata,
  groups by Nav.Group, sorts groups by the same priority order the legacy
  templ sidebar uses, sorts items within a group by Nav.Priority.
- Manifest: registers `navigation` query intent (capability=read), 60s
  staleTime cache. Wired through `pilot.Register`.
- Tests cover group ordering, in-group priority sort, skipping routes
  without Nav (e.g. /traces/:id detail), nil registry => CodeUnavailable.

## Optional login gate

- Principal endpoint refactored to `NewPrincipalHandler(opts)` distinguishing
  three responses:
  - 200 `{authenticated:false}` when auth is disabled (shell skips gate).
  - 401 `{code:"UNAUTHENTICATED",loginPath:...}` when enabled but unauthed.
  - 200 with full principal when authenticated.
  Backwards-compatible `HandleAPIPrincipalHTTP` preserved.
- Shell `usePrincipalStore` adds `authRequired` + `loginPath` derived from
  the new envelope shapes.
- `auth/AuthGate.tsx` blocks the layout while the principal loads, then
  passes through (auth disabled or signed in) or replaces the tree with
  the built-in `LoginScreen`.
- `auth/LoginScreen.tsx` is a minimal email+password form that issues a
  `kind: command, intent: <loginOp>` envelope (default `auth.login`,
  configurable via `window.__FORGE_DASHBOARD__.loginOp`). On 200 it
  reloads the principal so the gate releases.
- Server bootstrap injection (extension.go) extends the existing slice (i)
  config map with `authEnabled`, `loginPath`, `loginOp`. Shell config.ts
  surfaces them as `authEnabled`, `loginPath`, `loginOp` exports.
- aware.go documents the authsome integration shape: implement
  DashboardAuthAware (SetAuthChecker + EnableAuth) plus
  ContractContributorAware (register `auth.login` command + optional
  `/login` graph route to override the built-in form).

## Tests

Go: navigation_test.go (3 cases), principal_test.go (+2 cases for
auth-disabled vs auth-enabled responses).
React: auth.test.tsx (5 cases — gate pass-through, gate redirects,
LoginScreen happy path, error path, principal store envelope handling),
layout.test.tsx (2 cases — sidebar nav groups render from contract,
breadcrumb fallback).

Manifest counts bumped (intents 11 -> 12).

go build + go test ./... clean (37 packages green); pnpm test 32 pass;
pnpm lint clean; pnpm build clean.
…in route, polish built-in form

Two follow-ons to slice (l):

1. The auth extension now owns the login UI by default. AuthGate fetches
   useContractGraph(loginContributor, "/login") before falling back to the
   built-in LoginScreen. authsome (or any extension) registers a single
   `/login` graph route under its contributor (default "auth") to take
   over — no React code, just YAML + a command intent.
   - extension.go: bootstrap config gains `loginContributor: "auth"`.
   - runtime/config.ts: surfaces `loginContributor` (default "auth").
   - auth/AuthGate.tsx: when authRequired, fetches the contract /login
     graph; renders it via GraphRenderer wrapped in
     ContributorProvider/RouteParamsProvider when the extension owns it.
     404 / no graph → built-in LoginScreen.

2. Built-in LoginScreen polished to match the latest shadcn login-03 layout
   that ships with the Sidebar block from ui.shadcn.com:
   - Centered card on a subtle bg-muted/40 background.
   - Brand lockup above the card (LayoutDashboard icon + "Forge Dashboard"
     by default; override via prop).
   - "Welcome back" heading + concise description copy.
   - Email/password inputs with placeholder, "Forgot password?" link
     beside the password label, full-width primary submit button.
   - Inline error block with AlertCircle icon for failed auth.
   - Footer caption beneath the card.

Tests: AuthGate test split into three cases — pass-through, contract-owned
/login override, fallback to built-in form on 404. 33 React tests green.
…handlers via ctx

Auth extensions registering an `auth.login` command via the contract path
need to write a session cookie on the HTTP response — the contract handler
shape (in, principal -> out, error) doesn't carry the writer. Slice (l)'s
AuthGate routes login through the contract; the command handler had no
way to set the cookie.

Adds two small dashauth helpers:
  - WithHTTP(ctx, w, r) ctx
  - ResponseWriterFromContext(ctx) http.ResponseWriter
  - RequestFromContext(ctx) *http.Request

The contract transport handler now stashes both before dispatching, so
authsome (and any future extension that legitimately needs HTTP escape
hatches — downloads, redirects) can pull them via dashauth.

Pure data handlers ignore them. Documented as an escape hatch.
… + role gate

Brings the dashboard's login UX up to the latest shadcn login-04 reference
and adds two integration seams the auth extension uses to drive the form:

1. **Visual layer (LoginCard).** New `auth/login-card.tsx` is the canonical
   login surface — brand lockup, "Welcome to {brand}" heading, optional
   "Don't have an account? Sign up" line, email + Login button, "Or"
   separator, social provider button grid (Apple/Google/GitHub/Microsoft/
   Facebook/Discord glyphs ship inline; unknown providers fall back to a
   neutral circle), terms/privacy footer. Styled via the existing primitives
   plus a new shadcn-style `field.tsx` (Field/FieldGroup/FieldLabel/
   FieldDescription/FieldSeparator) that other shadcn blocks slot into.

2. **AuthLoginForm intent (`auth.login.form`).** New React component
   registered in the intent registry — the dashboard's contract `/login`
   route renders this. It fetches an `auth.config` query from the active
   contributor (authsome) and projects the result into LoginCard.
   Authsome owns brand, signup link, terms/privacy, and the configured
   list of social providers; the shell renders whatever ships, no React
   change required when authsome enables a new provider.

   Submits the password block via the existing `kind:command` envelope
   to the configured loginOp. Social buttons POST to each provider's
   `authStartURL` (returned by auth.config) and navigate to the upstream
   `auth_url`, matching authsome's social plugin start-flow contract.
   `slots.go` declares `auth.login.form` as a leaf vocabulary intent.

3. **Built-in fallback aligned.** LoginScreen now renders LoginCard with
   hardcoded defaults so deployments without an auth extension still get
   the polished login-04 visual. Visuals are byte-identical to the
   contract-rendered path; only the data plane differs.

4. **Required-roles gate.** New `WithRequiredRoles([]string)` option +
   `Extension.SetRequiredRoles` API. The principal handler returns 403
   `{code:"PERMISSION_DENIED", message, requiredRoles}` for users who
   don't carry a matching role. React `usePrincipalStore` adds
   `accessDenied` / `accessDeniedMessage` / `requiredRoles`; AuthGate
   renders an "Access denied" panel (ShieldAlert + message + retry button)
   when the gate fires. Auth extensions like authsome wire it via
   `SetRequiredRoles` from their own role config so dashboards can be
   locked down to specific user populations without writing handler code.

Tests:
- React: 9 auth tests (was 7) — added access-denied panel rendering,
  store handling of 403 envelope, plus the fallback LoginScreen still
  exercising password command flow with the new "Login" button label.
- Go: principal handler tests cover 200 anonymous, 401 unauth, 403
  required-role gate; transport + pilot suites unaffected.
- 35 React tests / 15 Go dashboard packages green; lint + build clean.

Authsome wires `auth.config` + `auth.login.form` /login route in a
companion commit on the authsome repo.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant