Compare commits
11 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 414b2290ce | |||
| b0e9dd7f80 | |||
| 42b864376c | |||
| ac7286f83b | |||
| 75d7abc089 | |||
| 6b9ce92103 | |||
| 0661e1d768 | |||
| 0cae963480 | |||
| 983d761bbe | |||
| d03dfe502f | |||
| 5ad6d77c56 |
419
CHANGELOG.md
419
CHANGELOG.md
@@ -2,6 +2,425 @@
|
|||||||
|
|
||||||
All notable changes to Armature are documented here.
|
All notable changes to Armature are documented here.
|
||||||
|
|
||||||
|
## v0.9.9 — Surface Access via Roles
|
||||||
|
|
||||||
|
Surfaces can now gate access by team role. A manifest declaring
|
||||||
|
`"access": "role:approver"` restricts the surface to users who hold the
|
||||||
|
`approver` role in any team — consistent with how `group:NAME` works.
|
||||||
|
|
||||||
|
**New surface access level: `role:ROLENAME`**
|
||||||
|
|
||||||
|
- Added to both `evaluateAccess()` and `validAccessLevels()` validation
|
||||||
|
- User must hold the specified role in at least one team (any-team
|
||||||
|
semantics — no team context needed in the URL)
|
||||||
|
- System admins bypass role checks, consistent with `RequireRole`
|
||||||
|
middleware
|
||||||
|
- Unauthenticated users get redirected to login
|
||||||
|
- Graceful fallback: if Teams store is nil, access is denied (fail closed)
|
||||||
|
|
||||||
|
**New store method: `HasRoleInAnyTeam(ctx, userID, role)`**
|
||||||
|
|
||||||
|
- Checks both primary role (`team_members`) and additional roles
|
||||||
|
(`team_user_roles`) across all teams in a single query
|
||||||
|
- Implemented for both SQLite and PostgreSQL
|
||||||
|
|
||||||
|
**Refactor:** `evaluateAccess` promoted from package-level function to
|
||||||
|
`Engine` method to enable store access for role lookups.
|
||||||
|
|
||||||
|
**Tests:** 10 new tests — 5 handler integration tests (granted, denied,
|
||||||
|
unauthenticated, admin bypass, nil store), 3 store tests
|
||||||
|
(primary role, additional role, no match), 2 validation tests
|
||||||
|
(valid role, empty role name)
|
||||||
|
|
||||||
|
## v0.9.8 — Conditional Routing → SDK Primitive
|
||||||
|
|
||||||
|
Promotes the workflow branch-rule engine to a generic Starlark SDK
|
||||||
|
module available to all extensions — no permission required.
|
||||||
|
|
||||||
|
**New Starlark module: `routing`**
|
||||||
|
|
||||||
|
- `routing.evaluate(rules, data)` — evaluates an ordered list of
|
||||||
|
condition rules against a data dict; returns the first matching
|
||||||
|
rule's `target` string, or `None` if no rule matches
|
||||||
|
- 10 operators: `exists`, `not_exists`, `eq`, `neq`, `gt`, `lt`,
|
||||||
|
`gte`, `lte`, `in`, `contains`
|
||||||
|
- First-match-wins semantics
|
||||||
|
- Domain-agnostic: uses `target` (not `target_stage`) so any
|
||||||
|
extension can use it for feature flags, content routing, approval
|
||||||
|
logic, etc.
|
||||||
|
- Always available — pure computation, no I/O, no permission gate
|
||||||
|
|
||||||
|
**Tests:** 8 new unit tests covering all operators, type coercion,
|
||||||
|
first-match-wins, empty/missing/bad input
|
||||||
|
|
||||||
|
## v0.9.7 — Full Read/Write Workflow Starlark Module
|
||||||
|
|
||||||
|
Extensions with `workflow.access` permission can now start, advance,
|
||||||
|
cancel, and signoff workflow instances directly from Starlark scripts.
|
||||||
|
|
||||||
|
**WorkflowEngine interface**
|
||||||
|
|
||||||
|
- Defined in `sandbox` package to break the `workflow ↔ sandbox` circular
|
||||||
|
import (follows `NotificationSender` / `ConnectionResolver` pattern)
|
||||||
|
- 4 methods: `Start`, `Advance`, `Cancel`, `SubmitSignoff`
|
||||||
|
- `workflow.Engine` satisfies the interface implicitly — zero changes to
|
||||||
|
the engine package
|
||||||
|
|
||||||
|
**New Starlark builtins**
|
||||||
|
|
||||||
|
- `workflow.start(workflow_id, data={})` → instance dict
|
||||||
|
- `workflow.advance(instance_id, data={})` → instance dict
|
||||||
|
- `workflow.cancel(instance_id)` → None
|
||||||
|
- `workflow.submit_signoff(instance_id, decision, comment="")` → signoff dict
|
||||||
|
- All write ops require authenticated user context (`RunContext.UserID`)
|
||||||
|
- Graceful errors when engine or user context unavailable
|
||||||
|
|
||||||
|
**Refactors**
|
||||||
|
|
||||||
|
- `instanceToDict` helper extracted — shared by `get_instance`, `start`,
|
||||||
|
`advance`
|
||||||
|
- `signoffToDict` helper for signoff return values
|
||||||
|
- `dictToJSON` helper for Starlark dict → `json.RawMessage` conversion
|
||||||
|
|
||||||
|
**Tests:** 6 new unit tests (mock engine, happy path + error guards)
|
||||||
|
|
||||||
|
## v0.9.6 — Deprecate stage_type, Collapse stage_mode
|
||||||
|
|
||||||
|
Simplifies the workflow stage classification model by removing
|
||||||
|
redundant fields.
|
||||||
|
|
||||||
|
**stage_type deprecated**
|
||||||
|
|
||||||
|
- No longer validated on input; any value accepted, defaults to "simple"
|
||||||
|
- Existing manifests parsed for backward compatibility
|
||||||
|
- DB column retained; export no longer includes the field
|
||||||
|
- `starlark_hook` presence (not `stage_type`) determines automation
|
||||||
|
|
||||||
|
**stage_mode collapsed (4 → 3 values)**
|
||||||
|
|
||||||
|
- "review" removed as valid mode; mapped to "form" on input
|
||||||
|
- Existing DB rows migrated: review → form
|
||||||
|
- Review surface removed from workflow.html (~110 lines); signoff system
|
||||||
|
in `stage_config.validation` handles review behavior
|
||||||
|
- Valid modes: form, delegated, automated
|
||||||
|
|
||||||
|
**DB migration 018**
|
||||||
|
|
||||||
|
- Postgres: UPDATE + CHECK constraint replacement
|
||||||
|
- SQLite: UPDATE only (CHECK stays broad)
|
||||||
|
|
||||||
|
**Package manifests updated**
|
||||||
|
|
||||||
|
- bug-report-triage, content-approval, employee-onboarding,
|
||||||
|
webhook-notifier: review → form, stage_type removed
|
||||||
|
|
||||||
|
## v0.9.5 — Typed Forms → SDK Primitive
|
||||||
|
|
||||||
|
Promotes the typed form system from a workflow-only model to a reusable
|
||||||
|
SDK primitive. Any package can now declare and validate forms — not just
|
||||||
|
workflow stages.
|
||||||
|
|
||||||
|
**Backend — `server/forms/` package**
|
||||||
|
|
||||||
|
- Extracted `TypedFormTemplate`, `FormField`, `FormFieldset`, `FormOption`,
|
||||||
|
`FormValidation`, `FieldCondition`, `FormHooks`, `FieldError` from
|
||||||
|
`models/workflow.go` into standalone `forms` package.
|
||||||
|
- `ParseTypedFormTemplate()`, `ValidateFormData()`, `EvaluateFieldCondition()`
|
||||||
|
exported for use by any consumer.
|
||||||
|
- ~317 lines removed from `models/workflow.go` (no external imports existed).
|
||||||
|
|
||||||
|
**REST endpoint**
|
||||||
|
|
||||||
|
- `POST /api/v1/forms/validate` — authenticated endpoint. Accepts
|
||||||
|
`{template, data}`, returns `{valid: bool, errors: [{key, message}]}`.
|
||||||
|
|
||||||
|
**Starlark module**
|
||||||
|
|
||||||
|
- `forms.validate(template, data)` — gated by `forms.validate` permission.
|
||||||
|
Returns dict with `valid` (bool) and `errors` (list of dicts).
|
||||||
|
|
||||||
|
**Frontend SDK**
|
||||||
|
|
||||||
|
- `sw.forms.render(container, template, opts)` — Preact component rendering
|
||||||
|
flat and progressive (fieldset) forms. Returns `{getData, setErrors, destroy}`.
|
||||||
|
- `sw.forms.validate(template, data)` — client-side validation (mirrors Go logic).
|
||||||
|
- `sw.forms.validateRemote(template, data)` — server-side validation via REST.
|
||||||
|
- SDK version bumped to `0.9.5`.
|
||||||
|
|
||||||
|
**Manifest validation**
|
||||||
|
|
||||||
|
- `form_template` accepted at package level. Validated via
|
||||||
|
`forms.ParseTypedFormTemplate()` at install time.
|
||||||
|
- `ManifestInfo.HasFormTemplate` flag added.
|
||||||
|
|
||||||
|
**Tests**
|
||||||
|
|
||||||
|
- 16 new tests: 12 forms package unit tests (parse, validate by type,
|
||||||
|
conditions, fieldsets), 4 Starlark module tests (valid, required,
|
||||||
|
type errors, conditions).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## v0.9.4 — Package Adoption + Roles
|
||||||
|
|
||||||
|
Packages can now declare `adoptable: true` in their manifest. When a team
|
||||||
|
adopts an adoptable package, a team-scoped copy is created that references
|
||||||
|
the original (shared assets, no disk duplication). The package's
|
||||||
|
`requires_roles` auto-populate into a new `team_role_catalog` table so
|
||||||
|
team admins know which roles to assign.
|
||||||
|
|
||||||
|
**Schema**
|
||||||
|
|
||||||
|
- Migration 017: `adoptable` and `adopted_from` columns on `packages`.
|
||||||
|
`team_role_catalog` table (team_id, role, source_package_id) with
|
||||||
|
unique constraint. Both Postgres and SQLite dialects.
|
||||||
|
|
||||||
|
**Store + Models**
|
||||||
|
|
||||||
|
- `PackageRegistration`: `Adoptable bool`, `AdoptedFrom *string`.
|
||||||
|
- `PackageStore`: `ListAdoptable()`, `GetByAdoptedFrom()`.
|
||||||
|
- `TeamRoleCatalogEntry` model struct.
|
||||||
|
- `TeamStore`: `AddRoleToCatalog`, `ListRoleCatalog`,
|
||||||
|
`RemoveRoleCatalogBySource`.
|
||||||
|
|
||||||
|
**Handlers**
|
||||||
|
|
||||||
|
- `POST /teams/:teamId/packages/:id/adopt` — adopt a global adoptable
|
||||||
|
package into the team. Creates team-scoped registration, clones
|
||||||
|
workflow if applicable, populates role catalog. Idempotent.
|
||||||
|
- `GET /teams/:teamId/packages/adoptable` — list available packages
|
||||||
|
with adoption status per team.
|
||||||
|
- `DELETE /teams/:teamId/packages/:id/unadopt` — remove adopted package
|
||||||
|
and clean up role catalog entries.
|
||||||
|
- `GET /teams/:teamId/roles/catalog` — list known roles for a team.
|
||||||
|
|
||||||
|
**Manifest Validation**
|
||||||
|
|
||||||
|
- `adoptable: true` parsed as `ManifestInfo.Adoptable`.
|
||||||
|
- Rejected on `library` and `test-runner` types.
|
||||||
|
- Persisted to DB on package install.
|
||||||
|
|
||||||
|
**Deprecation**
|
||||||
|
|
||||||
|
- `POST /teams/:teamId/workflows/:id/adopt` (`AdoptTeamWorkflow`) now
|
||||||
|
returns `X-Deprecated` header and logs a deprecation warning.
|
||||||
|
Use the package-level adoption endpoint instead.
|
||||||
|
|
||||||
|
**Tests**
|
||||||
|
|
||||||
|
- 11 new tests: 5 manifest validation, 3 role catalog store, 3 handler
|
||||||
|
integration (adopt success, idempotent, non-adoptable rejection).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## v0.9.3 — Team User Roles
|
||||||
|
|
||||||
|
Promotes the team role system from a single-role-per-member model to a
|
||||||
|
many-to-many relationship, enabling users to hold multiple roles within
|
||||||
|
a team simultaneously.
|
||||||
|
|
||||||
|
**Schema**
|
||||||
|
|
||||||
|
- Migration 016: `team_user_roles` table (team_id, user_id, role) with
|
||||||
|
unique constraint and compound index. Both Postgres and SQLite dialects.
|
||||||
|
|
||||||
|
**Store + Models**
|
||||||
|
|
||||||
|
- `TeamUserRole` model struct.
|
||||||
|
- 6 new `TeamStore` methods: `AddUserRole`, `RemoveUserRole`,
|
||||||
|
`ListUserRoles`, `GetMemberRoles` (union of primary + additional),
|
||||||
|
`HasRole`, `RemoveAllUserRoles`.
|
||||||
|
- Implementations for both Postgres and SQLite stores.
|
||||||
|
|
||||||
|
**Middleware**
|
||||||
|
|
||||||
|
- `RequireRole(teams, roles, stores)` — kernel middleware that checks
|
||||||
|
whether the user holds at least one of the required roles (OR semantics).
|
||||||
|
System admin bypass via permissions.
|
||||||
|
|
||||||
|
**Handlers**
|
||||||
|
|
||||||
|
- `GET /teams/:teamId/members/:memberId/roles` — list full role set.
|
||||||
|
- `POST /teams/:teamId/members/:memberId/roles` — assign additional role.
|
||||||
|
- `DELETE /teams/:teamId/members/:memberId/roles/:role` — remove role.
|
||||||
|
- `RemoveMember` handler now cleans up `team_user_roles` on member removal.
|
||||||
|
|
||||||
|
**Manifest**
|
||||||
|
|
||||||
|
- `requires_roles` field parsed from package manifests (advisory in v0.9.3;
|
||||||
|
extensions gate via `teams.has_role()` in Starlark).
|
||||||
|
|
||||||
|
**Starlark SDK**
|
||||||
|
|
||||||
|
- New `teams` module wired into sandbox runner:
|
||||||
|
- `teams.get_member_roles(team_id, user_id)` → list of strings.
|
||||||
|
- `teams.has_role(team_id, user_id, role)` → True/False.
|
||||||
|
|
||||||
|
**Admin UI**
|
||||||
|
|
||||||
|
- Team-admin members page: removable badge chips for additional roles,
|
||||||
|
"+ Role" dropdown for assignment.
|
||||||
|
- Fixed pre-existing SDK auto-unwrap bug in `loadRoles` / `loadMemberRoles`.
|
||||||
|
|
||||||
|
**Tests**
|
||||||
|
|
||||||
|
- 10 new tests: store CRUD (add, idempotent, has_role, remove, removeAll),
|
||||||
|
middleware (allowed, denied), manifest parsing (valid, empty, invalid).
|
||||||
|
|
||||||
|
## v0.9.2 — Starlark Converter Consolidation + Snapshot Cleanup
|
||||||
|
|
||||||
|
Consolidates duplicated Go↔Starlark conversion code and snapshot
|
||||||
|
parsers into canonical locations, removing ~350 lines of copy-paste
|
||||||
|
across 8 files.
|
||||||
|
|
||||||
|
**Converter consolidation**
|
||||||
|
|
||||||
|
- New `sandbox/convert.go` with four exported functions:
|
||||||
|
`GoToStarlark`, `StarlarkToGo`, `DictToMap`, `MapToDict`.
|
||||||
|
- Superset implementation handles all Go primitive types (nil, bool,
|
||||||
|
int, int64, float64, string), containers (map, slice), and Starlark
|
||||||
|
Tuple — covering every variant that previously existed.
|
||||||
|
- Deleted duplicate converters from `workflow/automated.go`,
|
||||||
|
`handlers/starlark_helpers.go`, `handlers/workflow_hooks.go`,
|
||||||
|
`sandbox/workflow_module.go`, `sandbox/realtime_module.go`,
|
||||||
|
`sandbox/files_module.go`, `triggers/event.go`, `triggers/webhook.go`.
|
||||||
|
- SQL-specific converters in `db_module.go` (error-returning, `[]byte`
|
||||||
|
handling) intentionally excluded — different semantics.
|
||||||
|
|
||||||
|
**Snapshot parser consolidation**
|
||||||
|
|
||||||
|
- New `models/snapshot.go` with `ParseSnapshotStages()` handling both
|
||||||
|
wrapped `{"stages":[...]}` and legacy flat `[...]` formats.
|
||||||
|
- Deleted three identical parsers from `workflow/engine.go`,
|
||||||
|
`handlers/workflow_instance_handlers.go`, and
|
||||||
|
`handlers/workflow_assignment_handlers.go`.
|
||||||
|
|
||||||
|
**Snapshot format standardization**
|
||||||
|
|
||||||
|
- `workflow_packages.go` publish path now emits wrapped format,
|
||||||
|
matching `workflows.go`. All snapshot creation is consistent.
|
||||||
|
|
||||||
|
**Tests:** 7 new converter tests with round-trip coverage. Existing
|
||||||
|
engine, handler, and workflow tests updated and passing.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## v0.9.1 — Server-Side Sub-Path Routing
|
||||||
|
|
||||||
|
Hardens multi-surface routing so full-page refreshes on sub-paths work
|
||||||
|
correctly server-side, with early auth optimization and back-button
|
||||||
|
resilience.
|
||||||
|
|
||||||
|
**Route consolidation**
|
||||||
|
|
||||||
|
- Root (`/s/:slug`) and catch-all (`/s/:slug/*path`) handlers now share
|
||||||
|
a single `dispatch` function. The root handler injects `path="/"` and
|
||||||
|
delegates, eliminating the separate code path.
|
||||||
|
|
||||||
|
**Early auth short-circuit**
|
||||||
|
|
||||||
|
- New `aggregateAccess()` function scans all surfaces to determine
|
||||||
|
whether a package is all-public, all-authenticated, or mixed.
|
||||||
|
- All-authenticated packages redirect to login before surface matching,
|
||||||
|
skipping `matchSurface()` + `evaluateAccess()` overhead.
|
||||||
|
- Mixed-access packages fall through to per-surface checks as before.
|
||||||
|
|
||||||
|
**SDK back-button fix**
|
||||||
|
|
||||||
|
- On boot, `history.replaceState()` seeds the initial history entry
|
||||||
|
with `__SURFACE_PATH__` and `__SURFACE_PARAMS__`. This fixes
|
||||||
|
back-button navigation from `sw.navigate()` to the initial
|
||||||
|
server-rendered view.
|
||||||
|
- SDK version bumped to `0.9.1`.
|
||||||
|
|
||||||
|
**Tests: 13 new**
|
||||||
|
|
||||||
|
- 5 `aggregateAccess` unit tests (all-public, all-auth, mixed,
|
||||||
|
default-access, single-public).
|
||||||
|
- 8 handler integration tests covering root refresh, sub-path static
|
||||||
|
and param refresh, unauth redirect, public anonymous, mixed-access,
|
||||||
|
non-matching sub-path, and early auth short-circuit.
|
||||||
|
|
||||||
|
**Modified files:**
|
||||||
|
|
||||||
|
- `server/pages/pages.go` — `aggregateAccess()`, route consolidation,
|
||||||
|
early auth short-circuit in `RenderExtensionSurface`
|
||||||
|
- `server/pages/pages_surface_match_test.go` — 5 new unit tests
|
||||||
|
- `server/pages/pages_handler_test.go` — 8 new integration tests
|
||||||
|
- `src/js/sw/sdk/index.js` — `history.replaceState()` seed, version bump
|
||||||
|
|
||||||
|
## v0.9.0 — Multi-Surface Packages
|
||||||
|
|
||||||
|
Packages can now declare multiple surfaces — each with its own path, access
|
||||||
|
level, title, and layout. A single package can serve a public submission
|
||||||
|
form, an authenticated dashboard, and an admin settings page. The kernel
|
||||||
|
resolves incoming requests to the correct surface and enforces access
|
||||||
|
server-side.
|
||||||
|
|
||||||
|
**Manifest: `surfaces` array**
|
||||||
|
|
||||||
|
- Packages declare a `surfaces` array with entries like
|
||||||
|
`{ "path": "/submit", "access": "public", "title": "Report a Bug" }`.
|
||||||
|
- Each surface has independent `path`, `access`, `title`, `layout`, and
|
||||||
|
`nav` fields. Package-level `auth` and `layout` serve as defaults.
|
||||||
|
- Supported access levels: `public`, `authenticated`, `admin`, `group:{name}`.
|
||||||
|
- Packages without `surfaces` get auto-synthesis from legacy `auth`/`layout`
|
||||||
|
fields — no existing packages break.
|
||||||
|
|
||||||
|
**Kernel: unified route tree**
|
||||||
|
|
||||||
|
- Surface handler and ext API handler share a single `/s/:slug` route tree
|
||||||
|
via `RegisterExtensionRoutes`. The dispatcher checks the path prefix:
|
||||||
|
`/api/*` → ext API handler with JWT auth; everything else → surface
|
||||||
|
handler with per-surface access checks.
|
||||||
|
- `matchSurface()` resolves request paths against surface patterns with
|
||||||
|
Gin-style `:param` support. Static segments preferred over params.
|
||||||
|
- `evaluateAccess()` checks access requirements per-surface, with login
|
||||||
|
redirect for unauthenticated users and 403 for insufficient permissions.
|
||||||
|
|
||||||
|
**Frontend: `__SURFACE_PATH__` + `sw.navigate()`**
|
||||||
|
|
||||||
|
- `window.__SURFACE_PATH__` and `window.__SURFACE_PARAMS__` injected into
|
||||||
|
every extension surface page. Packages use these to decide which view to
|
||||||
|
render.
|
||||||
|
- `sw.navigate(path, params)` for SPA-style intra-package routing via
|
||||||
|
`pushState`. Emits `surface.navigate` events. Handles back/forward via
|
||||||
|
`popstate`.
|
||||||
|
- SDK version bumped to `0.9.0`.
|
||||||
|
|
||||||
|
**Navigation**
|
||||||
|
|
||||||
|
- `extensionNavItems` reads the `surfaces` array to find the nav entry
|
||||||
|
(first `nav: true`, or root `/`).
|
||||||
|
- Fix: packages with `status: pending_review` no longer appear in the
|
||||||
|
sidebar navigation.
|
||||||
|
|
||||||
|
**Validation**
|
||||||
|
|
||||||
|
- `ValidateManifest` validates `surfaces` entries: path required, must
|
||||||
|
start with `/`, no duplicates, access level must be recognized.
|
||||||
|
- Empty `surfaces` array rejected. Non-array `surfaces` rejected.
|
||||||
|
|
||||||
|
**Tests: 22 new**
|
||||||
|
|
||||||
|
- 11 manifest validation tests (surfaces valid/invalid, auto-synthesis,
|
||||||
|
group access, duplicate paths).
|
||||||
|
- 11 route matching tests (static paths, param extraction, specificity
|
||||||
|
ordering, nav resolution).
|
||||||
|
|
||||||
|
**Modified files:**
|
||||||
|
|
||||||
|
- `server/handlers/package_validate.go` — surfaces validation + auto-synthesis
|
||||||
|
- `server/handlers/package_validate_test.go` — 11 new tests
|
||||||
|
- `server/main.go` — unified extension route registration
|
||||||
|
- `server/pages/pages.go` — matchSurface, evaluateAccess, findNavSurface,
|
||||||
|
RegisterExtensionRoutes, nav status filter
|
||||||
|
- `server/pages/pages_surface_match_test.go` — 11 new tests
|
||||||
|
- `server/pages/templates/base.html` — surface path/params injection
|
||||||
|
- `src/js/sw/sdk/index.js` — sw.navigate, popstate, version bump
|
||||||
|
- `docs/PACKAGE-FORMAT.md` — surfaces field documentation
|
||||||
|
- `docs/MULTI-SURFACE-GUIDE.md` — developer guide
|
||||||
|
|
||||||
## v0.8.5 — Extension Composability
|
## v0.8.5 — Extension Composability
|
||||||
|
|
||||||
Extensions can now compose with each other through declared slots, UI
|
Extensions can now compose with each other through declared slots, UI
|
||||||
|
|||||||
295
ROADMAP.md
295
ROADMAP.md
@@ -1,6 +1,6 @@
|
|||||||
# Armature — Roadmap
|
# Armature — Roadmap
|
||||||
|
|
||||||
## Current: v0.9.x — Workflow Redesign
|
## Current: v0.9.x — Workflow Redesign + Multi-Surface Packages
|
||||||
|
|
||||||
Self-hosted extensible platform kernel. Auth, identity, packages, Starlark
|
Self-hosted extensible platform kernel. Auth, identity, packages, Starlark
|
||||||
sandbox, storage, realtime, and ops are kernel primitives. Everything else
|
sandbox, storage, realtime, and ops are kernel primitives. Everything else
|
||||||
@@ -75,134 +75,201 @@ All completed work is documented in `CHANGELOG.md`.
|
|||||||
|
|
||||||
## Planned
|
## Planned
|
||||||
|
|
||||||
### v0.9.x — Workflow Redesign
|
### v0.9.x — Multi-Surface Packages + Workflow Redesign
|
||||||
|
|
||||||
Design doc: `docs/DESIGN-workflow-redesign.md`
|
**v0.9.0 — Multi-Surface Packages** *(completed)*
|
||||||
|
|
||||||
The workflow system (~7,600 lines, 25+ files) was built incrementally and
|
Packages declare a `surfaces` array with per-path access controls,
|
||||||
has accumulated redundant concepts, buried primitives, and a read-only
|
titles, and layouts. Unified route tree dispatches between surface
|
||||||
Starlark module. This series cleans up the debt and promotes reusable
|
rendering and ext API calls. `sw.navigate()` for client-side sub-path
|
||||||
primitives before building reference extensions on top.
|
routing. Design doc: `docs/DESIGN-multi-surface.md`.
|
||||||
|
|
||||||
**v0.9.0 — Starlark Converter Consolidation + Snapshot Cleanup**
|
**v0.9.1 — Server-Side Sub-Path Routing** *(completed)*
|
||||||
|
|
||||||
Three files contain near-identical Go↔Starlark converters; three copies
|
Consolidated root and catch-all route handlers into a unified dispatcher.
|
||||||
of the snapshot parser exist. Consolidate into `sandbox/convert.go` and
|
Added `aggregateAccess()` for early auth short-circuit on all-authenticated
|
||||||
one exported snapshot function. Standardize on wrapped snapshot format.
|
packages. SDK seeds initial history state for back-button resilience.
|
||||||
|
8 handler integration tests + 5 aggregateAccess unit tests.
|
||||||
|
|
||||||
**v0.9.1 — Team User Roles**
|
**v0.9.2 — Starlark Converter Consolidation + Snapshot Cleanup** *(completed)*
|
||||||
|
|
||||||
Promote the buried role system to a kernel primitive. New
|
Consolidated duplicate Go↔Starlark converters into `sandbox/convert.go`
|
||||||
`team_user_roles` table (many-to-many). Manifest `requires_roles` field.
|
(4 exported functions) and snapshot parsers into `models/snapshot.go`.
|
||||||
Team admin UI for role assignment. Kernel middleware `RequireRole()`.
|
Standardized on wrapped snapshot format. ~350 lines of duplication removed.
|
||||||
Starlark SDK: `teams.get_member_roles()`, `teams.has_role()`.
|
Design doc: `docs/DESIGN-workflow-redesign.md`.
|
||||||
|
|
||||||
**v0.9.2 — Package Adoption + Roles**
|
**v0.9.3 — Team User Roles** *(completed)*
|
||||||
|
|
||||||
`scope: adoptable` manifest field. When a team adopts an adoptable
|
Many-to-many `team_user_roles` table. `RequireRole()` middleware.
|
||||||
package, the package's `requires_roles` auto-populate into the team's
|
Manifest `requires_roles` field (advisory). Starlark `teams` module
|
||||||
role slots. Replaces `AdoptTeamWorkflow` clone mechanism.
|
with `get_member_roles()` and `has_role()`. Team-admin UI with role
|
||||||
|
badge chips and assignment dropdown. 10 new tests.
|
||||||
|
|
||||||
**v0.9.3 — Typed Forms → SDK Primitive**
|
**v0.9.4 — Package Adoption + Roles** *(completed)*
|
||||||
|
|
||||||
Extract `TypedFormTemplate`, `FormField`, `FormFieldset`, etc. from
|
`adoptable` manifest field + `team_role_catalog` table. When a team
|
||||||
`models/workflow.go` into a `forms` package. FE SDK: `sw.forms.render()`
|
adopts an adoptable package, the package's `requires_roles` auto-populate
|
||||||
and `sw.forms.validate()`. Starlark: `forms.validate()`. Any package
|
into the team's role catalog. Adopted packages reference the original via
|
||||||
can declare forms, not just workflow stages.
|
`adopted_from` column (shared assets, no disk duplication).
|
||||||
|
`AdoptTeamWorkflow` deprecated in favor of package-level adoption.
|
||||||
|
4 new endpoints, migration 017, 11 new tests.
|
||||||
|
|
||||||
**v0.9.4 — Deprecate `stage_type`, Collapse `stage_mode`**
|
**v0.9.5 — Typed Forms → SDK Primitive** *(completed)*
|
||||||
|
|
||||||
`stage_type` (simple/dynamic/automated) is redundant with `starlark_hook`
|
Extracted `TypedFormTemplate`, `FormField`, `FormFieldset`, etc. from
|
||||||
presence. Remove from new manifests, keep parsing for backward compat.
|
`models/workflow.go` into a standalone `forms` package. REST endpoint
|
||||||
Collapse `stage_mode` from 4 to 3 values: form / delegated / automated.
|
`POST /api/v1/forms/validate`. Starlark `forms.validate()` module.
|
||||||
`review` was just `delegated` with signoff — signoff is independent of
|
FE SDK: `sw.forms.render()`, `sw.forms.validate()`, `sw.forms.validateRemote()`.
|
||||||
rendering mode.
|
Manifest `form_template` accepted at package level. 16 new tests.
|
||||||
|
|
||||||
**v0.9.5 — Full Read/Write Workflow Starlark Module**
|
**v0.9.6 — Deprecate `stage_type`, Collapse `stage_mode`** *(completed)*
|
||||||
|
|
||||||
Add `workflow.start()`, `workflow.advance()`, `workflow.cancel()`,
|
`stage_type` deprecated (no longer validated, defaults to "simple").
|
||||||
`workflow.submit_signoff()` to the Starlark module. Extract engine
|
`stage_mode` collapsed from 4→3 values: form / delegated / automated.
|
||||||
interface to break circular import. Unlocks fully automated workflow
|
"review" mapped to "form" on input; review surface removed (~110 lines).
|
||||||
orchestration from Starlark hooks and extensions.
|
Migration 018. 4 package manifests updated.
|
||||||
|
|
||||||
**v0.9.6 — Conditional Routing → SDK Primitive**
|
**v0.9.7 — Full Read/Write Workflow Starlark Module** *(completed)*
|
||||||
|
|
||||||
Expose `routing.evaluate(rules, data)` as a Starlark SDK function.
|
`WorkflowEngine` interface extracted in sandbox package to break
|
||||||
Branch rules become a reusable decision engine for any extension.
|
circular import. Four write builtins added: `workflow.start()`,
|
||||||
|
`workflow.advance()`, `workflow.cancel()`, `workflow.submit_signoff()`.
|
||||||
|
`instanceToDict` and `signoffToDict` helpers shared by read+write paths.
|
||||||
|
6 new tests.
|
||||||
|
|
||||||
**v0.9.7 — Multi-Surface Manifest + Route Resolution**
|
**v0.9.8 — Conditional Routing → SDK Primitive** *(completed)*
|
||||||
|
|
||||||
Packages can declare multiple surface routes with independent access
|
`routing.evaluate(rules, data)` Starlark builtin — a generic decision
|
||||||
controls. Kernel resolves routes per-page. Enables mixed public/auth
|
engine reusable by any extension. 10 operators (exists, not_exists, eq,
|
||||||
pages in a single package.
|
neq, gt, lt, gte, lte, in, contains), first-match-wins, returns target
|
||||||
|
string or None. Always available (pure computation, no permission).
|
||||||
|
8 new tests.
|
||||||
|
|
||||||
**v0.9.8 — Surface Access via Roles**
|
**v0.9.9 — Surface Access via Roles** *(completed)*
|
||||||
|
|
||||||
Wire team roles (v0.9.1) into surface access declarations:
|
`role:ROLENAME` surface access level. User must hold the role in any
|
||||||
`access: role:approver`. Kernel middleware checks role membership.
|
team (any-team semantics, no URL context needed). `evaluateAccess`
|
||||||
Completes the workflow→package access story.
|
promoted to Engine method for store access. `HasRoleInAnyTeam` store
|
||||||
|
method queries both primary and additional roles. Admin bypass, fail-
|
||||||
|
closed on nil store. 10 new tests.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### v0.10.x — Reference Extensions
|
### v0.10.x — Panels + Composable Layout
|
||||||
|
|
||||||
The kernel is complete. This series proves the platform by shipping
|
Panels are a new kernel rendering tier between surfaces (full-page) and
|
||||||
first-party extensions that exercise every primitive. These are
|
block renderers (inline). They solve composable companion views — e.g.,
|
||||||
**extensions, not kernel code** — they ship as `.pkg` files and can be
|
a notes reference panel inside chat. Design doc: `docs/DESIGN-panels.md`.
|
||||||
uninstalled.
|
|
||||||
|
|
||||||
**v0.10.0 — `vector-store` Library**
|
| Version | Title |
|
||||||
|
|---------|-------|
|
||||||
Document ingestion, chunk storage, embedding persistence, semantic
|
| v0.10.0 | Panel Manifest + Lifecycle |
|
||||||
similarity search. Consumes: `db.write`, `files.read`.
|
| v0.10.1 | FloatingPanel Primitive |
|
||||||
|
| v0.10.2 | Docked Panels + Mode Transitions |
|
||||||
**v0.10.1 — `llm-bridge` Library**
|
| v0.10.3 | Panel Communication Patterns |
|
||||||
|
| v0.10.4 | Reference Panel: Notes (basic) |
|
||||||
Model abstraction, tool-use routing, provider BYOK via connections.
|
|
||||||
Exposes `complete()`, `embed()`, `classify()`, `register_tool()`.
|
|
||||||
Consumes: `connections.read`, `api.http`, `db.write`.
|
|
||||||
|
|
||||||
**v0.10.2 — `file-share` Extension**
|
|
||||||
|
|
||||||
File upload, storage via `files` module, download links, team/group
|
|
||||||
ACLs via resource grants.
|
|
||||||
|
|
||||||
**v0.10.3 — `code-workspace` Extension**
|
|
||||||
|
|
||||||
Managed code repos via `workspace` module. Git operations, file browser
|
|
||||||
surface, structural indexing. Consumes: `workspace.manage`, `files.write`.
|
|
||||||
|
|
||||||
**v0.10.4 — `image-gen` + `image-edit` Extensions**
|
|
||||||
|
|
||||||
Image generation/editing via external APIs. Composability demo:
|
|
||||||
contributes to `chat:image-actions` slot.
|
|
||||||
|
|
||||||
**v0.10.5 — Chat System**
|
|
||||||
|
|
||||||
Generic 1-to-N messaging with slots. `chat-core` library + `chat`
|
|
||||||
surface. Declares `chat:message-actions`, `chat:image-actions`,
|
|
||||||
`chat:composer-tools` slots. Consumes: `db.write`, `realtime.publish`,
|
|
||||||
`files.write`.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### v0.11.x — Sidecar Tier + Polish
|
### v0.11.x — Notes Reference Extension
|
||||||
|
|
||||||
**v0.11.0 — Sidecar Tier**
|
Notes becomes the first reference extension — a production-quality
|
||||||
|
knowledge base that exercises every kernel primitive. The UI/UX redesign
|
||||||
|
is front-loaded as v0.11.0 so every subsequent feature version builds on
|
||||||
|
a clean visual foundation. Design doc: `docs/DESIGN-notes-v011x.md`.
|
||||||
|
|
||||||
Out-of-process extensions for workloads that can't run in Starlark (ML
|
| Version | Title |
|
||||||
inference, media transcoding, language servers, local git). Sidecars are
|
|---------|-------|
|
||||||
independent processes that connect inward to the kernel, following the
|
| v0.11.0 | UI/UX Foundation — visual redesign |
|
||||||
cluster registry pattern. Authentication via mTLS or registration tokens.
|
| v0.11.1 | Deep Folders + Navigation |
|
||||||
|
| v0.11.2 | Wikilinks + Backlinks |
|
||||||
|
| v0.11.3 | Live Preview + Rich Editing |
|
||||||
|
| v0.11.4 | Note Sharing + Permissions |
|
||||||
|
| v0.11.5 | Graph + Outline Hardening |
|
||||||
|
| v0.11.6 | Quick Switcher + Commands |
|
||||||
|
| v0.11.7 | Daily Notes + Templates |
|
||||||
|
| v0.11.8 | Transclusion + Embeds |
|
||||||
|
| v0.11.9 | Composability: Slots + Actions |
|
||||||
|
| v0.11.10 | Panel Enhancement + Quality Gate |
|
||||||
|
|
||||||
**v0.11.1 — Native Dialog Audit**
|
---
|
||||||
|
|
||||||
Replace `prompt()`/`confirm()`/`alert()` with `sw.dialog` SDK primitives.
|
### v0.12.x — Chat Reference Extension
|
||||||
|
|
||||||
**v0.11.2 — Stability + Migration Tooling**
|
Chat becomes the second reference extension — human-to-human messaging
|
||||||
|
built entirely on Armature's extension architecture. Where notes proved
|
||||||
|
surfaces, panels, and storage, chat proves **realtime**, **cross-package
|
||||||
|
composition**, and **extensible data models**. Chat is human-to-human
|
||||||
|
first; AI participants arrive via `llm-bridge` (v0.13.x) extending chat
|
||||||
|
through folder attributes and slot contributions.
|
||||||
|
Design doc: `docs/DESIGN-chat-v012x.md`.
|
||||||
|
|
||||||
Versioned migrations, `armature migrate` CLI, backup/restore validation,
|
| Version | Title |
|
||||||
pre-1.0 schema freeze.
|
|---------|-------|
|
||||||
|
| v0.12.0 | UI/UX Foundation |
|
||||||
|
| v0.12.1 | Conversation Folders + Attributes |
|
||||||
|
| v0.12.2 | Reactions + Threads + Pins |
|
||||||
|
| v0.12.3 | Rich Compose + Attachments |
|
||||||
|
| v0.12.4 | @Mentions + Notifications |
|
||||||
|
| v0.12.5 | Link Previews + Message Formatting |
|
||||||
|
| v0.12.6 | Conversation Themes + Personality |
|
||||||
|
| v0.12.7 | Composability: Slots + Actions |
|
||||||
|
| v0.12.8 | Panels + Quality Gate |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### v0.13.x — Reference Libraries + Extensions
|
||||||
|
|
||||||
|
Custom public root surface unblocks everything — anonymous visitors,
|
||||||
|
landing pages, and the package registry. `llm-bridge` is the key
|
||||||
|
library delivery — extends both notes and chat through composability
|
||||||
|
primitives, and introduces the **tool meta-tool** pattern:
|
||||||
|
`sw.actions.list()` becomes the LLM tool registry, so installing an
|
||||||
|
extension = granting AI a new capability. Zero configuration.
|
||||||
|
`armature.run` deployment is the dogfood gate at the end of the series.
|
||||||
|
|
||||||
|
| Version | Title |
|
||||||
|
|---------|-------|
|
||||||
|
| v0.13.0 | Custom Public Root Surface |
|
||||||
|
| v0.13.1 | `vector-store` Library |
|
||||||
|
| v0.13.2 | `llm-bridge` Core Library |
|
||||||
|
| v0.13.3 | `llm-bridge` → Chat: Multi-Persona Context + Tool Meta-Tool |
|
||||||
|
| v0.13.4 | `llm-bridge` → Notes: AI Toolbar Actions |
|
||||||
|
| v0.13.5 | `file-share` Extension |
|
||||||
|
| v0.13.6 | Package Registry Extension |
|
||||||
|
| v0.13.7 | `code-workspace` Extension |
|
||||||
|
| v0.13.8 | `image-gen` + `image-edit` Extensions |
|
||||||
|
| v0.13.9 | Tool Meta-Tool Hardening + Scoping |
|
||||||
|
| v0.13.10 | `armature.run` Deployment |
|
||||||
|
| v0.13.11 | Integration Quality Gate |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### v0.14.x — Sidecar Tier
|
||||||
|
|
||||||
|
Connect-inward model: sidecars connect TO the kernel (no K8s RBAC, no
|
||||||
|
service mesh, no DNS discovery). Instance sidecars (shared infrastructure)
|
||||||
|
and user sidecars (personal local tools). Design doc:
|
||||||
|
`docs/DESIGN-sidecar-v014x.md`.
|
||||||
|
|
||||||
|
| Version | Title |
|
||||||
|
|---------|-------|
|
||||||
|
| v0.14.0 | Sidecar Registry + Auth |
|
||||||
|
| v0.14.1 | Capability Registration + Execution |
|
||||||
|
| v0.14.2 | Kernel API Access + Event Bus |
|
||||||
|
| v0.14.3 | Manifest Integration + Admin Polish |
|
||||||
|
| v0.14.4 | Reference Sidecar (`armature-embed`) + Instance Gate |
|
||||||
|
| v0.14.5 | User Sidecars + Reference (`user-bridge`) + User Gate |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### v0.15.x — Polish + Stability
|
||||||
|
|
||||||
|
| Version | Title |
|
||||||
|
|---------|-------|
|
||||||
|
| v0.15.0 | Native Dialog Audit |
|
||||||
|
| v0.15.1 | Versioned Migrations + `armature migrate` CLI |
|
||||||
|
| v0.15.2 | Pre-1.0 Schema Freeze + Upgrade Path Validation |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -215,11 +282,24 @@ Gate criteria:
|
|||||||
- All kernel Starlark modules documented with examples
|
- All kernel Starlark modules documented with examples
|
||||||
- All `api_routes` covered by OpenAPI spec
|
- All `api_routes` covered by OpenAPI spec
|
||||||
- Upgrade path tested from v0.8.0 → v1.0.0
|
- Upgrade path tested from v0.8.0 → v1.0.0
|
||||||
- At least 3 reference extensions shipped (vector-store, llm-bridge, chat)
|
- Notes reference extension shipped with all v0.11.x features
|
||||||
- At least 1 cross-extension composability demo (image-gen → chat:image-actions)
|
- Notes UI/UX reviewed against v0.11.0 design principles — no placeholder UI
|
||||||
|
- Chat reference extension shipped with all v0.12.x features
|
||||||
|
- `llm-bridge` extends both notes and chat through composability
|
||||||
|
- Tool meta-tool demonstrated: AI uses 3+ extension actions in a
|
||||||
|
single conversation turn
|
||||||
|
- Multi-persona context archetypes demonstrated
|
||||||
|
- Admin safety rails validated
|
||||||
|
- At least 2 panels consumed cross-package
|
||||||
|
- At least 2 slot contributions per host surface demonstrated
|
||||||
|
- Note and conversation sharing functional end-to-end
|
||||||
- Headless E2E green on PG + SQLite
|
- Headless E2E green on PG + SQLite
|
||||||
- `armature-ca.sh` + mTLS deployment guide
|
- `armature-ca.sh` + mTLS deployment guide
|
||||||
- Single-binary + Docker + K8s deployment paths documented
|
- Single-binary + Docker + K8s deployment paths documented
|
||||||
|
- Instance sidecar (`armature-embed`) deployed and functional
|
||||||
|
- User sidecar (`user-bridge`) functional on macOS/Linux/Windows
|
||||||
|
- Token + mTLS sidecar auth both tested
|
||||||
|
- `armature migrate` CLI functional
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -270,5 +350,24 @@ These are candidates, not commitments. Each requires a design doc.
|
|||||||
| `workspace` for real filesystem | Flat blob store can't serve git/compilers/ffmpeg. Managed disk paths. |
|
| `workspace` for real filesystem | Flat blob store can't serve git/compilers/ffmpeg. Managed disk paths. |
|
||||||
| Capability negotiation at install | Fail loud with actionable message, not silently at runtime. |
|
| Capability negotiation at install | Fail loud with actionable message, not silently at runtime. |
|
||||||
| Vector column with three-tier fallback | Works everywhere, works fast with pgvector. |
|
| Vector column with three-tier fallback | Works everywhere, works fast with pgvector. |
|
||||||
| Sidecar deferred to v0.11.x | HTTP module covers external APIs. Sidecars connect inward (no k8s RBAC needed). |
|
| Sidecar deferred to v0.14.x | HTTP module covers external APIs. Sidecars connect inward (no k8s RBAC needed). |
|
||||||
| Workflow redesign before reference extensions | Clean up debt and promote primitives before building on top. |
|
| Workflow redesign before reference extensions | Clean up debt and promote primitives before building on top. |
|
||||||
|
| Panels as kernel primitive (v0.10.x) | Z-index coordination, drag/resize, layout negotiation are kernel concerns. |
|
||||||
|
| UI/UX redesign as first version in each reference series | Every feature builds on the visual foundation. Front-loading avoids double work. |
|
||||||
|
| Notes as dedicated v0.11.x series (11 versions) | Complex enough to need changeset discipline. Each version independently shippable. |
|
||||||
|
| Notes before chat | Notes is simpler (no realtime) and proves storage/rendering/composability. Chat adds realtime + llm-bridge story. |
|
||||||
|
| Chat human-to-human first (v0.12.x) | AI is an extension concern. Keeps chat testable and usable standalone. |
|
||||||
|
| Chat as dedicated v0.12.x series (9 versions) | Second reference extension. Proves realtime, extensible data models, cross-package composition. |
|
||||||
|
| Folder attributes as extension bridge | Chat stores attributes it doesn't understand. llm-bridge contributes definitions. Zero coupling. |
|
||||||
|
| Action registry as tool registry (meta-tool) | Dynamic, zero-config AI tool-use. Installed extensions = AI capabilities. Uniquely Armature. |
|
||||||
|
| Layered prompt architecture (6 layers) | Admin safety not overridable. Clear separation: platform → space → character → context → tools → user. |
|
||||||
|
| Personas in llm-bridge, not chat | Chat sees AI as just another participant_type. Persona identity is llm-bridge's concern. |
|
||||||
|
| llm-bridge after notes + chat (v0.13.x) | Proves the composability hooks work without being designed for a specific consumer. |
|
||||||
|
| Custom public root first in v0.13.x | Unblocks registry, landing page, and armature.run. Small kernel change, high leverage. |
|
||||||
|
| Package registry as reference extension | Proves public surfaces work. Needs files module for .pkg storage. Self-referential: Armature's registry runs on Armature. |
|
||||||
|
| armature.run as dogfood gate | If armature.run can't run on Armature, the platform isn't ready. Issues feed into quality gate fixes. |
|
||||||
|
| Sidecar connect-inward | No K8s RBAC, no service mesh, no DNS discovery. Works on any deployment target. |
|
||||||
|
| Sidecar HTTP/JSON, not gRPC | KISS. 4-endpoint contract. Every language has HTTP. |
|
||||||
|
| User sidecars in v0.14.5 | Prove instance contract first, extend to users with same mechanism. |
|
||||||
|
| Three-layer user sidecar RBAC | Admin controls who + what, user controls visibility. Defense in depth. |
|
||||||
|
| Separate polish (v0.15.x) | Security-critical sidecar infra and quality-of-life polish shouldn't share focus. |
|
||||||
|
|||||||
@@ -127,7 +127,7 @@ STAGE2_RESP=$(curl -sf -X POST "${SERVER_URL}/api/v1/workflows/${WF_ID}/stages"
|
|||||||
-H "$AUTH" -H "Content-Type: application/json" \
|
-H "$AUTH" -H "Content-Type: application/json" \
|
||||||
-d "{
|
-d "{
|
||||||
\"name\": \"Team Review\",
|
\"name\": \"Team Review\",
|
||||||
\"stage_mode\": \"review\",
|
\"stage_mode\": \"form\",
|
||||||
\"audience\": \"team\",
|
\"audience\": \"team\",
|
||||||
\"ordinal\": 1,
|
\"ordinal\": 1,
|
||||||
\"assignment_team_id\": \"${TEAM_ID}\"
|
\"assignment_team_id\": \"${TEAM_ID}\"
|
||||||
|
|||||||
@@ -125,7 +125,7 @@ REVIEW_RESP=$(curl -sf -X POST "${SERVER_URL}/api/v1/workflows/${WF_ID}/stages"
|
|||||||
-H "$AUTH" -H "Content-Type: application/json" \
|
-H "$AUTH" -H "Content-Type: application/json" \
|
||||||
-d '{
|
-d '{
|
||||||
"name": "Manager Review",
|
"name": "Manager Review",
|
||||||
"stage_mode": "review",
|
"stage_mode": "form",
|
||||||
"ordinal": 1
|
"ordinal": 1
|
||||||
}' 2>/dev/null || echo '{"error":"failed"}')
|
}' 2>/dev/null || echo '{"error":"failed"}')
|
||||||
|
|
||||||
|
|||||||
@@ -184,3 +184,30 @@ Kernel event prefixes: `user.*`, `team.*`, `workflow.*`, `notification.*`, `pres
|
|||||||
| POST | `/presence/heartbeat` | Update presence status |
|
| POST | `/presence/heartbeat` | Update presence status |
|
||||||
| GET | `/presence` | Query online users |
|
| GET | `/presence` | Query online users |
|
||||||
| GET | `/users/search` | Search users |
|
| GET | `/users/search` | Search users |
|
||||||
|
|
||||||
|
### Forms (v0.9.5)
|
||||||
|
|
||||||
|
| Method | Path | Description |
|
||||||
|
|--------|------|-------------|
|
||||||
|
| POST | `/forms/validate` | Validate form data against a typed template |
|
||||||
|
|
||||||
|
**Request body:**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"template": {
|
||||||
|
"fields": [
|
||||||
|
{"key": "name", "type": "text", "label": "Name", "required": true}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"data": {"name": "Alice"}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{"valid": true, "errors": []}
|
||||||
|
```
|
||||||
|
|
||||||
|
On validation failure, `errors` contains `[{"key": "name", "message": "Name is required"}]`.
|
||||||
|
|||||||
@@ -1,209 +0,0 @@
|
|||||||
# Surface Audit — Settings, Admin, Team Admin, Docs
|
|
||||||
|
|
||||||
## Audit Methodology
|
|
||||||
|
|
||||||
For each surface: read the index.js (tab/section structure), read every
|
|
||||||
section module, check the HTML template, trace the topbar/navigation
|
|
||||||
pattern, identify bugs, missing features, dead ends, and legacy baggage.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 1. Settings Surface
|
|
||||||
|
|
||||||
**Files:** `src/js/sw/surfaces/settings/` (7 files, ~868 lines)
|
|
||||||
**Template:** `surfaces/settings.html` — mounts into `#settings-mount`
|
|
||||||
**Topbar:** Custom — back arrow + user icon + "Settings" title. No bell. No user menu.
|
|
||||||
|
|
||||||
### Sections
|
|
||||||
|
|
||||||
| Section | Lines | Status | Notes |
|
|
||||||
|---------|-------|--------|-------|
|
|
||||||
| General | 62 | ✅ OK | Default surface picker. Clean. |
|
|
||||||
| Appearance | 78 | ✅ OK | Theme toggle (light/dark/system) + UI scale slider. |
|
|
||||||
| Profile | 180 | ✅ OK | Display name, email, avatar upload, password change. |
|
|
||||||
| Teams | 47 | ⚠️ Thin | Read-only list of your teams. No actions. No link to team admin. |
|
|
||||||
| Connections | 222 | ✅ OK | Personal BYOK connection CRUD. Functional. |
|
|
||||||
| Notifications | 96 | ✅ OK | Toggle notification types on/off. |
|
|
||||||
|
|
||||||
### Issues Found
|
|
||||||
|
|
||||||
| # | Severity | Issue |
|
|
||||||
|---|----------|-------|
|
|
||||||
| S1 | **P1** | **No notification bell.** Custom topbar renders back arrow + icon + "Settings" — no bell, no user menu dropdown. User can't see notifications or navigate to other surfaces without using the back button. |
|
|
||||||
| S2 | **P2** | **Teams section is a dead-end.** Lists your teams with no actions — can't leave team, can't navigate to team admin, can't see team details. Just names. Should either link to team admin or show useful info. |
|
|
||||||
| S3 | **P2** | **Back button uses sessionStorage return URL.** `sb_settings_return` stash means: open settings in a new tab → back goes to `/` (correct). But open settings from a deep link → back goes to referrer, which might be unexpected. Shell topbar with consistent home link would fix this. |
|
|
||||||
| S4 | **P3** | **Extension config sections.** `__CONFIG_SECTIONS__` injection works but has no documentation. Extension authors don't know they can add settings sections. Needs a docs entry. |
|
|
||||||
|
|
||||||
### Shell Topbar Migration
|
|
||||||
|
|
||||||
Settings renders its own `settings-topbar`. With shell topbar injection:
|
|
||||||
- **Option A (simple):** `sw.shell.topbar.hide()` and keep custom topbar. Works immediately.
|
|
||||||
- **Option B (ideal):** Remove custom topbar. Shell topbar provides back + title + bell + user menu. Settings nav stays in the sidebar.
|
|
||||||
- **Recommendation:** Option B. Settings topbar adds nothing the shell topbar doesn't. The back arrow just navigates to `/`.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2. Admin Surface
|
|
||||||
|
|
||||||
**Files:** `src/js/sw/surfaces/admin/` (13 files, ~2,522 lines)
|
|
||||||
**Template:** `surfaces/admin.html` — mounts into `#admin-mount`
|
|
||||||
**Topbar:** Custom — favicon + "Administration" + category tabs (People/Workflows/System/Monitoring) + UserMenu component. No notification bell.
|
|
||||||
|
|
||||||
### Sections
|
|
||||||
|
|
||||||
| Section | Category | Lines | Status | Notes |
|
|
||||||
|---------|----------|-------|--------|-------|
|
|
||||||
| Users | People | 152 | ✅ OK | User list, create, edit status/role. Functional. |
|
|
||||||
| Teams | People | 178 | ✅ OK | Team list, create, member management. |
|
|
||||||
| Groups | People | 207 | ✅ OK | Full CRUD — create, delete, permission toggles, member add/remove. Functional but **undocumented** (see issues). |
|
|
||||||
| Workflows | Workflows | 163 | ⚠️ | CRUD + stage editor. `sw.api.workflows.list()` — needs same error-surfacing treatment as workflow-demo. |
|
|
||||||
| Settings | System | 242 | ✅ OK | Comprehensive: default surface, registration, banner, message bar, footer, session TTLs, vault, package registry, email test. Actually solid. |
|
|
||||||
| Storage | System | 76 | ✅ OK | Status cards, orphan cleanup. Clean. |
|
|
||||||
| Packages | System | 391 | ⚠️ | Core feature. Large. Package list, install, uninstall, registry browse. **User menu doesn't update after install/uninstall** (main bug Jeff reported). |
|
|
||||||
| Connections | System | 210 | ✅ OK | Global connection CRUD. |
|
|
||||||
| Broadcast | System | 44 | ✅ OK | Send broadcast. Minimal. |
|
|
||||||
| Backup | System | 162 | ✅ OK | Create/restore/download/delete. Works. |
|
|
||||||
| Health | Monitoring | 209 | ✅ OK | Runtime, DB pool, cluster, extension metrics. |
|
|
||||||
| Audit | Monitoring | 88 | ✅ OK | Audit log viewer with pagination. |
|
|
||||||
|
|
||||||
### Issues Found
|
|
||||||
|
|
||||||
| # | Severity | Issue |
|
|
||||||
|---|----------|-------|
|
|
||||||
| A1 | **P1** | **User menu not reactive to package changes.** `UserMenu` fetches surface list once on mount (`useEffect([authenticated])`). Installing/uninstalling a package doesn't trigger re-fetch. User must refresh the page to see new surfaces in the menu. Same for role changes (adding as team-admin). |
|
|
||||||
| A2 | **P1** | **No notification bell.** Admin topbar has category tabs + UserMenu but no NotificationBell component. |
|
|
||||||
| A3 | **P2** | **Groups: no documentation or inline help.** Admin Groups has full CRUD but zero explanation of what groups are, what permissions mean, or how the RBAC model works. "No groups" → user creates one → sees a list of permission slugs like `surface.admin.access` with no description. Every permission should have a human-readable description. |
|
|
||||||
| A4 | **P2** | **Workflows: silent error potential.** `sw.api.workflows.list()` — if this fails, `catch (e) { sw.toast(e.message, 'error'); }` fires a toast but leaves the list empty. Better than workflow-demo's silent swallow, but the toast disappears and the user is left with an empty list + no context. Should show inline error state. |
|
|
||||||
| A5 | **P2** | **Packages: no post-install feedback.** After installing a package, the package list refreshes (good) but the user menu doesn't update (bad — A1). User installs Notes, doesn't see it in the menu, thinks it's broken. |
|
|
||||||
| A6 | **P3** | **Admin topbar favicon is hardcoded.** Line 142: `<img src="${BASE}/favicon.svg">`. Should respect light/dark theme favicon swap. |
|
|
||||||
| A7 | **P3** | **Category icon rendering is fragile.** Custom compact SVG format (`C12 12 3\|M19.4 15...`) in `CatIcon`. Works but is unmaintainable — any icon change requires understanding the custom format. Should use standard SVG paths or lucide/feather icons. |
|
|
||||||
|
|
||||||
### Shell Topbar Migration
|
|
||||||
|
|
||||||
Admin has the most complex custom topbar — category tabs are genuinely useful navigation. Options:
|
|
||||||
- **Option A (recommended):** `sw.shell.topbar.hide()`. Admin keeps its custom topbar but adds NotificationBell component to its existing right-side area next to UserMenu.
|
|
||||||
- **Option B:** Shell topbar with `sw.shell.topbar.setSlot()` for category tabs. Works but requires rethinking the layout since shell topbar has fixed structure (home | title | slot | bell | user).
|
|
||||||
- **Recommendation:** Option A for v0.7.0. Admin's custom topbar is bespoke enough to warrant keeping. Just wire in the bell.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 3. Team Admin Surface
|
|
||||||
|
|
||||||
**Files:** `src/js/sw/surfaces/team-admin/` (7 files, ~1,119 lines)
|
|
||||||
**Template:** `surfaces/team-admin.html` — mounts into `#team-admin-mount`
|
|
||||||
**Topbar:** Custom — back arrow + "Team Admin: {team name}" title. No bell. No user menu.
|
|
||||||
|
|
||||||
### Sections
|
|
||||||
|
|
||||||
| Section | Lines | Status | Notes |
|
|
||||||
|---------|-------|--------|-------|
|
|
||||||
| Members | ~90 | ✅ OK | Member list, add, remove. Functional. |
|
|
||||||
| Groups | 37 | ❌ Dead-end | Read-only "No groups" display. No create, no docs, no link to admin. |
|
|
||||||
| Connections | ~120 | ✅ OK | Team-scoped connections. Same pattern as user/admin connections. |
|
|
||||||
| Workflows | 723 | ⚠️ Massive | Three tabs: Workflows (CRUD + inline stage editor), Assignments (claim/release/complete), Monitor (active instances + signoff). This is 65% of the surface's code. |
|
|
||||||
| Settings | 72 | ✅ OK | Team name + description. Clean. |
|
|
||||||
| Activity | ~80 | ✅ OK | Audit log. Works. |
|
|
||||||
|
|
||||||
### Issues Found
|
|
||||||
|
|
||||||
| # | Severity | Issue |
|
|
||||||
|---|----------|-------|
|
|
||||||
| T1 | **P1** | **Groups is a dead-end.** 37 lines. Read-only list of team groups. No "Create Group" button. No explanation of what groups are. No link to Admin > Groups where creation actually happens. A team admin user who isn't a platform admin literally cannot create team groups. The Admin groups page supports `scope: team` but that creates a global group with team scope — it's unclear if team-admin should even see groups at all. |
|
|
||||||
| T2 | **P1** | **Workflows "Adopt Global" — same silent-error class.** `sw.api.teams.availableWorkflows(teamId)` — if this fails, the catch fires a toast but the adopt panel shows "No global workflows available" — indistinguishable from "there genuinely aren't any" vs "the API errored." |
|
|
||||||
| T3 | **P1** | **Workflows is disproportionately complex.** 723 lines — inline stage editor with mode/type selectors, SLA fields, stage reordering, team role assignment per stage. This is a full workflow designer embedded in a tab. It works but it's a maintenance burden and the UX is dense. Question: should this complexity live here or be a separate workflow-designer surface? |
|
|
||||||
| T4 | **P1** | **No notification bell.** Same as Settings — custom topbar with no bell. |
|
|
||||||
| T5 | **P2** | **No user menu.** Unlike Admin (which renders UserMenu), Team Admin has no user menu in its topbar. User can't navigate to other surfaces except via the back button. |
|
|
||||||
| T6 | **P2** | **Signoff panel shows raw user_id.** Line 714: `<span>${s.user_id}</span>` — shows UUID instead of display name. Should use `sw.users.displayName(s.user_id)`. |
|
|
||||||
| T7 | **P3** | **Back button uses sessionStorage.** Same pattern as Settings (`sb_team_admin_return`). Shell topbar would fix. |
|
|
||||||
|
|
||||||
### Shell Topbar Migration
|
|
||||||
|
|
||||||
Team Admin has a simple topbar (back + title). Direct replacement:
|
|
||||||
- Shell topbar provides: home link + "Team Admin: {name}" title + bell + user menu.
|
|
||||||
- Team name from `sw.api.teams.get(teamId)` → `sw.shell.topbar.setTitle('Team Admin: ' + team.name)`.
|
|
||||||
- Delete the custom topbar entirely.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 4. Docs Surface
|
|
||||||
|
|
||||||
**Files:** `src/js/sw/surfaces/docs/` (1 file, 313 lines)
|
|
||||||
**Template:** `surfaces/docs.html` — mounts into `#docs-mount`
|
|
||||||
**Topbar:** Imports and renders `shell/topbar.js` (the SDK Topbar component). **Only surface that uses the shell Topbar.**
|
|
||||||
|
|
||||||
### Features
|
|
||||||
|
|
||||||
| Feature | Status | Notes |
|
|
||||||
|---------|--------|-------|
|
|
||||||
| Document list sidebar | ✅ OK | Fetches from `/api/v1/docs`, renders nav links. |
|
|
||||||
| Markdown rendering | ✅ OK | Uses `sw.markdown.renderSync()` + post-renderers (mermaid, katex). |
|
|
||||||
| Document outline | ✅ OK | Parses H1-H4 from markdown, renders table of contents. |
|
|
||||||
| Search | ✅ OK | Filters documents in sidebar. |
|
|
||||||
| URL updates | ✅ OK | `history.replaceState` on doc change. |
|
|
||||||
| Topbar | ✅ OK | Uses shell `Topbar` component — has title, bell, user menu. |
|
|
||||||
|
|
||||||
### Issues Found
|
|
||||||
|
|
||||||
| # | Severity | Issue |
|
|
||||||
|---|----------|-------|
|
|
||||||
| D1 | **P2** | **Stale content.** The docs themselves may be outdated — GETTING-STARTED, EXTENSION-GUIDE, API-REFERENCE, DEPLOYMENT, PACKAGE-FORMAT were written in v0.6.1. 18 versions later, some content is likely stale. Needs a content review pass. |
|
|
||||||
| D2 | **P3** | **No docs for RBAC/Groups.** Admin Groups exists with full CRUD but there's no documentation explaining the permission model, what each permission slug means, how groups interact with teams, or how the settings cascade works. This directly causes the "groups WTF" reaction. |
|
|
||||||
| D3 | **P3** | **No docs for Workflows.** The workflow engine is complex (multi-stage, team roles, signoff gates, SLA, public entry) but has no user-facing documentation. `DESIGN-WORKFLOWS.md` exists but is a design doc, not a user guide. |
|
|
||||||
| D4 | **P3** | **Shell topbar migration.** Docs already imports `shell/topbar.js` — when shell topbar injection lands, Docs will get a double topbar. Needs migration: delete the import, let shell topbar handle it. Docs currently passes no custom slot content, so it's a pure delete. |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Cross-Surface Issues
|
|
||||||
|
|
||||||
These affect multiple or all surfaces:
|
|
||||||
|
|
||||||
| # | Severity | Issue | Surfaces |
|
|
||||||
|---|----------|-------|----------|
|
|
||||||
| X1 | **P0** | **User menu not reactive.** Package install/uninstall, role changes, team membership changes — none trigger a menu refresh. User must reload the page. | All (via UserMenu component) |
|
|
||||||
| X2 | **P1** | **No notification bell on 3/4 surfaces.** Only Docs has a bell (via Topbar import). Settings, Admin, and Team Admin all lack it. | Settings, Admin, Team Admin |
|
|
||||||
| X3 | **P1** | **No user menu on 2/4 surfaces.** Settings and Team Admin have no user menu at all. Admin and Docs have one. | Settings, Team Admin |
|
|
||||||
| X4 | **P2** | **Every surface has its own topbar.** Four different topbar implementations. None use the (not-yet-existing) shell topbar injection. Shell topbar (v0.7.0) eliminates this duplication. | All |
|
|
||||||
| X5 | **P2** | **Silent error swallowing.** Multiple sections use `catch (e) { toast }` which fires a toast and leaves an empty/stale UI. Toast disappears after seconds; user is left confused. Every list endpoint needs an inline error state with retry. | Admin Workflows, Team Admin Workflows, Packages |
|
|
||||||
| X6 | **P2** | **Empty states provide no guidance.** "No groups", "No workflows", "No notifications" — no explanation of what the feature is, why it's empty, or what action to take. Every empty state should have a one-line explanation and a primary action (create, link to docs, etc.). | Admin Groups, Team Admin Groups, Workflows |
|
|
||||||
| X7 | **P3** | **Raw IDs in UI.** Team Admin signoff panel shows `user_id` UUIDs. Any surface showing IDs should resolve via `sw.users.displayName()`. | Team Admin Workflows |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Recommendations
|
|
||||||
|
|
||||||
### Immediate (fold into v0.7.0)
|
|
||||||
|
|
||||||
1. **User menu reactivity** — emit `package.changed` and `auth.changed` events over WS + local custom events. UserMenu listens and re-fetches surface list. This is the single most impactful fix.
|
|
||||||
|
|
||||||
2. **Shell topbar migration for Settings + Team Admin** — both have simple topbars that the shell topbar directly replaces. Docs deletes its Topbar import. Admin keeps its custom topbar but adds NotificationBell.
|
|
||||||
|
|
||||||
3. **Remove Team Admin Groups tab** — it's 37 lines of dead-end. Team-scoped group management should either (a) be added properly with create/edit/delete or (b) removed until it's properly designed. Showing "No groups" with no path forward is worse than not showing the tab.
|
|
||||||
|
|
||||||
4. **Error states** — replace `catch { toast }` with inline error + retry UI on every list endpoint. Systematic pass across all four surfaces.
|
|
||||||
|
|
||||||
5. **Empty state copy** — every "No X" message gets a one-line explanation + primary action button or doc link.
|
|
||||||
|
|
||||||
### Deferred (v0.7.1+ / runner coverage)
|
|
||||||
|
|
||||||
6. **Admin Groups documentation** — write a "Permissions & Groups" doc for the docs surface. Explain the RBAC model, list all permission slugs with descriptions, explain group scoping.
|
|
||||||
|
|
||||||
7. **Workflow user guide** — write a "Workflows" doc. Entry modes, stage types, team roles, signoff gates, SLA.
|
|
||||||
|
|
||||||
8. **Team Admin Workflows simplification** — the 723-line inline stage editor is the most complex piece of UI in the entire application. Consider extracting to a dedicated workflow-designer surface or at minimum breaking into separate files.
|
|
||||||
|
|
||||||
9. **Docs content refresh** — review all 5 docs for accuracy at v0.6.18+.
|
|
||||||
|
|
||||||
10. **Settings Teams section** — either add useful actions (link to team admin, show team role, leave team) or remove the tab.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Asset Inventory
|
|
||||||
|
|
||||||
| Surface | Lines (total) | Sections | Custom Topbar | Bell | UserMenu | Error Handling |
|
|
||||||
|---------|--------------|----------|---------------|------|----------|---------------|
|
|
||||||
| Settings | 868 | 6 | Yes (back+icon) | ❌ | ❌ | Toast only |
|
|
||||||
| Admin | 2,522 | 12 | Yes (tabs+menu) | ❌ | ✅ | Toast only |
|
|
||||||
| Team Admin | 1,119 | 6 | Yes (back+title) | ❌ | ❌ | Toast only |
|
|
||||||
| Docs | 313 | 1 | Shell Topbar ✅ | ✅ | ✅ | Inline error ✅ |
|
|
||||||
|
|
||||||
Docs is the gold standard. The other three need to converge toward its pattern.
|
|
||||||
1074
docs/DESIGN-chat-v012x.md
Normal file
1074
docs/DESIGN-chat-v012x.md
Normal file
File diff suppressed because it is too large
Load Diff
744
docs/DESIGN-notes-v011x.md
Normal file
744
docs/DESIGN-notes-v011x.md
Normal file
@@ -0,0 +1,744 @@
|
|||||||
|
# DESIGN: Notes Reference Extension — v0.11.x
|
||||||
|
|
||||||
|
## Status: Proposed
|
||||||
|
|
||||||
|
## Purpose
|
||||||
|
|
||||||
|
Notes becomes the first **reference extension** — a first-party package
|
||||||
|
that exercises every kernel primitive and proves the platform is capable
|
||||||
|
of delivering a production-quality application. This is not a toy demo;
|
||||||
|
it is the answer to "what can you build on Armature?"
|
||||||
|
|
||||||
|
The v0.11.x series takes the existing notes package (folders, tags,
|
||||||
|
wikilinks, graph, CM6 editor, three view modes) and builds it into a
|
||||||
|
full-featured knowledge base comparable to Obsidian, Notion, or Logseq —
|
||||||
|
but running on Armature's extension architecture.
|
||||||
|
|
||||||
|
**The UI must be clean, inviting, and genuinely enjoyable to use.** The
|
||||||
|
current notes surface is functional but visually mechanical — no
|
||||||
|
personality, no micro-interactions, no sense of craft. If the reference
|
||||||
|
extension feels like a developer prototype, the platform pitch fails
|
||||||
|
regardless of how many kernel primitives it exercises. v0.11.0 is a
|
||||||
|
full visual redesign before any feature work begins, and every subsequent
|
||||||
|
version ships with UX quality built in, not bolted on.
|
||||||
|
|
||||||
|
### What Notes Exercises
|
||||||
|
|
||||||
|
| Kernel Primitive | How Notes Uses It |
|
||||||
|
|-----------------|-------------------|
|
||||||
|
| `db.write` | Notes, folders, tags, links, shares tables |
|
||||||
|
| `sw.renderers` | Markdown rendering pipeline, custom block renderers |
|
||||||
|
| `sw.panels` | `notes.reference` panel (from v0.10.4) |
|
||||||
|
| `sw.slots` | Declares `notes:toolbar-actions`, `notes:note-footer` for extension composition |
|
||||||
|
| `sw.events` | Realtime updates, panel ↔ surface communication |
|
||||||
|
| `sw.markdown` | Unified markdown rendering with wikilink extensions |
|
||||||
|
| `sw.shell.topbar` | Topbar slots for navigation context |
|
||||||
|
| `sw.storage` | Editor state, sidebar collapse, preferences |
|
||||||
|
| `sw.actions` | Exports `notes.create`, `notes.search` for cross-package calls |
|
||||||
|
| Resource grants | Note and folder sharing via kernel permission model |
|
||||||
|
| Starlark sandbox | All backend logic in `script.star` |
|
||||||
|
| Settings cascade | Per-user editor mode, default view, daily note folder |
|
||||||
|
| Surfaces | Multi-surface: full editor + public read-only surface |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## What Already Exists (v0.9.0)
|
||||||
|
|
||||||
|
### Frontend (1,806 lines — `js/main.js`)
|
||||||
|
|
||||||
|
- **NotesApp** — main shell with sidebar + editor layout
|
||||||
|
- **FolderTree / FolderNode** — tree with expand/collapse, context menu,
|
||||||
|
drag-and-drop note→folder, rename, create sub-folder (schema has
|
||||||
|
`parent_id`, UI renders depth)
|
||||||
|
- **NoteCard** — list items with title, snippet, date, tags, drag handle
|
||||||
|
- **TagInput / TagFilter** — tag CRUD with autocomplete
|
||||||
|
- **EditorPane** — three view modes (rendered / edit / split), CodeMirror
|
||||||
|
6 integration with textarea fallback, auto-save, frontmatter parsing
|
||||||
|
- **BacklinksPanel** — lists notes that link to the current note
|
||||||
|
- **SidebarTabs** — Notes / Outline tabs
|
||||||
|
- **SidebarOutline** — heading tree parsed from markdown body
|
||||||
|
- **GraphPane** — canvas-based force-directed graph with folder coloring,
|
||||||
|
orphan hiding, focus mode, pan/zoom
|
||||||
|
- **Export** — download note as `.md` with frontmatter
|
||||||
|
|
||||||
|
### Backend (23K — `script.star`)
|
||||||
|
|
||||||
|
- Full CRUD: notes, folders, tags
|
||||||
|
- Wikilink extraction (`_extract_wikilinks`) and link resolution
|
||||||
|
(`_sync_links`) on every note save
|
||||||
|
- Links / backlinks queries
|
||||||
|
- Graph data endpoint (nodes + edges)
|
||||||
|
- Search (title + body substring)
|
||||||
|
- Stats endpoint
|
||||||
|
- Folder CRUD with `parent_id`
|
||||||
|
|
||||||
|
### Schema (4 tables)
|
||||||
|
|
||||||
|
- `notes` — title, body, folder_id, creator_id, updated_at, pinned, archived
|
||||||
|
- `tags` — note_id, tag
|
||||||
|
- `links` — source_id, target_id, link_text
|
||||||
|
- `folders` — name, parent_id, creator_id, sort_order
|
||||||
|
|
||||||
|
### Current CSS (795 lines — `css/main.css`)
|
||||||
|
|
||||||
|
Functional but visually flat. No animations, no keyframes, no
|
||||||
|
micro-interactions. Identical `transition: var(--transition)` on every
|
||||||
|
hover. No typographic hierarchy beyond font-size/weight. No visual
|
||||||
|
rhythm. Sidebar and editor feel like admin panels, not a writing tool.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Version Plan
|
||||||
|
|
||||||
|
### v0.11.0 — UI/UX Foundation
|
||||||
|
|
||||||
|
**Goal:** Complete visual redesign of the notes surface. Every pixel
|
||||||
|
intentional. Every interaction feels crafted. This is the foundation
|
||||||
|
that all subsequent feature versions build on.
|
||||||
|
|
||||||
|
This is NOT a "make it pretty" pass on the existing CSS. It is a
|
||||||
|
ground-up rethink of the notes UI with the understanding that this is
|
||||||
|
the first thing people see when evaluating what Armature can do.
|
||||||
|
|
||||||
|
**Design principles for notes:**
|
||||||
|
|
||||||
|
- **Writing-first.** The editor area dominates. Generous whitespace.
|
||||||
|
Nothing competes for attention with the content the user is writing.
|
||||||
|
- **Quiet chrome, loud content.** Sidebar, toolbar, and navigation fade
|
||||||
|
into the background. The note body is the visual anchor — clean
|
||||||
|
typography, comfortable line height, readable measure (60–80 chars).
|
||||||
|
- **Progressive disclosure.** Folder tree, tags, backlinks, graph — all
|
||||||
|
there but not all visible simultaneously. Contextual — show what's
|
||||||
|
relevant to what the user is doing right now.
|
||||||
|
- **Micro-interactions that feel alive.** Folder expand/collapse with
|
||||||
|
rotation animation. Note cards with subtle lift on hover. Smooth
|
||||||
|
sidebar resize. Mode transitions that animate, not snap. Save
|
||||||
|
indicator that pulses, not just appears.
|
||||||
|
- **Personality without kitsch.** The UI should feel like a well-designed
|
||||||
|
indie app, not a Material Design template and not a Bootstrap theme.
|
||||||
|
|
||||||
|
**Specific deliverables:**
|
||||||
|
|
||||||
|
**Typography overhaul:**
|
||||||
|
- Rendered note body: system serif stack for body text (Georgia, serif
|
||||||
|
fallback) at 16px/1.7 line height. Headings in the system sans stack.
|
||||||
|
Comfortable reading measure — `max-width: 720px` centered in the
|
||||||
|
editor area with generous padding. Code blocks with distinct
|
||||||
|
background and a monospace stack.
|
||||||
|
- Editor (CM6): matching font size and line height so switching between
|
||||||
|
rendered/edit mode doesn't jar. CM6 theme tokens aligned with note
|
||||||
|
design tokens.
|
||||||
|
- Sidebar text: smaller (13px), tighter, utility font. Clear hierarchy
|
||||||
|
between folder names, note titles, snippet text, dates.
|
||||||
|
|
||||||
|
**Sidebar redesign:**
|
||||||
|
- **Resizable** — drag handle between sidebar and editor. Width persisted.
|
||||||
|
Smooth resize with no layout jank.
|
||||||
|
- **Collapsible** — collapse to a thin icon strip (folder + search +
|
||||||
|
graph icons) on narrow viewports or by user choice. Expand on hover
|
||||||
|
or click. Collapse state persisted.
|
||||||
|
- **Folder tree polish:** Indent guides (subtle vertical lines connecting
|
||||||
|
parent→child). Folder icons that change on expand (open folder / closed
|
||||||
|
folder, not just a triangle). Drop target highlighting with animation
|
||||||
|
(not just background color change). Smooth height animation on
|
||||||
|
expand/collapse.
|
||||||
|
- **Note cards:** Subtle left border color-coded by folder (pulls from a
|
||||||
|
soft palette, not harsh primaries). Title, snippet, relative date
|
||||||
|
("3h ago" not "2026-04-03T12:34"). Tag pills with rounded, muted
|
||||||
|
styling. Pin indicator as a subtle icon, not a text label.
|
||||||
|
- **Search:** Inline search with clear button. Results highlight matching
|
||||||
|
text. Smooth appear/disappear.
|
||||||
|
|
||||||
|
**Editor redesign:**
|
||||||
|
- **Header:** Title input styled as a large heading (not an input field
|
||||||
|
with a border). Folder breadcrumb below title in muted text. Toolbar
|
||||||
|
actions as icon buttons with tooltips — no text labels cluttering the
|
||||||
|
header. Save status indicator (saved ✓ / saving… / unsaved •) as a
|
||||||
|
small, elegant badge.
|
||||||
|
- **Mode switcher:** Segmented control (Read / Edit / Split) replacing
|
||||||
|
the cycling button. Clear visual state.
|
||||||
|
- **Rendered view:** Clean markdown rendering with proper spacing between
|
||||||
|
elements. Block quotes with a left accent border. Tables with subtle
|
||||||
|
borders and alternating row tinting. Inline code with pill-style
|
||||||
|
background. Links with underline on hover only.
|
||||||
|
- **Empty editor state:** "Select a note or create a new one" with a
|
||||||
|
softly illustrated empty state — not a plain text message.
|
||||||
|
|
||||||
|
**Graph visual refresh:**
|
||||||
|
- Dark-on-light node rendering with soft shadows (not flat circles with
|
||||||
|
outlines). Node labels that appear on hover with smooth fade-in.
|
||||||
|
Edge rendering with slight curves (not straight lines). Background
|
||||||
|
subtle dot grid.
|
||||||
|
|
||||||
|
**Transitions and animations:**
|
||||||
|
- Sidebar expand/collapse: 200ms ease-out slide.
|
||||||
|
- Folder tree expand/collapse: 150ms height animation with children
|
||||||
|
fading in.
|
||||||
|
- Note card hover: subtle translateY(-1px) + box-shadow lift.
|
||||||
|
- View mode switch: crossfade (100ms fade out → swap → 100ms fade in).
|
||||||
|
- Save indicator: pulse animation on "saving", check mark with brief
|
||||||
|
scale-up on "saved".
|
||||||
|
- Graph node hover: scale(1.2) with spring easing.
|
||||||
|
- Panel/dialog open: 150ms slide-up + fade-in (consistent with kernel
|
||||||
|
Dialog animation).
|
||||||
|
|
||||||
|
**Color and theming:**
|
||||||
|
- Notes should work beautifully in both light and dark themes.
|
||||||
|
Use CSS custom properties (already the pattern via `var(--bg-surface)`,
|
||||||
|
etc.) but add notes-specific tokens for accent colors, folder palette,
|
||||||
|
and typography:
|
||||||
|
```css
|
||||||
|
--notes-body-font: Georgia, 'Times New Roman', serif;
|
||||||
|
--notes-body-size: 16px;
|
||||||
|
--notes-body-line-height: 1.7;
|
||||||
|
--notes-body-measure: 720px;
|
||||||
|
--notes-accent: var(--accent);
|
||||||
|
--notes-folder-1: #6366f1; /* indigo */
|
||||||
|
--notes-folder-2: #8b5cf6; /* violet */
|
||||||
|
--notes-folder-3: #ec4899; /* pink */
|
||||||
|
--notes-folder-4: #f59e0b; /* amber */
|
||||||
|
--notes-folder-5: #10b981; /* emerald */
|
||||||
|
--notes-folder-6: #06b6d4; /* cyan */
|
||||||
|
```
|
||||||
|
|
||||||
|
**Responsive:**
|
||||||
|
- Below 768px: sidebar collapses to overlay (slide-in from left).
|
||||||
|
Editor goes full-width. Split view disabled. Touch-friendly tap
|
||||||
|
targets (44px minimum on all interactive elements).
|
||||||
|
- Between 768px and 1024px: sidebar narrower (220px). Editor gets
|
||||||
|
remaining space.
|
||||||
|
- Above 1024px: full layout with comfortable sidebar width.
|
||||||
|
|
||||||
|
**What this version does NOT change:**
|
||||||
|
- No new features. Same note CRUD, same folders, same tags, same
|
||||||
|
wikilinks, same graph, same three view modes.
|
||||||
|
- No backend changes. No schema changes.
|
||||||
|
- The existing JS components are restructured for the new layout but
|
||||||
|
retain their current behavior.
|
||||||
|
|
||||||
|
**Deliverable:** Complete CSS rewrite (`css/main.css`), targeted JS
|
||||||
|
changes for new layout structure (resizable sidebar, collapsible sidebar,
|
||||||
|
mode switcher component, animation hooks), and updated component
|
||||||
|
templates where the HTML structure needs to change for the new design.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### v0.11.1 — Deep Folders + Navigation
|
||||||
|
|
||||||
|
**Goal:** Folders become a real hierarchy with breadcrumbs, not just a
|
||||||
|
flat tree with parent_id.
|
||||||
|
|
||||||
|
**Features:**
|
||||||
|
|
||||||
|
- **Breadcrumb navigation** in editor header showing the folder path.
|
||||||
|
Click any segment to navigate to that folder's note list. Styled
|
||||||
|
consistently with the v0.11.0 muted-text breadcrumb design.
|
||||||
|
- **Drag folder→folder** to reparent. Animated drop indicator shows
|
||||||
|
nesting target with indent guide preview. Depth limit: 5 levels.
|
||||||
|
- **Folder sort** — drag to reorder within a level. Smooth reorder
|
||||||
|
animation. Persists via `sort_order` column (already in schema).
|
||||||
|
- **Collapse/expand persistence** — expanded folder set stored in
|
||||||
|
`sw.storage` keyed by user.
|
||||||
|
- **Move dialog** — select destination folder from a tree picker when
|
||||||
|
moving notes (alternative to drag for accessibility / many folders).
|
||||||
|
Uses `sw.ui.Dialog` with the new folder tree component inside.
|
||||||
|
|
||||||
|
**UX standard:** All folder interactions use the animation language
|
||||||
|
established in v0.11.0. Drop targets highlight with the folder color
|
||||||
|
from the palette. Reparent shows a brief connection-line animation.
|
||||||
|
|
||||||
|
**Backend changes:**
|
||||||
|
|
||||||
|
- Validate `parent_id` chain on create/update (no cycles, depth ≤ 5).
|
||||||
|
- Folder delete: require empty or offer cascade (move children to parent,
|
||||||
|
or move contained notes to Unfiled).
|
||||||
|
- `_list_folders()` returns full tree with `children_count` and
|
||||||
|
`note_count` for each folder.
|
||||||
|
|
||||||
|
**Schema:** No changes.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### v0.11.2 — Wikilinks + Backlinks
|
||||||
|
|
||||||
|
**Goal:** `[[wikilinks]]` become a first-class editing and navigation
|
||||||
|
primitive with autocomplete, previews, and unresolved link handling.
|
||||||
|
|
||||||
|
**Features:**
|
||||||
|
|
||||||
|
- **CM6 autocomplete** — typing `[[` triggers a fuzzy note title picker
|
||||||
|
with a clean dropdown styled consistently with the notes design
|
||||||
|
language (not browser-default autocomplete). Shows note title, folder
|
||||||
|
path, and snippet preview. Keyboard navigable.
|
||||||
|
- **Wikilink rendering** — rendered markdown converts `[[Title]]` to
|
||||||
|
internal links with a subtle notes-specific style (dotted underline,
|
||||||
|
small link icon). Distinct from external URLs.
|
||||||
|
- **Hover preview** — hovering a wikilink shows a floating card with
|
||||||
|
the target note's title, first ~200 chars rendered as markdown, and
|
||||||
|
folder/tag metadata. Smooth fade-in, positioned to avoid viewport
|
||||||
|
overflow. Same visual language as graph node hover cards.
|
||||||
|
- **Unresolved links** — `[[Nonexistent Note]]` renders with a dashed
|
||||||
|
styling and a muted color. Click to create the note with that title
|
||||||
|
(pre-filled). Small "+" indicator on hover.
|
||||||
|
- **Backlinks panel improvements:**
|
||||||
|
- Show context snippet (surrounding text with the `[[link]]`
|
||||||
|
highlighted).
|
||||||
|
- Group by folder with folder color indicators.
|
||||||
|
- Count badge in sidebar tab (animated increment on new backlinks).
|
||||||
|
- Navigate to the linking note and scroll to the link location.
|
||||||
|
- **Aliases** — new `aliases` column on `notes` table. Autocomplete
|
||||||
|
searches both title and aliases.
|
||||||
|
|
||||||
|
**UX standard:** Autocomplete dropdown uses the notes card styling.
|
||||||
|
Hover preview card shares visual language with graph tooltips. Unresolved
|
||||||
|
link creation is a smooth inline experience, not a navigate-away-and-back.
|
||||||
|
|
||||||
|
**Backend changes:**
|
||||||
|
|
||||||
|
- `_sync_links()` resolves aliases as well as titles.
|
||||||
|
- New endpoint: `GET /autocomplete?q=...` — fast title+alias prefix
|
||||||
|
search (limit 10).
|
||||||
|
- Backlinks response includes `context_snippet` field.
|
||||||
|
|
||||||
|
**Schema changes:**
|
||||||
|
|
||||||
|
- `notes` table: add `aliases` column (text, comma-separated).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### v0.11.3 — Live Preview + Rich Editing
|
||||||
|
|
||||||
|
**Goal:** The editor becomes genuinely pleasant to write in — live
|
||||||
|
preview that scrolls in sync, or an optional inline-formatted mode.
|
||||||
|
|
||||||
|
**Features:**
|
||||||
|
|
||||||
|
- **Split pane scroll sync** — scrolling the CM6 editor scrolls the
|
||||||
|
rendered preview to the corresponding position. Uses heading anchors
|
||||||
|
for coarse sync and line-height interpolation for fine sync.
|
||||||
|
- **Live preview debounce** — rendered pane updates as you type with
|
||||||
|
150ms debounce. Smooth content transitions (no jarring reflow).
|
||||||
|
- **Inline preview** (Obsidian-style) — optional mode where markdown
|
||||||
|
syntax hides and formatting appears inline while editing. CM6
|
||||||
|
decorations for bold, italic, headings, links, code blocks.
|
||||||
|
Syntax reappears when cursor enters the formatted region. This is
|
||||||
|
the "fourth mode" alongside rendered / edit / split — a true
|
||||||
|
WYSIWYG-ish editing experience without leaving markdown.
|
||||||
|
- **Image paste** — paste from clipboard, upload via `files` module,
|
||||||
|
insert `` at cursor. Progress indicator during upload.
|
||||||
|
Fallback: base64 inline if `files` module unavailable.
|
||||||
|
- **Table editing** — tab-to-next-cell, auto-expand columns, add
|
||||||
|
row/column with floating buttons on hover. Markdown tables become
|
||||||
|
usable instead of a formatting chore.
|
||||||
|
|
||||||
|
**UX standard:** Split pane has an animated gutter with a subtle drag
|
||||||
|
handle. Mode switcher expands to accommodate the fourth option (or
|
||||||
|
becomes a dropdown if space is tight). Image paste shows a smooth
|
||||||
|
inline loading skeleton.
|
||||||
|
|
||||||
|
**Backend changes:**
|
||||||
|
|
||||||
|
- Image upload endpoint: `POST /images` — receives multipart, stores
|
||||||
|
via `files` module, returns URL.
|
||||||
|
|
||||||
|
**Schema:** No changes.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### v0.11.4 — Note Sharing + Permissions
|
||||||
|
|
||||||
|
**Goal:** Notes become collaborative. Share a note or folder with
|
||||||
|
a team, group, individual, or the public — with read-only or read-write
|
||||||
|
permissions.
|
||||||
|
|
||||||
|
**Share Model:**
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────┐
|
||||||
|
│ Share Scope │ Granularity │ Permissions │
|
||||||
|
├──────────────────────────┼─────────────┼─────────────┤
|
||||||
|
│ Public (anonymous URL) │ note only │ read-only │
|
||||||
|
│ Team │ note/folder │ ro / rw │
|
||||||
|
│ Group │ note/folder │ ro / rw │
|
||||||
|
│ Individual (user) │ note/folder │ ro / rw │
|
||||||
|
└──────────────────────────┴─────────────┴─────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
**Folder sharing inherits:** sharing a folder grants access to all notes
|
||||||
|
in that folder (and sub-folders). Individual note shares override folder
|
||||||
|
permissions (higher wins).
|
||||||
|
|
||||||
|
**Features:**
|
||||||
|
|
||||||
|
- **Share dialog** — clean modal (using `sw.ui.Dialog`) with scope
|
||||||
|
selector, user/team/group picker (using `sw.ui.UserPicker` or new
|
||||||
|
entity picker), permission toggle, and copy-link button. Share URL
|
||||||
|
preview with visual indicator.
|
||||||
|
- **Public notes surface** — new surface at `/s/notes/public/:share_id`.
|
||||||
|
Renders a single note in read-only mode with clean, minimal chrome.
|
||||||
|
Uses the v0.11.0 typography and rendered view styling. Armature
|
||||||
|
branding footer. No authentication required.
|
||||||
|
- **Shared-with-me view** — sidebar section (new tab) showing notes/folders
|
||||||
|
shared by others. Grouped by owner with avatar. Visual distinction
|
||||||
|
from your own notes.
|
||||||
|
- **Share indicators** — shared notes show a subtle share icon on the
|
||||||
|
note card. Folder tree shows share badge on shared folders. Hover
|
||||||
|
reveals share scope.
|
||||||
|
- **Permission enforcement** — Starlark backend checks share permissions
|
||||||
|
on every read/write.
|
||||||
|
- **Share revocation** — remove access from share dialog with
|
||||||
|
confirmation.
|
||||||
|
|
||||||
|
**UX standard:** Share dialog is the most complex new UI in the series
|
||||||
|
and must feel simple. One-click sharing for common cases (share with
|
||||||
|
team, make public). Advanced options (specific users, groups) are
|
||||||
|
available but not in the way. Copy-link button shows a brief "Copied!"
|
||||||
|
toast. Public note surface is a showcase — the best the notes rendering
|
||||||
|
can look.
|
||||||
|
|
||||||
|
**Backend changes:**
|
||||||
|
|
||||||
|
- New `note_shares` table.
|
||||||
|
- New `folder_shares` table.
|
||||||
|
- Access check function: `_can_access(user_id, note_id, permission)`.
|
||||||
|
- `_list_notes()` includes shared notes with `shared_by` metadata.
|
||||||
|
- Public note endpoint: unauthenticated GET by share_token.
|
||||||
|
|
||||||
|
**Schema changes:**
|
||||||
|
|
||||||
|
- New table: `note_shares` — note_id, share_type, target_id, permission,
|
||||||
|
share_token, created_by, created_at.
|
||||||
|
- New table: `folder_shares` — folder_id, share_type, target_id,
|
||||||
|
permission, share_token, created_by, created_at.
|
||||||
|
|
||||||
|
**Manifest changes:**
|
||||||
|
|
||||||
|
- New surface: `/notes/public/:share_id` with `auth: "public"`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### v0.11.5 — Graph + Outline Hardening
|
||||||
|
|
||||||
|
**Goal:** The graph becomes a genuine navigation and discovery tool.
|
||||||
|
The outline becomes a reliable TOC.
|
||||||
|
|
||||||
|
**Graph improvements:**
|
||||||
|
|
||||||
|
- **d3-force layout** — replace hand-rolled force sim with d3-force.
|
||||||
|
Better convergence, collision avoidance, centering. ~15KB optional
|
||||||
|
vendor (same pattern as CM6).
|
||||||
|
- **Zoom controls** — explicit +/- buttons (styled as floating pills) plus
|
||||||
|
scroll-to-zoom. Reset-to-fit button.
|
||||||
|
- **Minimap** — small overview in corner with viewport rectangle.
|
||||||
|
Draggable to navigate large graphs.
|
||||||
|
- **Cluster by folder** — visual grouping with soft convex hulls in
|
||||||
|
folder colors (from the v0.11.0 palette). Toggle on/off.
|
||||||
|
- **Filter by tag** — highlight matching nodes, dim others. Smooth
|
||||||
|
opacity transition.
|
||||||
|
- **Search in graph** — highlight and smooth-pan to matching nodes.
|
||||||
|
- **Hover card** — note title, snippet, folder, tag count. Same visual
|
||||||
|
language as wikilink hover preview (v0.11.2).
|
||||||
|
|
||||||
|
**Outline improvements:**
|
||||||
|
|
||||||
|
- **Scroll sync** — active heading highlights as user scrolls. Uses
|
||||||
|
`IntersectionObserver`. Smooth highlight transition.
|
||||||
|
- **Click-to-scroll** — smooth scroll with brief heading highlight pulse.
|
||||||
|
- **Indent levels** — h1→h6 with subtle indent guides matching the
|
||||||
|
folder tree style.
|
||||||
|
- **Collapse/expand** — heading sections collapsible in outline.
|
||||||
|
Animated, consistent with folder tree animations.
|
||||||
|
|
||||||
|
**UX standard:** The graph should feel like a discovery tool, not a tech
|
||||||
|
demo. Interactions should be fluid — pan, zoom, hover, click — with no
|
||||||
|
jank. The outline should feel like a table of contents in a well-typeset
|
||||||
|
book.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### v0.11.6 — Quick Switcher + Commands
|
||||||
|
|
||||||
|
**Goal:** Keyboard-driven navigation. Power users never touch the mouse.
|
||||||
|
|
||||||
|
**Features:**
|
||||||
|
|
||||||
|
- **Quick switcher** (`Cmd+O` / `Ctrl+O`) — centered overlay with fuzzy
|
||||||
|
search. Clean design: large input field, results below with note
|
||||||
|
title, folder path, and snippet. Recent notes above search results.
|
||||||
|
Arrow keys + Enter. Smooth open/close animation. Matches the visual
|
||||||
|
language of command palettes in VS Code / Raycast.
|
||||||
|
- **Command palette** (`Cmd+Shift+P`) — same overlay style, listing all
|
||||||
|
available note actions as searchable commands. Extensible via
|
||||||
|
`sw.actions`.
|
||||||
|
- **Keyboard shortcuts:** Registered via a central keymap.
|
||||||
|
|
||||||
|
| Shortcut | Action |
|
||||||
|
|----------|--------|
|
||||||
|
| `Cmd+O` | Quick switcher |
|
||||||
|
| `Cmd+Shift+P` | Command palette |
|
||||||
|
| `Cmd+N` | New note |
|
||||||
|
| `Cmd+S` | Save (in edit mode) |
|
||||||
|
| `Cmd+E` | Toggle edit/rendered |
|
||||||
|
| `Cmd+Shift+E` | Toggle split view |
|
||||||
|
| `Cmd+D` | Open today's daily note |
|
||||||
|
| `Cmd+G` | Toggle graph view |
|
||||||
|
| `Cmd+B` | Toggle backlinks panel |
|
||||||
|
| `Escape` | Close active panel/dialog |
|
||||||
|
|
||||||
|
**UX standard:** The quick switcher is a high-frequency interaction —
|
||||||
|
it must open instantly (no perceptible delay), search results must
|
||||||
|
appear as-you-type, and the whole flow (Cmd+O → type → Enter) should
|
||||||
|
take under 2 seconds for a user who knows what they want.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### v0.11.7 — Daily Notes + Templates
|
||||||
|
|
||||||
|
**Goal:** Recurring note patterns that lower friction.
|
||||||
|
|
||||||
|
**Daily notes:**
|
||||||
|
|
||||||
|
- Auto-created daily note — navigating to "today" creates a note titled
|
||||||
|
`YYYY-MM-DD` in a configurable daily notes folder.
|
||||||
|
- Daily note template — configurable template body.
|
||||||
|
- **Calendar picker** — small, beautiful calendar widget in the sidebar.
|
||||||
|
Days with notes dot-marked. Styled as a subtle, compact component
|
||||||
|
(not a full-page calendar). Click a date to open that day's note.
|
||||||
|
- Previous/next day nav arrows in editor header for daily notes.
|
||||||
|
|
||||||
|
**Templates:**
|
||||||
|
|
||||||
|
- Notes in a "Templates" folder are templates. No special type.
|
||||||
|
- "New Note" dropdown offers template selection with preview.
|
||||||
|
- Template variables: `{{date}}`, `{{time}}`, `{{title}}`, `{{folder}}` —
|
||||||
|
simple string replacement.
|
||||||
|
- **Slash commands** in CM6 — typing `/` at line start shows a styled
|
||||||
|
command menu: `/template`, `/date`, `/time`, `/todo`, `/callout`,
|
||||||
|
`/table`, `/code`, `/divider`. Extensible via `sw.slots`.
|
||||||
|
|
||||||
|
**UX standard:** The calendar picker should feel like a subtle,
|
||||||
|
integrated part of the sidebar — not a jarring widget. Template
|
||||||
|
selection should show a live preview of the template content.
|
||||||
|
Slash commands should appear fast and be keyboard navigable.
|
||||||
|
|
||||||
|
**Settings additions:**
|
||||||
|
|
||||||
|
- `daily_note_folder` — folder name for daily notes.
|
||||||
|
- `daily_note_template` — template body.
|
||||||
|
- `template_folder` — folder name for templates.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### v0.11.8 — Transclusion + Embeds
|
||||||
|
|
||||||
|
**Goal:** Notes reference and embed each other's content.
|
||||||
|
|
||||||
|
**Features:**
|
||||||
|
|
||||||
|
- **Transclusion** (`![[Note Title]]`) — embeds referenced note content
|
||||||
|
inline in a clean bordered container with source title as header link.
|
||||||
|
- **Section transclusion** (`![[Note Title#Heading]]`) — embeds content
|
||||||
|
under specified heading only.
|
||||||
|
- **Block transclusion** (`![[Note Title^block-id]]`) — single paragraph
|
||||||
|
by block ID.
|
||||||
|
- **Recursion guard** — depth limited to 3. Circular references show
|
||||||
|
a clean warning card, not an error.
|
||||||
|
- **File attachments** — drag-and-drop files onto editor to upload.
|
||||||
|
Non-image files render as styled download cards (file icon, name,
|
||||||
|
size).
|
||||||
|
- **Embed preview in editor** — CM6 decoration shows read-only preview
|
||||||
|
below `![[...]]` line. Collapsible with smooth animation.
|
||||||
|
|
||||||
|
**UX standard:** Transclusions should feel like natural parts of the
|
||||||
|
document, not foreign inclusions. The border and header link should be
|
||||||
|
subtle. Embedded content uses the same typography as the host note.
|
||||||
|
Collapse animation is smooth.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### v0.11.9 — Composability: Slots + Actions
|
||||||
|
|
||||||
|
**Goal:** Notes becomes a host surface that other extensions enhance.
|
||||||
|
|
||||||
|
**Slot declarations:**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"slots": {
|
||||||
|
"notes:toolbar-actions": {
|
||||||
|
"description": "Action buttons in the note editor toolbar",
|
||||||
|
"context": {
|
||||||
|
"noteId": "string",
|
||||||
|
"getContent": "function — returns markdown body",
|
||||||
|
"setContent": "function — replaces markdown body",
|
||||||
|
"getTitle": "function — returns title"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"notes:note-footer": {
|
||||||
|
"description": "Content rendered below the note body",
|
||||||
|
"context": { "noteId": "string", "content": "string — HTML" }
|
||||||
|
},
|
||||||
|
"notes:sidebar-tabs": {
|
||||||
|
"description": "Additional tabs in the notes sidebar",
|
||||||
|
"context": { "activeNoteId": "string or null" }
|
||||||
|
},
|
||||||
|
"notes:slash-commands": {
|
||||||
|
"description": "Additional slash commands in the editor",
|
||||||
|
"context": { "insertText": "function(text)" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Exported actions:**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"exports": {
|
||||||
|
"actions": {
|
||||||
|
"notes.create": "Create note (params: title, body, folder_id)",
|
||||||
|
"notes.search": "Search notes (params: query, limit)",
|
||||||
|
"notes.get": "Get note by ID (params: note_id)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**UX standard:** Slot contributions must fit visually. Toolbar action
|
||||||
|
buttons contributed by other extensions inherit the notes icon-button
|
||||||
|
styling. Sidebar tabs contributed by extensions match the built-in tab
|
||||||
|
design. The notes surface should not look different when extensions
|
||||||
|
contribute to its slots.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### v0.11.10 — Panel Enhancement + Quality Gate
|
||||||
|
|
||||||
|
**Goal:** The `notes.reference` panel (shipped in v0.10.4) inherits
|
||||||
|
the v0.11.x features. Final quality gate.
|
||||||
|
|
||||||
|
**Panel updates:**
|
||||||
|
|
||||||
|
- Reference panel gets the v0.11.0 visual design language.
|
||||||
|
- Search, folder filter, tag filter in panel.
|
||||||
|
- Preview pane shows rendered note content (v0.11.0 typography).
|
||||||
|
- Click-to-insert: emits `panel.notes.reference.selected` for host.
|
||||||
|
- Wikilink resolution works within panel preview.
|
||||||
|
|
||||||
|
**Quality gate criteria:**
|
||||||
|
|
||||||
|
- All v0.11.x features exercised in SDK test runner.
|
||||||
|
- Notes-runner surface tests cover: CRUD, sharing, wikilinks, daily
|
||||||
|
notes, templates, transclusion.
|
||||||
|
- Graph renders correctly with 200+ nodes at 60fps.
|
||||||
|
- Public note surface renders without authentication.
|
||||||
|
- At least one slot contribution demonstrated.
|
||||||
|
- Panel reference works in at least one other surface.
|
||||||
|
- **UX review:** every screen, every interaction, every empty state
|
||||||
|
reviewed against v0.11.0 design principles. No "placeholder" UI
|
||||||
|
surviving to this point.
|
||||||
|
- **Responsive:** full experience on mobile (sidebar overlay, touch
|
||||||
|
targets, no horizontal scroll).
|
||||||
|
- **Accessibility:** keyboard navigation through all views. ARIA labels.
|
||||||
|
Focus management on panel/dialog open/close.
|
||||||
|
- **Performance:** virtual scrolling on note list for 1000+ notes.
|
||||||
|
Graph 60fps with 500+ nodes. Lazy-load graph and CM6.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Uniquely Armature
|
||||||
|
|
||||||
|
| Feature | Obsidian/Notion | Armature Notes |
|
||||||
|
|---------|-----------------|----------------|
|
||||||
|
| Extension slots | Plugin API (Obsidian) | Kernel-level composability — any package contributes to any slot without coupling |
|
||||||
|
| Sharing | Obsidian Publish (paid), Notion sharing | Built on kernel resource model — same permission patterns as every other extension |
|
||||||
|
| Panels | N/A | Notes is a panel provider — embed a notes view inside chat, dashboard, or any surface |
|
||||||
|
| Multi-surface | Single window | Full surface + public surface + reference panel, all from one package |
|
||||||
|
| Server-side logic | Local only (Obsidian) | Starlark backend — search, link resolution, access control run server-side |
|
||||||
|
| Realtime | N/A (Obsidian), built-in (Notion) | Via `sw.realtime` — same primitive available to all extensions |
|
||||||
|
| Cross-extension actions | Plugin API | `sw.actions` — any package can call `notes.create` or `notes.search` |
|
||||||
|
| Self-hosted | Obsidian Sync (paid) | Runs on your infrastructure, your data, your rules |
|
||||||
|
|
||||||
|
The thesis: **Notes isn't just a note-taking app — it's proof that
|
||||||
|
Armature's extension architecture can deliver a production-quality
|
||||||
|
knowledge base that competes with purpose-built SaaS tools, while
|
||||||
|
remaining decomposable and extensible.** And it has to *look and feel*
|
||||||
|
like it competes, not just architecturally compete.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Schema Summary
|
||||||
|
|
||||||
|
### Existing tables (no changes)
|
||||||
|
|
||||||
|
- `notes` — title, body, folder_id, creator_id, updated_at, pinned, archived
|
||||||
|
- `tags` — note_id, tag
|
||||||
|
- `links` — source_id, target_id, link_text
|
||||||
|
- `folders` — name, parent_id, creator_id, sort_order
|
||||||
|
|
||||||
|
### New columns
|
||||||
|
|
||||||
|
- `notes.aliases` (text) — comma-separated aliases (v0.11.2)
|
||||||
|
|
||||||
|
### New tables
|
||||||
|
|
||||||
|
- `note_shares` — note_id, share_type, target_id, permission, share_token, created_by, created_at (v0.11.4)
|
||||||
|
- `folder_shares` — folder_id, share_type, target_id, permission, share_token, created_by, created_at (v0.11.4)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Settings Summary
|
||||||
|
|
||||||
|
### Existing
|
||||||
|
|
||||||
|
- `default_view` — "recent" or "pinned"
|
||||||
|
- `editor_mode` — "rendered", "edit", or "split"
|
||||||
|
|
||||||
|
### New
|
||||||
|
|
||||||
|
| Setting | Type | Default | Version |
|
||||||
|
|---------|------|---------|---------|
|
||||||
|
| `daily_note_folder` | string | "Daily Notes" | v0.11.7 |
|
||||||
|
| `daily_note_template` | string | `"# {{date}}\n\n"` | v0.11.7 |
|
||||||
|
| `template_folder` | string | "Templates" | v0.11.7 |
|
||||||
|
| `inline_preview` | boolean | false | v0.11.3 |
|
||||||
|
| `graph_hide_orphans` | boolean | false | v0.11.5 |
|
||||||
|
| `graph_cluster_by` | string | "folder" | v0.11.5 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Design Decisions
|
||||||
|
|
||||||
|
| Decision | Rationale |
|
||||||
|
|----------|-----------|
|
||||||
|
| UI redesign as v0.11.0, before features | Every subsequent version builds on the visual foundation. Shipping features on an ugly base means reworking the UI of every feature when the redesign eventually happens. Front-loading avoids double work and ensures every version screenshot looks like a real product. |
|
||||||
|
| Notes as first reference extension | Exercises more kernel primitives than any other candidate. Complex enough to prove the platform; familiar enough to be immediately useful. |
|
||||||
|
| Dedicated v0.11.x series (11 versions) | Notes is too large for a single version. Each v0.11.x is independently shippable and CI-green. |
|
||||||
|
| UX quality threaded through every version | Each version specifies its UX standard, not just functionality. No "make it pretty later" — every feature ships with its final visual quality. |
|
||||||
|
| Share model via new tables | Notes sharing needs note/folder-specific semantics (inheritance, public URLs, share tokens). Purpose-built tables beat generic resource grants for this use case. |
|
||||||
|
| Aliases as comma-separated text | Small set (1–3 per note). Separate table adds join complexity for marginal normalization benefit. |
|
||||||
|
| d3-force for graph | Existing hand-rolled sim has poor convergence at 100+ nodes. d3-force is battle-tested, ~15KB. |
|
||||||
|
| Transclusion rendered on view, not live-synced | Live-sync requires WebSocket subs per embedded note. Disproportionate complexity pre-MVP. |
|
||||||
|
| Templates are just notes in a folder | No template schema. KISS. |
|
||||||
|
| Inline preview as fourth editing mode | Obsidian's killer UX feature. Users who want WYSIWYG-ish editing without leaving markdown get it without a ProseMirror dependency — CM6 decorations handle it. |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Dependency Chain
|
||||||
|
|
||||||
|
```
|
||||||
|
v0.11.0 UI/UX Foundation ← EVERYTHING depends on this
|
||||||
|
v0.11.1 Deep Folders ← foundation for folder sharing (v0.11.4)
|
||||||
|
v0.11.2 Wikilinks ← foundation for transclusion (v0.11.8)
|
||||||
|
v0.11.3 Live Preview ← foundation for inline preview + embeds (v0.11.8)
|
||||||
|
v0.11.4 Sharing ← depends on deep folders (v0.11.1)
|
||||||
|
v0.11.5 Graph + Outline ← independent (hardening existing features)
|
||||||
|
v0.11.6 Quick Switcher ← independent (keyboard navigation)
|
||||||
|
v0.11.7 Daily Notes ← depends on deep folders (v0.11.1) for daily folder
|
||||||
|
v0.11.8 Transclusion ← depends on wikilinks (v0.11.2) + live preview (v0.11.3)
|
||||||
|
v0.11.9 Composability ← independent (slot/action declarations)
|
||||||
|
v0.11.10 Polish + Gate ← depends on all above
|
||||||
|
```
|
||||||
|
|
||||||
|
Versions v0.11.5, v0.11.6, and v0.11.9 are independent and can be
|
||||||
|
reordered if priorities shift.
|
||||||
743
docs/DESIGN-panels.md
Normal file
743
docs/DESIGN-panels.md
Normal file
@@ -0,0 +1,743 @@
|
|||||||
|
# DESIGN: Panels — v0.10.x
|
||||||
|
|
||||||
|
## Status: Proposed
|
||||||
|
|
||||||
|
## Problem
|
||||||
|
|
||||||
|
Packages export one rendering granularity today: a **surface** — a
|
||||||
|
full-page application that owns `#extension-mount`. There is no way
|
||||||
|
for a package to offer a lightweight, composable view of itself that
|
||||||
|
another surface can pull in.
|
||||||
|
|
||||||
|
Concrete example: the `notes` package provides a full note editor
|
||||||
|
surface at `/s/notes`. When a user is in the `chat` surface and wants
|
||||||
|
to reference their notes, the only option is to navigate away. There is
|
||||||
|
no mechanism to embed a notes panel alongside chat, whether as a
|
||||||
|
floating window, a docked sidebar, or a bottom strip.
|
||||||
|
|
||||||
|
This forces users into one-thing-at-a-time workflows and prevents the
|
||||||
|
"extensions extending extensions" composability story from reaching the
|
||||||
|
UI layer. Slots and actions allow injection of buttons and menu items,
|
||||||
|
but not entire companion views.
|
||||||
|
|
||||||
|
Without kernel coordination, package authors who need this will roll
|
||||||
|
their own floating containers, producing:
|
||||||
|
|
||||||
|
- **Z-index wars** between packages and kernel overlays (Dialog at 1000,
|
||||||
|
debug at 9999, toast in between).
|
||||||
|
- **Inconsistent drag/resize** behavior (touch support, bounds clamping,
|
||||||
|
accessibility).
|
||||||
|
- **No position persistence** — panels reset on every navigation.
|
||||||
|
- **No layout negotiation** — docked panels can't tell the host surface
|
||||||
|
to shrink.
|
||||||
|
|
||||||
|
## Non-Goals
|
||||||
|
|
||||||
|
- **Window management system.** This is not a tiling WM. The kernel
|
||||||
|
provides a small set of presentation modes (floating, docked). Complex
|
||||||
|
layouts are post-1.0 horizon.
|
||||||
|
- **Cross-surface panel sharing at runtime.** A panel runs inside one
|
||||||
|
host surface's page context. It is not an iframe or a separate
|
||||||
|
browsing context.
|
||||||
|
- **Panel-to-panel communication.** Panels communicate with their host
|
||||||
|
surface (and each other) via `sw.events`. No new IPC mechanism.
|
||||||
|
- **Server-side panel rendering.** Panels are frontend-only. The kernel
|
||||||
|
resolves dependencies and serves JS; rendering happens in the browser.
|
||||||
|
|
||||||
|
## What Already Exists
|
||||||
|
|
||||||
|
**Three rendering tiers exist today; panels fill the gap between the
|
||||||
|
first two:**
|
||||||
|
|
||||||
|
| Tier | Scope | Example |
|
||||||
|
|------|-------|---------|
|
||||||
|
| **Surface** | Full-page app, owns `#extension-mount` | Notes editor, Chat, Admin |
|
||||||
|
| *(gap)* | *Composable companion view* | *Notes reference panel in Chat* |
|
||||||
|
| **Block renderer** | Inline content unit in markdown | Mermaid diagram, KaTeX formula |
|
||||||
|
|
||||||
|
**Kernel primitives that panels build on:**
|
||||||
|
|
||||||
|
- `sw.ui.Dialog` — centered modal with focus trap, backdrop, z-index 1000.
|
||||||
|
- `sw.ui.Drawer` — slide-in side panel (left/right), backdrop, z-index 1000.
|
||||||
|
- `sw.events` — pub/sub event bus, local and realtime. Already the
|
||||||
|
communication channel between surfaces and slot contributors.
|
||||||
|
- `sw.storage` — per-user localStorage wrapper with namespaced keys.
|
||||||
|
- `sw.slots` — named UI injection points with manifest declarations.
|
||||||
|
- Manifest `depends` field — package dependency resolution at install time.
|
||||||
|
|
||||||
|
**What panels add:** A second rendering granularity between surfaces and
|
||||||
|
block renderers, with kernel-managed presentation modes and lifecycle.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
### 1. Three-Tier Rendering Hierarchy
|
||||||
|
|
||||||
|
After this change, every package can export up to three things:
|
||||||
|
|
||||||
|
```
|
||||||
|
Surface — full-page app, owns the mount, has shell topbar slots
|
||||||
|
Panel — composable view, kernel-managed container, presentation-agnostic
|
||||||
|
Renderer — inline content block (sw.renderers)
|
||||||
|
```
|
||||||
|
|
||||||
|
The key architectural property: **the panel component does not know its
|
||||||
|
presentation mode.** It renders into whatever container the kernel
|
||||||
|
provides. Floating, docked-right, docked-bottom — that is a shell/layout
|
||||||
|
concern. The panel receives a mount element and a size. Nothing else.
|
||||||
|
|
||||||
|
### 2. Manifest Schema
|
||||||
|
|
||||||
|
#### Provider: `panels` field
|
||||||
|
|
||||||
|
The package that provides a panel declares it in its manifest:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": "notes",
|
||||||
|
"surfaces": ["/notes"],
|
||||||
|
"panels": {
|
||||||
|
"reference": {
|
||||||
|
"entry": "js/panels/reference.js",
|
||||||
|
"title": "Notes",
|
||||||
|
"icon": "📝",
|
||||||
|
"description": "Searchable note list with quick preview",
|
||||||
|
"min_width": 280,
|
||||||
|
"min_height": 200,
|
||||||
|
"default_width": 400,
|
||||||
|
"default_height": 350
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Panel IDs are namespaced: `<package_id>.<panel_key>`. The notes
|
||||||
|
reference panel above is addressed as `notes.reference` everywhere
|
||||||
|
in the SDK and in consumer manifests.
|
||||||
|
|
||||||
|
A package may declare multiple panels:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": "notes",
|
||||||
|
"panels": {
|
||||||
|
"reference": { "entry": "js/panels/reference.js", ... },
|
||||||
|
"graph": { "entry": "js/panels/graph.js", ... }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Consumer: `panels` dependency
|
||||||
|
|
||||||
|
The consuming surface declares which panels it wants available:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": "chat",
|
||||||
|
"surfaces": ["/chat"],
|
||||||
|
"panels": ["notes.reference"]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
This is a **soft dependency**: the panel is available if the providing
|
||||||
|
package is installed and enabled. If not, `sw.panels.open()` returns
|
||||||
|
`false` and the consuming surface can degrade gracefully (hide the
|
||||||
|
button, show a tooltip explaining the missing package).
|
||||||
|
|
||||||
|
This is distinct from `depends` (hard dependency — install fails without
|
||||||
|
it). A consumer does not need the provider installed to function; the
|
||||||
|
panel is an enhancement.
|
||||||
|
|
||||||
|
### 3. Panel Entry Point Contract
|
||||||
|
|
||||||
|
The panel JS module exports a single `mount` function. The kernel calls
|
||||||
|
it with a DOM element and a context object. It returns a cleanup function.
|
||||||
|
|
||||||
|
```js
|
||||||
|
// notes/js/panels/reference.js
|
||||||
|
const { html } = window;
|
||||||
|
const { render } = preact;
|
||||||
|
|
||||||
|
export function mount(el, ctx) {
|
||||||
|
// ctx.sw — SDK instance
|
||||||
|
// ctx.params — optional params from sw.panels.open()
|
||||||
|
// ctx.panelId — 'notes.reference'
|
||||||
|
// ctx.close — function to request close
|
||||||
|
// ctx.resize — function(width, height) to request resize
|
||||||
|
|
||||||
|
render(html`<${NotesReference} sw=${ctx.sw} />`, el);
|
||||||
|
|
||||||
|
// Return cleanup
|
||||||
|
return () => render(null, el);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Rules:**
|
||||||
|
|
||||||
|
- The panel must not assume any particular container size. Use CSS
|
||||||
|
`width: 100%; height: 100%` and let the kernel container control
|
||||||
|
dimensions.
|
||||||
|
- The panel must not create its own overlay, backdrop, or drag handle.
|
||||||
|
The kernel wraps the panel in the appropriate chrome.
|
||||||
|
- The panel must not set `z-index` on anything. The kernel manages
|
||||||
|
stacking.
|
||||||
|
- The panel may use `ctx.close()` to request its own dismissal (e.g.
|
||||||
|
user clicks an "X" inside the panel content area).
|
||||||
|
- The panel may use `ctx.resize(w, h)` to suggest a new size, but the
|
||||||
|
kernel may clamp or ignore the request.
|
||||||
|
- Communication with the host surface is via `sw.events` — the same
|
||||||
|
bus surfaces and slot contributors already use.
|
||||||
|
|
||||||
|
### 4. SDK API — `sw.panels`
|
||||||
|
|
||||||
|
New SDK module: `src/js/sw/sdk/panels.js`
|
||||||
|
|
||||||
|
```js
|
||||||
|
export function createPanels(events, storage) {
|
||||||
|
const _registry = new Map(); // panelId → manifest entry
|
||||||
|
const _active = new Map(); // panelId → { el, cleanup, mode, ... }
|
||||||
|
|
||||||
|
return {
|
||||||
|
/**
|
||||||
|
* Register a panel from manifest data. Called by the kernel
|
||||||
|
* during surface load — not by package code directly.
|
||||||
|
*/
|
||||||
|
_register(panelId, manifest) { ... },
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Open a panel. Lazy-loads the JS entry if not yet loaded.
|
||||||
|
*
|
||||||
|
* @param {string} panelId — e.g. 'notes.reference'
|
||||||
|
* @param {object} opts
|
||||||
|
* @param {string} opts.mode — 'floating' | 'docked-right' | 'docked-left' | 'docked-bottom'
|
||||||
|
* @param {object} opts.params — passed to mount(el, ctx)
|
||||||
|
* @param {number} opts.width — override default width
|
||||||
|
* @param {number} opts.height — override default height
|
||||||
|
* @returns {Promise<boolean>} — false if panel not available
|
||||||
|
*/
|
||||||
|
async open(panelId, opts = {}) { ... },
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Close a panel. Calls cleanup, removes container.
|
||||||
|
*/
|
||||||
|
close(panelId) { ... },
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Toggle open/close.
|
||||||
|
*/
|
||||||
|
toggle(panelId, opts = {}) { ... },
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a panel is currently open.
|
||||||
|
*/
|
||||||
|
isOpen(panelId) { ... },
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a panel is available (provider installed + enabled).
|
||||||
|
*/
|
||||||
|
isAvailable(panelId) { ... },
|
||||||
|
|
||||||
|
/**
|
||||||
|
* List available panel IDs for the current surface.
|
||||||
|
*/
|
||||||
|
list() { ... },
|
||||||
|
|
||||||
|
/**
|
||||||
|
* List currently open panel IDs.
|
||||||
|
*/
|
||||||
|
active() { ... },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Exposed on `sw` as:**
|
||||||
|
|
||||||
|
```js
|
||||||
|
sw.panels.open('notes.reference', { mode: 'floating' });
|
||||||
|
sw.panels.open('notes.reference', { mode: 'docked-right' });
|
||||||
|
sw.panels.close('notes.reference');
|
||||||
|
sw.panels.toggle('notes.reference');
|
||||||
|
sw.panels.isOpen('notes.reference'); // boolean
|
||||||
|
sw.panels.isAvailable('notes.reference'); // boolean
|
||||||
|
sw.panels.list(); // ['notes.reference', 'notes.graph']
|
||||||
|
sw.panels.active(); // ['notes.reference']
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5. Presentation Modes
|
||||||
|
|
||||||
|
#### Floating (v0.10.1)
|
||||||
|
|
||||||
|
```
|
||||||
|
┌──────────────────────────────────────────────────────────┐
|
||||||
|
│ ← │ Chat │ 🔔 │ 👤 │
|
||||||
|
├──────────────────────────────────────────────────────────┤
|
||||||
|
│ │
|
||||||
|
│ Host surface (interactive) │
|
||||||
|
│ ┌───────────────────┐ │
|
||||||
|
│ │ ≡ Notes ─ ✕ │ │
|
||||||
|
│ │ │ │
|
||||||
|
│ │ [panel content] │ │
|
||||||
|
│ │ │ │
|
||||||
|
│ │ ◢ │ │
|
||||||
|
│ └───────────────────┘ │
|
||||||
|
│ │
|
||||||
|
└──────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
- Draggable via title bar (pointer + touch events).
|
||||||
|
- Resize via bottom-right handle.
|
||||||
|
- Viewport bounds clamping — panel cannot be dragged fully offscreen
|
||||||
|
(at least 48px of title bar must remain visible).
|
||||||
|
- No backdrop — host surface remains fully interactive.
|
||||||
|
- Z-index above surfaces, below Dialog/Drawer overlays.
|
||||||
|
- On focus or drag-start, panel gets next-z (multiple floating panels
|
||||||
|
stack correctly).
|
||||||
|
- Escape key: configurable — close panel or do nothing (default: close).
|
||||||
|
- Position and size persisted in `sw.storage` keyed by panel ID.
|
||||||
|
|
||||||
|
**Title bar chrome (kernel-provided):**
|
||||||
|
|
||||||
|
```
|
||||||
|
┌────────────────────────────────────┐
|
||||||
|
│ ≡ │ {icon} {title} │ ─ ✕ │
|
||||||
|
└────────────────────────────────────┘
|
||||||
|
│ │ │
|
||||||
|
drag handle minimize close
|
||||||
|
```
|
||||||
|
|
||||||
|
- **≡** Drag handle / grip indicator.
|
||||||
|
- **─** Minimize: collapses to a small pill at the bottom of the viewport.
|
||||||
|
Click to restore. Minimized state persisted.
|
||||||
|
- **✕** Close: calls cleanup, removes panel.
|
||||||
|
- Title and icon from manifest.
|
||||||
|
|
||||||
|
#### Docked (v0.10.2)
|
||||||
|
|
||||||
|
```
|
||||||
|
┌──────────────────────────────────────────────────────────┐
|
||||||
|
│ ← │ Chat │ 🔔 │ 👤 │
|
||||||
|
├──────────────────────────────┬──┬────────────────────────┤
|
||||||
|
│ │▐▐│ 📝 Notes ✕ │
|
||||||
|
│ Host surface │▐▐│ │
|
||||||
|
│ (shrinks to fit) │▐▐│ [panel content] │
|
||||||
|
│ │▐▐│ │
|
||||||
|
│ │▐▐│ │
|
||||||
|
└──────────────────────────────┴──┴────────────────────────┘
|
||||||
|
resize
|
||||||
|
gutter
|
||||||
|
```
|
||||||
|
|
||||||
|
- Docked panels consume space from the host surface. The
|
||||||
|
`#extension-mount` area shrinks via CSS flex or grid.
|
||||||
|
- Resize gutter between host and panel (drag to adjust split).
|
||||||
|
- Three dock positions: right (default), left, bottom.
|
||||||
|
- No z-index concerns — docked panels are in normal flow.
|
||||||
|
- Split ratio persisted in `sw.storage` keyed by panel ID + mode.
|
||||||
|
- Close button removes panel, host surface reclaims full width/height.
|
||||||
|
|
||||||
|
#### Mode Transitions (v0.10.2)
|
||||||
|
|
||||||
|
A floating panel can be dragged to a dock zone (edge highlight on
|
||||||
|
hover). A docked panel's title bar can be dragged away to float.
|
||||||
|
The panel component is never unmounted during a mode transition — only
|
||||||
|
the wrapping container changes.
|
||||||
|
|
||||||
|
The user controls presentation mode. Surfaces can suggest a default
|
||||||
|
mode in `sw.panels.open()`, but the user's last-used mode (persisted)
|
||||||
|
takes precedence.
|
||||||
|
|
||||||
|
### 6. Z-Index Strategy
|
||||||
|
|
||||||
|
Panels slot into the existing stacking context:
|
||||||
|
|
||||||
|
| Layer | Z-Index | Contents |
|
||||||
|
|-------|---------|----------|
|
||||||
|
| Base | 0 | Surface content, docked panels (in flow) |
|
||||||
|
| Floating panels | 100–199 | `sw.panels` floating mode (auto-incrementing) |
|
||||||
|
| Shell overlays | 200–299 | Dropdowns, tooltips, menus |
|
||||||
|
| Drawer | 1000 | `sw.ui.Drawer` |
|
||||||
|
| Dialog | 1000 | `sw.ui.Dialog` + confirm/prompt |
|
||||||
|
| Toast | 1100 | `sw.ui.toast` |
|
||||||
|
| Debug | 9999 | Debug panel |
|
||||||
|
|
||||||
|
Floating panels start at z-index 100. Each panel that receives focus
|
||||||
|
or drag-start gets `max(current panel z-indexes) + 1`, capped at 199.
|
||||||
|
If the cap is hit, all floating panels are renumbered starting from 100
|
||||||
|
(maintaining relative order). This prevents unbounded z-index growth.
|
||||||
|
|
||||||
|
When a Dialog or Drawer opens, floating panels remain visible beneath
|
||||||
|
the overlay — they do not auto-hide. This preserves the "see while
|
||||||
|
doing" property.
|
||||||
|
|
||||||
|
### 7. Dependency Resolution + Loading
|
||||||
|
|
||||||
|
**At install time:** The kernel validates that panel dependencies
|
||||||
|
reference valid `<package>.<panel>` identifiers. Invalid references
|
||||||
|
are warnings (soft deps), not errors.
|
||||||
|
|
||||||
|
**At surface load time:**
|
||||||
|
|
||||||
|
1. Kernel reads the surface's `panels` array from its manifest.
|
||||||
|
2. For each declared panel, check if the providing package is installed
|
||||||
|
and enabled. Build the available panel list.
|
||||||
|
3. Register available panels into `sw.panels._register()` with their
|
||||||
|
manifest metadata (entry path, title, icon, size constraints).
|
||||||
|
4. Panel JS is **not** loaded yet — lazy loading on first
|
||||||
|
`sw.panels.open()`.
|
||||||
|
|
||||||
|
**On `sw.panels.open(panelId)`:**
|
||||||
|
|
||||||
|
1. If panel JS not yet loaded: `import()` the entry module from the
|
||||||
|
package's static asset path
|
||||||
|
(`/api/v1/ext/<package>/static/js/panels/<name>.js`).
|
||||||
|
2. Call `module.mount(el, ctx)` where `el` is the content area of the
|
||||||
|
kernel-provided container (FloatingPanel or DockedPanel component).
|
||||||
|
3. Store the cleanup function returned by `mount()`.
|
||||||
|
4. Emit `panels.opened` event with `{ panelId, mode }`.
|
||||||
|
|
||||||
|
**On `sw.panels.close(panelId)`:**
|
||||||
|
|
||||||
|
1. Call stored cleanup function.
|
||||||
|
2. Remove kernel container from DOM.
|
||||||
|
3. Emit `panels.closed` event with `{ panelId }`.
|
||||||
|
|
||||||
|
### 8. Panel Communication
|
||||||
|
|
||||||
|
No new mechanism. Panels and their host surface share the same
|
||||||
|
`sw.events` bus and `sw` SDK instance. Convention-based event
|
||||||
|
namespacing:
|
||||||
|
|
||||||
|
```js
|
||||||
|
// Host surface requests the notes panel to show a specific note
|
||||||
|
sw.emit('panel.notes.reference.show', { noteId: '...' });
|
||||||
|
|
||||||
|
// Panel listens
|
||||||
|
sw.on('panel.notes.reference.show', (data) => {
|
||||||
|
navigateToNote(data.noteId);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Panel notifies the host that a note was selected
|
||||||
|
sw.emit('panel.notes.reference.selected', { noteId: '...', title: '...' });
|
||||||
|
|
||||||
|
// Host listens
|
||||||
|
sw.on('panel.notes.reference.selected', (data) => {
|
||||||
|
insertNoteLink(data);
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
The `params` object in `sw.panels.open()` provides initial state:
|
||||||
|
|
||||||
|
```js
|
||||||
|
sw.panels.open('notes.reference', {
|
||||||
|
mode: 'floating',
|
||||||
|
params: { folderId: 'inbox', highlight: noteId }
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
Params are passed to `mount(el, ctx)` as `ctx.params`. This avoids
|
||||||
|
the need for an event round-trip on first open.
|
||||||
|
|
||||||
|
### 9. CSS
|
||||||
|
|
||||||
|
New file: `src/css/panels.css`
|
||||||
|
|
||||||
|
**Floating panel:**
|
||||||
|
|
||||||
|
```css
|
||||||
|
.sw-panel-floating {
|
||||||
|
position: fixed;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
background: var(--bg-surface);
|
||||||
|
box-shadow: var(--shadow-lg);
|
||||||
|
overflow: hidden;
|
||||||
|
/* z-index set dynamically by JS */
|
||||||
|
}
|
||||||
|
|
||||||
|
.sw-panel-floating__titlebar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--sp-2);
|
||||||
|
padding: var(--sp-2) var(--sp-3);
|
||||||
|
background: var(--bg-raised);
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
cursor: grab;
|
||||||
|
user-select: none;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sw-panel-floating__titlebar:active {
|
||||||
|
cursor: grabbing;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sw-panel-floating__title {
|
||||||
|
flex: 1;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text);
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sw-panel-floating__actions {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--sp-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sw-panel-floating__action {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: var(--text-3);
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 2px 4px;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sw-panel-floating__action:hover {
|
||||||
|
color: var(--text);
|
||||||
|
background: var(--bg-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sw-panel-floating__body {
|
||||||
|
flex: 1;
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sw-panel-floating__resize {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 0;
|
||||||
|
right: 0;
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
cursor: nwse-resize;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Minimized pill */
|
||||||
|
.sw-panel-pill {
|
||||||
|
position: fixed;
|
||||||
|
bottom: var(--sp-3);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--sp-2);
|
||||||
|
padding: var(--sp-2) var(--sp-3);
|
||||||
|
background: var(--bg-surface);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-2);
|
||||||
|
z-index: 100;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sw-panel-pill:hover {
|
||||||
|
color: var(--text);
|
||||||
|
background: var(--bg-raised);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Docked panel:**
|
||||||
|
|
||||||
|
```css
|
||||||
|
.sw-panel-dock-container {
|
||||||
|
display: flex;
|
||||||
|
height: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sw-panel-dock-container--bottom {
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sw-panel-dock-gutter {
|
||||||
|
flex-shrink: 0;
|
||||||
|
background: var(--bg-raised);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
cursor: col-resize;
|
||||||
|
width: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sw-panel-dock-container--bottom .sw-panel-dock-gutter {
|
||||||
|
cursor: row-resize;
|
||||||
|
width: auto;
|
||||||
|
height: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sw-panel-docked {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
background: var(--bg-surface);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sw-panel-docked__header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--sp-2);
|
||||||
|
padding: var(--sp-2) var(--sp-3);
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
background: var(--bg-raised);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sw-panel-docked__body {
|
||||||
|
flex: 1;
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Touch targets (mobile):**
|
||||||
|
|
||||||
|
```css
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.sw-panel-floating__action {
|
||||||
|
min-width: 44px;
|
||||||
|
min-height: 44px;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sw-panel-floating__titlebar {
|
||||||
|
padding: var(--sp-3) var(--sp-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sw-panel-dock-gutter {
|
||||||
|
width: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sw-panel-dock-container--bottom .sw-panel-dock-gutter {
|
||||||
|
height: 10px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 10. Backend Changes
|
||||||
|
|
||||||
|
**Minimal.** The kernel backend already serves extension static assets
|
||||||
|
and reads manifests. Changes:
|
||||||
|
|
||||||
|
1. **Manifest parsing** (`server/extensions/manifest.go`): Parse the
|
||||||
|
`panels` field from provider manifests (map of panel key → entry
|
||||||
|
metadata). Parse the `panels` field from consumer manifests (array
|
||||||
|
of panel ID strings). Store in the existing `PackageRecord` struct.
|
||||||
|
|
||||||
|
2. **Surface load endpoint** (`/api/v1/surfaces/:id`): Include resolved
|
||||||
|
panel metadata in the response — for each panel declared by the
|
||||||
|
surface, include the provider's panel manifest entry (title, icon,
|
||||||
|
entry path, size constraints) if the provider is installed and
|
||||||
|
enabled. This lets the frontend `sw.panels._register()` at surface
|
||||||
|
boot without additional API calls.
|
||||||
|
|
||||||
|
3. **Static asset serving**: Already works — panels are served from
|
||||||
|
the same package static path as surface JS
|
||||||
|
(`/api/v1/ext/<package>/static/...`).
|
||||||
|
|
||||||
|
**No new tables.** No new migrations. Panel metadata lives in the
|
||||||
|
existing manifest JSON stored in the packages table.
|
||||||
|
|
||||||
|
### 11. Admin Visibility
|
||||||
|
|
||||||
|
The admin packages page already shows package manifests. With panels:
|
||||||
|
|
||||||
|
- The package detail view shows declared panels with their metadata.
|
||||||
|
- The dependency view shows which surfaces consume which panels.
|
||||||
|
- Panel availability warnings surface when a provider package is
|
||||||
|
disabled but consumers reference its panels.
|
||||||
|
|
||||||
|
This falls out naturally from the existing manifest introspection —
|
||||||
|
no new admin surfaces needed.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Event Inventory
|
||||||
|
|
||||||
|
| Event | Emitted by | Payload |
|
||||||
|
|-------|-----------|---------|
|
||||||
|
| `panels.opened` | Kernel (sw.panels) | `{ panelId, mode }` |
|
||||||
|
| `panels.closed` | Kernel (sw.panels) | `{ panelId }` |
|
||||||
|
| `panels.mode_changed` | Kernel (sw.panels) | `{ panelId, from, to }` |
|
||||||
|
| `panels.focused` | Kernel (sw.panels) | `{ panelId }` |
|
||||||
|
| `panel.<id>.*` | Convention (package code) | Package-defined |
|
||||||
|
|
||||||
|
All panel events are `localOnly: true` — they do not broadcast over
|
||||||
|
the WebSocket. Panels are a frontend-only concern.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Persistence
|
||||||
|
|
||||||
|
Panel state persisted in `sw.storage` (localStorage wrapper):
|
||||||
|
|
||||||
|
| Key | Value | Scope |
|
||||||
|
|-----|-------|-------|
|
||||||
|
| `panel_pos_<panelId>` | `{ x, y, w, h }` | Floating position + size |
|
||||||
|
| `panel_mode_<panelId>` | `'floating' \| 'docked-right' \| ...` | Last-used presentation mode |
|
||||||
|
| `panel_split_<panelId>` | `{ ratio: 0.3 }` | Docked split ratio |
|
||||||
|
| `panel_minimized_<panelId>` | `true` | Minimized state |
|
||||||
|
|
||||||
|
Persisted per-user (sw.storage is already user-scoped). Cleared when
|
||||||
|
the providing package is uninstalled.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Version Plan
|
||||||
|
|
||||||
|
| Version | Title | Scope |
|
||||||
|
|---------|-------|-------|
|
||||||
|
| v0.10.0 | Panel Manifest + Lifecycle | Manifest `panels` field (provider + consumer). Backend parsing. `sw.panels` SDK module (register, open, close, toggle, isOpen, isAvailable, list). Lazy JS loading pipeline. No presentation UI yet — panels render into a plain unstyled container for contract validation. |
|
||||||
|
| v0.10.1 | FloatingPanel Primitive | `FloatingPanel` Preact component with drag, resize, minimize, z-index stacking, viewport bounds, position persistence. `panels.css` floating section. Touch support. |
|
||||||
|
| v0.10.2 | Docked Panels + Mode Transitions | `DockedPanel` component. Layout negotiation (flex resize of `#extension-mount`). Resize gutter. Drag-to-dock / drag-to-float transitions. `panels.css` docked section. Split ratio persistence. |
|
||||||
|
| v0.10.3 | Panel Communication Patterns | Event namespace conventions documented. `ctx.params` for initial state. SDK helper: `sw.panels.send(panelId, event, data)` as sugar over `sw.emit('panel.' + panelId + '.' + event, data)`. Panel communication section in extension developer guide. |
|
||||||
|
| v0.10.4 | Reference Panel: Notes | Notes package ships `panels.reference` — searchable note list with quick preview. Chat declares it as a consumer. End-to-end proof: open notes panel in chat, select a note, insert link. Test runner coverage. |
|
||||||
|
|
||||||
|
Each version independently CI-green. v0.10.0 is the foundation that
|
||||||
|
all subsequent versions build on.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Changeset Plan — v0.10.0
|
||||||
|
|
||||||
|
| CS | Scope | Description |
|
||||||
|
|----|-------|-------------|
|
||||||
|
| CS-1 | Backend (Go) | Manifest parsing: `panels` field on provider + consumer manifests. `PackageRecord` struct update. Surface load endpoint includes resolved panel metadata. |
|
||||||
|
| CS-2 | Frontend (JS) | `sw.panels` SDK module: `createPanels()`, register, open, close, toggle, isOpen, isAvailable, list, active. Lazy `import()` loader. Unstyled container mount. |
|
||||||
|
| CS-3 | Frontend (JS) | SDK boot integration: wire `sw.panels` into SDK boot sequence. Register panels from `__MANIFEST__` surface data. |
|
||||||
|
| CS-4 | Frontend (JS) | Panel lifecycle events: `panels.opened`, `panels.closed`, `panels.focused`. |
|
||||||
|
| CS-5 | Tests | SDK test runner: panel registration, open/close lifecycle, lazy loading, availability checks, event emission. |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Design Decisions
|
||||||
|
|
||||||
|
| Decision | Rationale |
|
||||||
|
|----------|-----------|
|
||||||
|
| Kernel primitive, not package-level | Z-index coordination, drag/resize consistency, layout negotiation, and position persistence are all kernel concerns. Multiple packages need this. |
|
||||||
|
| Soft dependency (not `depends`) | Chat should work without notes installed. Panel availability is a runtime check, not an install-time requirement. |
|
||||||
|
| Presentation-agnostic panel contract | `mount(el, ctx)` / cleanup is the entire API surface. Panels never know if they are floating or docked. This lets us add new modes without touching panel code. |
|
||||||
|
| Lazy loading | Panel JS is not loaded until first `sw.panels.open()`. Surfaces that declare panels but the user never opens them pay zero cost. |
|
||||||
|
| Event bus for communication | `sw.events` already exists and is the established pattern for cross-package communication. No new mechanism needed. |
|
||||||
|
| No new backend tables | Panel metadata is manifest data stored in the existing packages table JSON. No schema changes. |
|
||||||
|
| Z-index band 100–199 | Above surface content, below shell overlays and Dialog/Drawer. Renumber on cap to prevent unbounded growth. |
|
||||||
|
| User controls mode, surface suggests | The surface can pass `mode: 'docked-right'` as a default, but the user's persisted mode preference wins. Users know their own workflow. |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Future Considerations (post-v0.10.x)
|
||||||
|
|
||||||
|
- **User-composable layouts.** Power users drag panels from different
|
||||||
|
packages into a persistent workspace layout. Requires a layout
|
||||||
|
persistence model beyond per-panel position.
|
||||||
|
- **Panel marketplace metadata.** Panels become a discoverability
|
||||||
|
feature: "this package provides 2 surfaces and 3 panels."
|
||||||
|
- **Multi-monitor.** Pop a floating panel into a separate browser
|
||||||
|
window via `window.open()` + `SharedWorker` for event bus. Very
|
||||||
|
post-1.0.
|
||||||
|
- **Keyboard shortcuts.** `Ctrl+Shift+N` opens the notes panel.
|
||||||
|
Requires a keybinding registry (not yet a kernel primitive).
|
||||||
@@ -5,7 +5,7 @@
|
|||||||
## Problem
|
## Problem
|
||||||
|
|
||||||
Extension surfaces render into `#extension-mount` with no shell chrome.
|
Extension surfaces render into `#extension-mount` with no shell chrome.
|
||||||
A full audit (docs/AUDIT-surfaces.md) found: 3/4 primary surfaces lack a
|
A full audit (v0.7.0 surface audit) found: 3/4 primary surfaces lack a
|
||||||
notification bell, 2/4 lack a user menu, 4 different topbar implementations,
|
notification bell, 2/4 lack a user menu, 4 different topbar implementations,
|
||||||
user menu never updates on package/role changes, toast-and-forget error
|
user menu never updates on package/role changes, toast-and-forget error
|
||||||
handling, and empty states that explain nothing.
|
handling, and empty states that explain nothing.
|
||||||
|
|||||||
922
docs/DESIGN-sidecar-v014x.md
Normal file
922
docs/DESIGN-sidecar-v014x.md
Normal file
@@ -0,0 +1,922 @@
|
|||||||
|
# DESIGN: Sidecar Tier — v0.14.x
|
||||||
|
|
||||||
|
## Status: Proposed
|
||||||
|
|
||||||
|
## Problem
|
||||||
|
|
||||||
|
Starlark is the right sandbox for most extension logic: declarative,
|
||||||
|
safe, fast to start, no deployment pipeline. But some workloads cannot
|
||||||
|
run in Starlark:
|
||||||
|
|
||||||
|
- **ML inference** — Python + PyTorch/TF model loading, GPU access.
|
||||||
|
- **Media transcoding** — ffmpeg, ImageMagick, Whisper.
|
||||||
|
- **Language servers** — LSP processes for code-workspace.
|
||||||
|
- **Local git** — clone, diff, merge operations on workspace directories.
|
||||||
|
- **Custom runtimes** — anything needing system libraries, native code,
|
||||||
|
or long-running processes.
|
||||||
|
- **Personal local bridges** — a user's laptop exposing filesystem,
|
||||||
|
terminal, clipboard, or local compute to the Armature instance.
|
||||||
|
|
||||||
|
Today, the `http` module lets Starlark call external APIs (`api.http`
|
||||||
|
permission, domain allowlists, SSRF protection). This covers cloud
|
||||||
|
services (OpenAI, S3, external webhooks) but not **co-located processes**
|
||||||
|
that need access to Armature's data, events, and workspace filesystem,
|
||||||
|
nor **personal processes** on a user's own machine.
|
||||||
|
|
||||||
|
The sidecar tier bridges this gap: out-of-process extensions that
|
||||||
|
connect inward to the kernel, authenticate, register capabilities, and
|
||||||
|
participate in the extension ecosystem as first-class citizens — at both
|
||||||
|
the **instance level** (shared infrastructure) and the **user level**
|
||||||
|
(personal local tools).
|
||||||
|
|
||||||
|
**If done wrong, the consequences are severe:**
|
||||||
|
|
||||||
|
- Wrong auth model → arbitrary processes access kernel APIs.
|
||||||
|
- Wrong lifecycle → resource leaks, zombie processes, orphaned
|
||||||
|
registrations.
|
||||||
|
- Wrong API surface → every sidecar extension inherits the debt.
|
||||||
|
- Wrong isolation → a runaway sidecar takes down the cluster.
|
||||||
|
- Wrong discovery → fragile deployments that break on restart.
|
||||||
|
- Wrong scoping → user sidecars see other users' data.
|
||||||
|
|
||||||
|
This is invisible infrastructure. Users never see it. But every
|
||||||
|
extension that needs native code depends on the contract being right.
|
||||||
|
|
||||||
|
## Non-Goals
|
||||||
|
|
||||||
|
- **Kernel manages sidecar processes.** The kernel does not start, stop,
|
||||||
|
or restart sidecars. That's the deployment layer's job (K8s, Docker
|
||||||
|
Compose, systemd, or a human running a binary). The kernel discovers
|
||||||
|
sidecars that connect to it.
|
||||||
|
- **Sidecar marketplace.** Sidecar packages are installed like any other
|
||||||
|
`.pkg` file. The sidecar binary ships separately (container image,
|
||||||
|
standalone binary, pip package). The manifest declares the sidecar
|
||||||
|
endpoint; the operator deploys it.
|
||||||
|
- **Arbitrary bidirectional streaming.** Sidecars communicate via
|
||||||
|
HTTP/JSON request-response and WebSocket events. No gRPC, no custom
|
||||||
|
binary protocols. KISS.
|
||||||
|
- **Hot code reload.** Sidecar updates require process restart. The
|
||||||
|
kernel detects the reconnection and re-registers capabilities.
|
||||||
|
- **Multi-tenant sidecar isolation.** Instance-level sidecars run at the
|
||||||
|
instance level, not per-user. User-level isolation is handled by
|
||||||
|
user sidecars (v0.14.5), which are scoped to the owning user's
|
||||||
|
identity.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
### Connect-Inward Model
|
||||||
|
|
||||||
|
The defining property: **sidecars connect TO the kernel, not the other
|
||||||
|
way around.** The kernel never reaches outward to discover or contact
|
||||||
|
a sidecar. This eliminates:
|
||||||
|
|
||||||
|
- K8s RBAC for the kernel to discover pods.
|
||||||
|
- Service mesh configuration.
|
||||||
|
- DNS-based service discovery.
|
||||||
|
- Any notion of the kernel "managing" external processes.
|
||||||
|
|
||||||
|
A sidecar is just a process that knows the kernel's address and has
|
||||||
|
credentials to authenticate. It could be a container in the same pod, a
|
||||||
|
separate deployment, a systemd service on the same machine, or a process
|
||||||
|
on a developer's laptop connected via tunnel.
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────┐
|
||||||
|
│ Deployment Layer (K8s / Docker / systemd / manual) │
|
||||||
|
│ │
|
||||||
|
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
|
||||||
|
│ │ instance │ │ instance │ │ instance │ │
|
||||||
|
│ │ sidecar: │ │ sidecar: │ │ sidecar: │ │
|
||||||
|
│ │ ml-infer │ │ ffmpeg │ │ git-ops │ │
|
||||||
|
│ └────┬─────┘ └────┬─────┘ └────┬─────┘ │
|
||||||
|
│ │ │ │ │
|
||||||
|
│ │ connect inward │ │
|
||||||
|
│ ▼ ▼ ▼ │
|
||||||
|
│ ┌──────────────────────────────────────────┐ │
|
||||||
|
│ │ Armature Kernel │ │
|
||||||
|
│ │ ┌─────────────────────────────────────┐ │ │
|
||||||
|
│ │ │ Sidecar Registry (PG) │ │ │
|
||||||
|
│ │ │ ml-infer: instance, 3 caps │ │ │
|
||||||
|
│ │ │ ffmpeg: instance, 2 caps │ │ │
|
||||||
|
│ │ │ git-ops: instance, 4 caps │ │ │
|
||||||
|
│ │ │ jeff-local: user:jeff, 3 caps │ │ │
|
||||||
|
│ │ └─────────────────────────────────────┘ │ │
|
||||||
|
│ └────────────────────────────────▲─────────┘ │
|
||||||
|
│ │ │
|
||||||
|
└───────────────────────────────────┼──────────────────┘
|
||||||
|
│ connect inward
|
||||||
|
┌─────┴──────┐
|
||||||
|
│ user │
|
||||||
|
│ sidecar: │
|
||||||
|
│ jeff-local │
|
||||||
|
│ (laptop) │
|
||||||
|
└────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### Two Scopes
|
||||||
|
|
||||||
|
**Instance sidecars** — shared infrastructure. Deployed by the operator.
|
||||||
|
Any user's requests can invoke their capabilities. An ML inference
|
||||||
|
sidecar, a transcoding service, a git operations worker.
|
||||||
|
|
||||||
|
**User sidecars** — personal processes. Run by an individual user on
|
||||||
|
their own machine. Only the owning user's requests can invoke their
|
||||||
|
capabilities. A local file bridge, a personal Ollama instance, a
|
||||||
|
script that reacts to Armature events.
|
||||||
|
|
||||||
|
Both use the same connect-inward mechanism, same registration flow,
|
||||||
|
same capability contract. The difference is the identity binding and
|
||||||
|
access scoping.
|
||||||
|
|
||||||
|
### Two Authentication Modes
|
||||||
|
|
||||||
|
#### Mode A: Registration Token
|
||||||
|
|
||||||
|
For simple deployments (Docker Compose, single machine, dev/test).
|
||||||
|
|
||||||
|
1. Admin generates an instance sidecar token:
|
||||||
|
`armature sidecar token create --package ml-inference`
|
||||||
|
User generates a personal sidecar token (from settings UI or CLI):
|
||||||
|
`armature sidecar token create --personal`
|
||||||
|
2. Token is a signed JWT with claims: `{package_id, scope, user_id, exp}`.
|
||||||
|
Instance tokens: `scope: "instance"`, `user_id: null`.
|
||||||
|
User tokens: `scope: "user"`, `user_id: "<owner>"`.
|
||||||
|
3. Sidecar starts with the token as env: `ARMATURE_TOKEN=ey...`
|
||||||
|
4. Sidecar calls `POST /api/v1/sidecar/register`.
|
||||||
|
5. Kernel validates JWT, creates registry entry with appropriate scope.
|
||||||
|
|
||||||
|
#### Mode B: mTLS
|
||||||
|
|
||||||
|
For production deployments with certificate infrastructure.
|
||||||
|
|
||||||
|
1. Certificate CN encodes identity:
|
||||||
|
Instance: `sidecar:ml-inference`
|
||||||
|
User: `sidecar-user:jeff:local-bridge`
|
||||||
|
2. Kernel's `MTLSNativeProvider` parses the CN prefix and resolves to
|
||||||
|
the appropriate sidecar identity.
|
||||||
|
|
||||||
|
Both modes produce the same internal identity: a `SidecarIdentity`
|
||||||
|
struct with `{package_id, scope, user_id, node_id, registered_at}`.
|
||||||
|
|
||||||
|
### Sidecar Registry
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CREATE TABLE IF NOT EXISTS sidecar_registry (
|
||||||
|
sidecar_id TEXT PRIMARY KEY,
|
||||||
|
package_id TEXT NOT NULL,
|
||||||
|
scope TEXT NOT NULL DEFAULT 'instance',
|
||||||
|
user_id TEXT,
|
||||||
|
endpoint TEXT NOT NULL,
|
||||||
|
heartbeat TIMESTAMPTZ DEFAULT now(),
|
||||||
|
capabilities JSONB DEFAULT '[]',
|
||||||
|
stats JSONB DEFAULT '{}',
|
||||||
|
registered_at TIMESTAMPTZ DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_sidecar_pkg ON sidecar_registry (package_id);
|
||||||
|
CREATE INDEX idx_sidecar_user ON sidecar_registry (user_id)
|
||||||
|
WHERE user_id IS NOT NULL;
|
||||||
|
```
|
||||||
|
|
||||||
|
`scope` is `'instance'` or `'user'`. `user_id` is NULL for instance
|
||||||
|
sidecars, set for user sidecars.
|
||||||
|
|
||||||
|
**Lifecycle (mirrors cluster registry):**
|
||||||
|
|
||||||
|
- **Register:** `POST /api/v1/sidecar/register` with capability
|
||||||
|
manifest. Kernel creates/updates registry row.
|
||||||
|
- **Heartbeat:** `POST /api/v1/sidecar/heartbeat` every N seconds.
|
||||||
|
- **Sweep:** Kernel's heartbeat loop sweeps stale sidecar entries
|
||||||
|
alongside node entries. 3× heartbeat interval = stale.
|
||||||
|
- **Self-eviction:** Heartbeat returns 404 → re-register or exit.
|
||||||
|
- **Deregister:** `DELETE /api/v1/sidecar/deregister` on graceful
|
||||||
|
shutdown.
|
||||||
|
|
||||||
|
### Capability Registration
|
||||||
|
|
||||||
|
On registration, the sidecar declares capabilities:
|
||||||
|
|
||||||
|
```json
|
||||||
|
POST /api/v1/sidecar/register
|
||||||
|
{
|
||||||
|
"package_id": "ml-inference",
|
||||||
|
"endpoint": "http://ml-inference:8080",
|
||||||
|
"capabilities": [
|
||||||
|
{
|
||||||
|
"name": "embed",
|
||||||
|
"description": "Generate text embeddings",
|
||||||
|
"input_schema": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"text": { "type": "string" },
|
||||||
|
"model": { "type": "string", "default": "all-MiniLM-L6-v2" }
|
||||||
|
},
|
||||||
|
"required": ["text"]
|
||||||
|
},
|
||||||
|
"timeout_seconds": 30
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "classify",
|
||||||
|
"description": "Zero-shot text classification",
|
||||||
|
"input_schema": { ... },
|
||||||
|
"timeout_seconds": 60
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Sidecar SDK (Starlark)
|
||||||
|
|
||||||
|
New `sidecar` Starlark module. Permission: `sidecar.call`.
|
||||||
|
|
||||||
|
```python
|
||||||
|
# Call an instance sidecar capability
|
||||||
|
result = sidecar.call("ml-inference", "embed", {"text": "Hello"})
|
||||||
|
|
||||||
|
# Check availability
|
||||||
|
available = sidecar.available("ml-inference")
|
||||||
|
|
||||||
|
# List capabilities
|
||||||
|
caps = sidecar.capabilities("ml-inference")
|
||||||
|
```
|
||||||
|
|
||||||
|
User sidecar calls are routed automatically. When a request originates
|
||||||
|
from user Jeff, `sidecar.call("user-bridge", "fs.search", {...})` hits
|
||||||
|
Jeff's personal sidecar. The same call from user Alice would hit Alice's
|
||||||
|
personal `user-bridge` sidecar (or fail if she doesn't have one). The
|
||||||
|
Starlark caller doesn't specify the user — the kernel resolves it from
|
||||||
|
the request context.
|
||||||
|
|
||||||
|
### Sidecar SDK (Frontend)
|
||||||
|
|
||||||
|
```js
|
||||||
|
const result = await sw.sidecar.call('ml-inference', 'embed', {
|
||||||
|
text: 'Hello, world'
|
||||||
|
});
|
||||||
|
const available = await sw.sidecar.available('user-bridge');
|
||||||
|
```
|
||||||
|
|
||||||
|
Frontend calls go through the kernel API. The kernel proxies to the
|
||||||
|
appropriate sidecar based on scope + user identity.
|
||||||
|
|
||||||
|
### Sidecar API Contract (Inbound to Kernel)
|
||||||
|
|
||||||
|
| Endpoint | Purpose | Auth |
|
||||||
|
|----------|---------|------|
|
||||||
|
| `POST /api/v1/sidecar/register` | Register + declare capabilities | Token or mTLS |
|
||||||
|
| `POST /api/v1/sidecar/heartbeat` | Heartbeat + stats | Token or mTLS |
|
||||||
|
| `DELETE /api/v1/sidecar/deregister` | Graceful shutdown | Token or mTLS |
|
||||||
|
| `GET /api/v1/ext/{pkg}/*` | Read extension data (scoped) | Token or mTLS |
|
||||||
|
| `POST /api/v1/ext/{pkg}/*` | Write extension data (scoped) | Token or mTLS |
|
||||||
|
| `POST /api/v1/sidecar/emit` | Emit events | Token or mTLS |
|
||||||
|
| `GET /api/v1/sidecar/subscribe` | WebSocket event subscription | Token or mTLS |
|
||||||
|
| `POST /api/v1/sidecar/lib/{pkg}/{fn}` | Cross-package function call | Token or mTLS |
|
||||||
|
|
||||||
|
**Scoping rules:**
|
||||||
|
|
||||||
|
- Instance sidecars: can access `/api/v1/ext/{own_package}/*` only.
|
||||||
|
- User sidecars: can access `/api/v1/ext/{own_package}/*` scoped to
|
||||||
|
the owning user's data within that package. The sidecar sees the
|
||||||
|
same data the user would see through the UI.
|
||||||
|
- Event emission scoped to the sidecar's package prefix.
|
||||||
|
|
||||||
|
### Sidecar API Contract (Outbound from Kernel)
|
||||||
|
|
||||||
|
| Endpoint | Purpose |
|
||||||
|
|----------|---------|
|
||||||
|
| `POST {sidecar_endpoint}/api/v1/exec/{capability}` | Execute a capability |
|
||||||
|
| `GET {sidecar_endpoint}/api/v1/health` | Health check |
|
||||||
|
|
||||||
|
The `/exec/{capability}` endpoint receives:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"input": { ... },
|
||||||
|
"context": {
|
||||||
|
"user_id": "...",
|
||||||
|
"package_id": "...",
|
||||||
|
"request_id": "..."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"output": { ... },
|
||||||
|
"error": null
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Resource Limits
|
||||||
|
|
||||||
|
What the kernel enforces at the proxy layer:
|
||||||
|
|
||||||
|
- **Timeout:** Per-capability, from capability declaration.
|
||||||
|
- **Request rate:** Per-sidecar, configurable in admin.
|
||||||
|
- **Response size:** Default 10MB, configurable per-sidecar.
|
||||||
|
- **Concurrent calls:** Default 10, configurable per-sidecar.
|
||||||
|
|
||||||
|
User sidecars have separate (typically lower) defaults:
|
||||||
|
|
||||||
|
- **User sidecar rate limit:** Default 30/min (vs 100/min instance).
|
||||||
|
- **User sidecar concurrency:** Default 3 (vs 10 instance).
|
||||||
|
- **User sidecar response size:** Default 5MB (vs 10MB instance).
|
||||||
|
|
||||||
|
Admin can adjust user sidecar defaults globally. Individual users
|
||||||
|
cannot override admin limits.
|
||||||
|
|
||||||
|
### Manifest Integration
|
||||||
|
|
||||||
|
Instance sidecar manifest:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": "ml-inference",
|
||||||
|
"type": "full",
|
||||||
|
"tier": "sidecar",
|
||||||
|
"version": "1.0.0",
|
||||||
|
|
||||||
|
"sidecar": {
|
||||||
|
"image": "gobha/armature-ml-inference:latest",
|
||||||
|
"required": true,
|
||||||
|
"health_endpoint": "/api/v1/health",
|
||||||
|
"env_hints": {
|
||||||
|
"ARMATURE_URL": "Kernel URL (auto-populated)",
|
||||||
|
"ARMATURE_TOKEN": "Registration token",
|
||||||
|
"MODEL_PATH": "Path to model files"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
User sidecar manifest (installed as a regular package, sidecar runs
|
||||||
|
on user's machine):
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": "user-bridge",
|
||||||
|
"type": "full",
|
||||||
|
"tier": "sidecar",
|
||||||
|
"version": "1.0.0",
|
||||||
|
|
||||||
|
"sidecar": {
|
||||||
|
"scope": "user",
|
||||||
|
"required": false,
|
||||||
|
"download_url": "https://github.com/armature/user-bridge/releases",
|
||||||
|
"env_hints": {
|
||||||
|
"ARMATURE_URL": "Your Armature instance URL",
|
||||||
|
"ARMATURE_TOKEN": "Generate from Settings → Sidecars"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
"surfaces": ["/user-bridge/settings"]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`sidecar.scope: "user"` tells the kernel this is a user sidecar
|
||||||
|
package. The package installs normally (admin or self-install depending
|
||||||
|
on permissions), but the sidecar binary runs on the user's machine.
|
||||||
|
|
||||||
|
### Tool Meta-Tool Integration
|
||||||
|
|
||||||
|
When `llm-bridge` assembles tools for a completion:
|
||||||
|
|
||||||
|
1. Instance-level actions from `sw.actions.list()` → available to all.
|
||||||
|
2. Instance sidecar capabilities → available to all.
|
||||||
|
3. **Requesting user's personal sidecar capabilities** → available to
|
||||||
|
that user only.
|
||||||
|
|
||||||
|
This means different users get different tool sets. Jeff has
|
||||||
|
`user-bridge` running with `fs.search`, `terminal.exec`, and
|
||||||
|
`clipboard.get`. Alice doesn't. When Jeff asks "Hey Max, find the auth
|
||||||
|
file on my desktop," Max has `user-bridge.fs.search` in his tool set.
|
||||||
|
When Alice asks the same, that tool doesn't exist — Max tells her he
|
||||||
|
can't access her local files.
|
||||||
|
|
||||||
|
The same AI persona, different capabilities per user, zero configuration.
|
||||||
|
|
||||||
|
### Admin UI
|
||||||
|
|
||||||
|
**Instance sidecar management:**
|
||||||
|
- Sidecar health status per registered instance sidecar.
|
||||||
|
- Capabilities list with call counts, error rates, latency.
|
||||||
|
- Token management: generate, revoke, list.
|
||||||
|
- Per-sidecar rate limit and concurrency configuration.
|
||||||
|
|
||||||
|
**User sidecar admin controls:**
|
||||||
|
- Permission: `sidecar.connect_personal` — admin grants to specific
|
||||||
|
users or groups.
|
||||||
|
- Capability allowlist for user sidecars — admin restricts which
|
||||||
|
capabilities user sidecars can declare.
|
||||||
|
- Global user sidecar resource limits (rate, concurrency, response
|
||||||
|
size).
|
||||||
|
- Monitoring: list of all connected user sidecars with owner, endpoint,
|
||||||
|
capabilities, last heartbeat.
|
||||||
|
- Kill switch: admin can revoke any user sidecar token or sweep a
|
||||||
|
specific user's sidecars.
|
||||||
|
|
||||||
|
**User settings (for users with `sidecar.connect_personal`):**
|
||||||
|
- Generate personal sidecar token.
|
||||||
|
- List personal connected sidecars with health status.
|
||||||
|
- Revoke own tokens.
|
||||||
|
- Download link / instructions for user sidecar binaries (from package
|
||||||
|
manifest `download_url`).
|
||||||
|
|
||||||
|
### Monitoring
|
||||||
|
|
||||||
|
Kernel-side metrics:
|
||||||
|
|
||||||
|
- `sidecar_call_total{package, capability, scope, status}` — counter.
|
||||||
|
- `sidecar_call_duration_seconds{package, capability, scope}` — histogram.
|
||||||
|
- `sidecar_heartbeat_age_seconds{package, scope}` — gauge.
|
||||||
|
- `sidecar_active{package, scope}` — gauge.
|
||||||
|
- `sidecar_user_count` — gauge (number of connected user sidecars).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## User Sidecar Use Cases
|
||||||
|
|
||||||
|
### Local Bridge (Desktop Commander Pattern)
|
||||||
|
|
||||||
|
A thin agent on the user's laptop that exposes local capabilities:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"capabilities": [
|
||||||
|
{
|
||||||
|
"name": "fs.search",
|
||||||
|
"description": "Search local filesystem by filename or content",
|
||||||
|
"input_schema": {
|
||||||
|
"properties": {
|
||||||
|
"query": { "type": "string" },
|
||||||
|
"path": { "type": "string", "default": "~" },
|
||||||
|
"content_search": { "type": "boolean", "default": false }
|
||||||
|
},
|
||||||
|
"required": ["query"]
|
||||||
|
},
|
||||||
|
"timeout_seconds": 15
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "fs.read",
|
||||||
|
"description": "Read a local file",
|
||||||
|
"input_schema": {
|
||||||
|
"properties": {
|
||||||
|
"path": { "type": "string" },
|
||||||
|
"max_bytes": { "type": "integer", "default": 1048576 }
|
||||||
|
},
|
||||||
|
"required": ["path"]
|
||||||
|
},
|
||||||
|
"timeout_seconds": 10
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "terminal.exec",
|
||||||
|
"description": "Execute a shell command",
|
||||||
|
"input_schema": {
|
||||||
|
"properties": {
|
||||||
|
"command": { "type": "string" },
|
||||||
|
"cwd": { "type": "string", "default": "~" },
|
||||||
|
"timeout": { "type": "integer", "default": 30 }
|
||||||
|
},
|
||||||
|
"required": ["command"]
|
||||||
|
},
|
||||||
|
"timeout_seconds": 60
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "clipboard.get",
|
||||||
|
"description": "Read clipboard contents",
|
||||||
|
"input_schema": {},
|
||||||
|
"timeout_seconds": 5
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Tool meta-tool flow:**
|
||||||
|
|
||||||
|
```
|
||||||
|
User: "Hey Max, find the database migration script I was
|
||||||
|
working on yesterday and add it to my notes"
|
||||||
|
|
||||||
|
llm-bridge assembles (for this user):
|
||||||
|
Layer 5 tools: [...instance tools..., user-bridge.fs.search,
|
||||||
|
user-bridge.fs.read, notes.create, ...]
|
||||||
|
|
||||||
|
LLM:
|
||||||
|
1. Tool: user-bridge.fs.search({query: "migration", path: "~/code"})
|
||||||
|
2. Result: [{path: "~/code/armature/migrations/015_sidecar.sql", ...}]
|
||||||
|
3. Tool: user-bridge.fs.read({path: "~/code/armature/migrations/015_sidecar.sql"})
|
||||||
|
4. Result: {content: "CREATE TABLE IF NOT EXISTS sidecar_registry..."}
|
||||||
|
5. Tool: notes.create({title: "Migration 015 - Sidecar Registry",
|
||||||
|
body: "```sql\nCREATE TABLE..."})
|
||||||
|
6. Text: "Found your sidecar migration script and saved it to Notes."
|
||||||
|
```
|
||||||
|
|
||||||
|
Three extensions orchestrated: user's local filesystem, instance-level
|
||||||
|
notes, all through the same tool meta-tool pattern.
|
||||||
|
|
||||||
|
### Personal Compute (Local Ollama)
|
||||||
|
|
||||||
|
User runs Ollama on their workstation with a GPU:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"capabilities": [
|
||||||
|
{
|
||||||
|
"name": "complete",
|
||||||
|
"description": "LLM completion via local Ollama",
|
||||||
|
"input_schema": {
|
||||||
|
"properties": {
|
||||||
|
"prompt": { "type": "string" },
|
||||||
|
"model": { "type": "string", "default": "llama3" }
|
||||||
|
},
|
||||||
|
"required": ["prompt"]
|
||||||
|
},
|
||||||
|
"timeout_seconds": 120
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "embed",
|
||||||
|
"description": "Local embedding via Ollama",
|
||||||
|
"input_schema": {
|
||||||
|
"properties": {
|
||||||
|
"text": { "type": "string" },
|
||||||
|
"model": { "type": "string", "default": "nomic-embed-text" }
|
||||||
|
},
|
||||||
|
"required": ["text"]
|
||||||
|
},
|
||||||
|
"timeout_seconds": 30
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`llm-bridge` can be configured (per-user setting or persona preference)
|
||||||
|
to route completions through the user's local Ollama instead of the
|
||||||
|
instance-level provider. BYOK taken to the extreme — bring your own
|
||||||
|
hardware.
|
||||||
|
|
||||||
|
### Personal Automation
|
||||||
|
|
||||||
|
A script that reacts to Armature events:
|
||||||
|
|
||||||
|
```python
|
||||||
|
# Personal deploy watcher — connects to Armature, subscribes to
|
||||||
|
# events in the #deploys conversation, runs kubectl locally
|
||||||
|
|
||||||
|
ws = connect(f'{KERNEL_URL}/api/v1/sidecar/subscribe',
|
||||||
|
token=TOKEN)
|
||||||
|
|
||||||
|
for event in ws:
|
||||||
|
if event['type'] == 'chat.message' and 'deploy' in event['content']:
|
||||||
|
result = subprocess.run(['kubectl', 'get', 'pods', '-n', 'prod'],
|
||||||
|
capture_output=True, text=True)
|
||||||
|
# Post result back to chat
|
||||||
|
requests.post(f'{KERNEL_URL}/api/v1/sidecar/lib/chat-core/send',
|
||||||
|
json={'conversation_id': event['conversation_id'],
|
||||||
|
'content': f'```\n{result.stdout}\n```'},
|
||||||
|
headers={'Authorization': f'Bearer {TOKEN}'})
|
||||||
|
```
|
||||||
|
|
||||||
|
Personal workflow glue. No instance-level deployment required.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## RBAC for User Sidecars
|
||||||
|
|
||||||
|
Three-layer permission model:
|
||||||
|
|
||||||
|
### Layer 1: Admin Controls WHO
|
||||||
|
|
||||||
|
New permission: `sidecar.connect_personal`.
|
||||||
|
|
||||||
|
Admin grants to specific users, groups, or roles. Most users don't
|
||||||
|
need it. Developers, power users, AI-heavy workflows — they get the
|
||||||
|
permission.
|
||||||
|
|
||||||
|
Default: not granted. Opt-in only.
|
||||||
|
|
||||||
|
### Layer 2: Admin Controls WHAT
|
||||||
|
|
||||||
|
**Capability allowlist for user sidecars.**
|
||||||
|
|
||||||
|
Admin setting: `SIDECAR_USER_ALLOWED_CAPABILITIES`
|
||||||
|
|
||||||
|
Default: `["fs.search", "fs.read", "clipboard.get"]` — safe read-only
|
||||||
|
operations.
|
||||||
|
|
||||||
|
To enable terminal access: admin adds `"terminal.exec"` to the
|
||||||
|
allowlist. This is a deliberate escalation requiring admin action.
|
||||||
|
|
||||||
|
A user sidecar that attempts to register a capability not on the
|
||||||
|
allowlist gets a clear error: "Capability 'terminal.exec' not permitted
|
||||||
|
for user sidecars. Contact your administrator."
|
||||||
|
|
||||||
|
### Layer 3: User Controls VISIBILITY
|
||||||
|
|
||||||
|
User sidecar capabilities are **private by default** — only the owning
|
||||||
|
user's requests invoke them.
|
||||||
|
|
||||||
|
Optional: user can share a specific capability with their team via the
|
||||||
|
settings UI. Shared capabilities appear in team members' tool sets
|
||||||
|
(with attribution: "via Jeff's local bridge").
|
||||||
|
|
||||||
|
User sidecars are never instance-wide. That's what instance sidecars
|
||||||
|
are for.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Security Considerations
|
||||||
|
|
||||||
|
### Instance Sidecars
|
||||||
|
|
||||||
|
1. **Compromised binary** → can read/write own package data, call
|
||||||
|
exported functions. Mitigation: package-scoped access, audit logging,
|
||||||
|
token revocation.
|
||||||
|
2. **Token theft** → impersonate sidecar. Mitigation: package-scoped
|
||||||
|
tokens, mTLS eliminates theft, revocable.
|
||||||
|
3. **SSRF via sidecar.call** → Mitigation: endpoints validated at
|
||||||
|
registration, no runtime changes.
|
||||||
|
4. **Resource exhaustion** → Mitigation: per-sidecar concurrency and
|
||||||
|
rate limits, timeout enforcement, response size limits.
|
||||||
|
5. **Privilege escalation via lib.require** → Mitigation: calls
|
||||||
|
attributed to sidecar identity, target permission checks apply.
|
||||||
|
|
||||||
|
### User Sidecars (Additional Concerns)
|
||||||
|
|
||||||
|
6. **User deploys malicious sidecar** → registers capabilities that
|
||||||
|
exfiltrate data when called. Mitigation: capability allowlist (admin
|
||||||
|
controls what capabilities can be declared), private-by-default
|
||||||
|
(only the user's own requests trigger it — they're exfiltrating
|
||||||
|
from themselves).
|
||||||
|
|
||||||
|
7. **User shares malicious capability with team** → team members
|
||||||
|
invoke it, sidecar captures their request data. Mitigation: shared
|
||||||
|
capabilities are clearly attributed ("via Jeff's local bridge").
|
||||||
|
Admin can disable capability sharing entirely. Shared capabilities
|
||||||
|
run with the CALLING user's permissions, not the sidecar owner's.
|
||||||
|
|
||||||
|
8. **User sidecar as pivot** → user's laptop compromised, attacker uses
|
||||||
|
connected sidecar to access Armature data. Mitigation: user sidecar
|
||||||
|
can only access data the user could already access through the UI.
|
||||||
|
No privilege escalation. User can revoke own tokens. Admin kill
|
||||||
|
switch.
|
||||||
|
|
||||||
|
9. **Stale user sidecars** → user's laptop goes offline, sidecar
|
||||||
|
becomes stale. Mitigation: same sweep mechanism as instance
|
||||||
|
sidecars. 3× heartbeat interval = swept. User reconnects when
|
||||||
|
laptop comes back online.
|
||||||
|
|
||||||
|
### Principle of Least Privilege
|
||||||
|
|
||||||
|
- Instance sidecar: access own package data only, middleware-enforced.
|
||||||
|
- User sidecar: access own package data scoped to owning user only.
|
||||||
|
- Event emission scoped to package prefix.
|
||||||
|
- Capability allowlist for user sidecars (admin-controlled).
|
||||||
|
- Shared capabilities run with caller's permissions.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Version Plan
|
||||||
|
|
||||||
|
### v0.14.0 — Sidecar Registry + Auth
|
||||||
|
|
||||||
|
**Goal:** Sidecars can register, authenticate, and heartbeat.
|
||||||
|
|
||||||
|
- Sidecar registry table with `scope` and `user_id` columns.
|
||||||
|
- Registration endpoint with JWT token validation.
|
||||||
|
- Heartbeat + sweep integration.
|
||||||
|
- Token generation (instance tokens via admin, user tokens via settings).
|
||||||
|
- `SIDECAR_AUTH_MODE` config: `token` or `mtls`.
|
||||||
|
- mTLS auth: `sidecar:` and `sidecar-user:` CN prefixes.
|
||||||
|
- Admin UI: sidecar health on packages page.
|
||||||
|
|
||||||
|
**Instance sidecars only in this version.** User sidecar plumbing
|
||||||
|
exists in the schema but user token generation and RBAC gating land
|
||||||
|
in v0.14.5.
|
||||||
|
|
||||||
|
### v0.14.1 — Capability Registration + Execution
|
||||||
|
|
||||||
|
**Goal:** Sidecars declare capabilities. Starlark extensions call them.
|
||||||
|
|
||||||
|
- Capability manifest in registration payload.
|
||||||
|
- `sidecar` Starlark module: `call()`, `available()`, `capabilities()`.
|
||||||
|
- Kernel proxy: `sidecar.call()` → `POST {endpoint}/exec/{cap}`.
|
||||||
|
- Input validation against declared schema.
|
||||||
|
- Timeout, response size, concurrent call limits.
|
||||||
|
- Error propagation.
|
||||||
|
- `sidecar.call` permission.
|
||||||
|
- Audit logging.
|
||||||
|
|
||||||
|
### v0.14.2 — Kernel API Access + Event Bus
|
||||||
|
|
||||||
|
**Goal:** Sidecars read/write their own data and participate in events.
|
||||||
|
|
||||||
|
- Sidecar middleware: authenticate, scope to own package routes.
|
||||||
|
- `/api/v1/sidecar/lib/{pkg}/{fn}` — cross-package function calls.
|
||||||
|
- `/api/v1/sidecar/emit` — event emission (package-scoped).
|
||||||
|
- `/api/v1/sidecar/subscribe` — WebSocket event subscription.
|
||||||
|
- Frontend `sw.sidecar` SDK module.
|
||||||
|
|
||||||
|
### v0.14.3 — Manifest Integration + Admin Polish
|
||||||
|
|
||||||
|
**Goal:** Sidecar packages are first-class in install/admin flow.
|
||||||
|
|
||||||
|
- `tier: "sidecar"` manifest support.
|
||||||
|
- `sidecar` manifest field (image, required, scope, env_hints,
|
||||||
|
download_url).
|
||||||
|
- Admin UI: deployment instructions from manifest.
|
||||||
|
- Admin UI: capability list with metrics.
|
||||||
|
- Admin UI: per-sidecar rate limit configuration.
|
||||||
|
- Token management UI.
|
||||||
|
- Monitoring metrics.
|
||||||
|
|
||||||
|
### v0.14.4 — Reference Sidecar + Instance Quality Gate
|
||||||
|
|
||||||
|
**Goal:** Ship one real instance sidecar. Prove the contract.
|
||||||
|
|
||||||
|
**`armature-embed`** — minimal Python sidecar running
|
||||||
|
sentence-transformers for local embedding generation. One capability:
|
||||||
|
`embed(text) → float[]`.
|
||||||
|
|
||||||
|
`vector-store` calls `sidecar.call('armature-embed', 'embed', {...})`
|
||||||
|
for local embedding without an external API. Completes the local-first
|
||||||
|
RAG story.
|
||||||
|
|
||||||
|
**Instance sidecar quality gate:**
|
||||||
|
|
||||||
|
- Registration, heartbeat, sweep lifecycle tested.
|
||||||
|
- Token and mTLS auth both functional.
|
||||||
|
- Capability execution with limits enforced.
|
||||||
|
- Package-scoped API access enforced.
|
||||||
|
- Event emission/subscription functional.
|
||||||
|
- `sw.sidecar` frontend module functional.
|
||||||
|
- Reference sidecar runs in Docker and K8s.
|
||||||
|
- Admin UI complete.
|
||||||
|
- Monitoring metrics in Grafana.
|
||||||
|
|
||||||
|
### v0.14.5 — User Sidecars
|
||||||
|
|
||||||
|
**Goal:** Users connect personal processes to Armature.
|
||||||
|
|
||||||
|
- `sidecar.connect_personal` permission (admin-granted).
|
||||||
|
- User token generation in Settings → Sidecars.
|
||||||
|
- Capability allowlist for user sidecars (admin setting:
|
||||||
|
`SIDECAR_USER_ALLOWED_CAPABILITIES`).
|
||||||
|
- User sidecar registration with `scope: "user"`, `user_id` binding.
|
||||||
|
- Execution routing: `sidecar.call()` resolves user sidecars based
|
||||||
|
on request context (calling user gets their own sidecar).
|
||||||
|
- Capability sharing: user can share specific capabilities with team.
|
||||||
|
Shared caps attributed ("via Jeff's local bridge").
|
||||||
|
- Separate resource limits for user sidecars (lower defaults).
|
||||||
|
- User settings UI: connected sidecars, token management, sidecar
|
||||||
|
instructions from package manifests.
|
||||||
|
- Admin UI: user sidecar monitoring, kill switch.
|
||||||
|
- Tool meta-tool integration: `llm-bridge` includes user's personal
|
||||||
|
sidecar capabilities in tool set assembly.
|
||||||
|
|
||||||
|
**Reference user sidecar: `user-bridge`** — thin agent binary with
|
||||||
|
`fs.search`, `fs.read`, `clipboard.get` capabilities. Published as a
|
||||||
|
package with `sidecar.scope: "user"`. Binary as a GitHub release.
|
||||||
|
|
||||||
|
**User sidecar quality gate:**
|
||||||
|
|
||||||
|
- RBAC: user without `sidecar.connect_personal` cannot register.
|
||||||
|
- Capability allowlist enforced.
|
||||||
|
- User A cannot invoke user B's sidecar.
|
||||||
|
- Shared capabilities run with caller's permissions.
|
||||||
|
- Admin can revoke any user sidecar.
|
||||||
|
- Tool meta-tool assembles per-user tool sets correctly.
|
||||||
|
- `user-bridge` reference sidecar runs on macOS, Linux, Windows.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Schema Summary
|
||||||
|
|
||||||
|
### New tables
|
||||||
|
|
||||||
|
- `sidecar_registry` — sidecar_id, package_id, scope, user_id,
|
||||||
|
endpoint, heartbeat, capabilities (JSON), stats (JSON),
|
||||||
|
registered_at. (v0.14.0)
|
||||||
|
- `sidecar_tokens` — id, package_id, scope, user_id, token_hash,
|
||||||
|
created_by, expires_at, revoked_at. (v0.14.0)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
| Setting | Default | Purpose |
|
||||||
|
|---------|---------|---------|
|
||||||
|
| `SIDECAR_AUTH_MODE` | `token` | `token` or `mtls` |
|
||||||
|
| `SIDECAR_HEARTBEAT_INTERVAL` | `10s` | Expected heartbeat frequency |
|
||||||
|
| `SIDECAR_STALE_THRESHOLD` | `30s` | 3× heartbeat = stale |
|
||||||
|
| `SIDECAR_MAX_RESPONSE_SIZE` | `10485760` (10MB) | Instance default |
|
||||||
|
| `SIDECAR_DEFAULT_CONCURRENCY` | `10` | Instance default |
|
||||||
|
| `SIDECAR_DEFAULT_RATE_LIMIT` | `100/min` | Instance default |
|
||||||
|
| `SIDECAR_USER_MAX_RESPONSE_SIZE` | `5242880` (5MB) | User default |
|
||||||
|
| `SIDECAR_USER_CONCURRENCY` | `3` | User default |
|
||||||
|
| `SIDECAR_USER_RATE_LIMIT` | `30/min` | User default |
|
||||||
|
| `SIDECAR_USER_ALLOWED_CAPABILITIES` | `["fs.search","fs.read","clipboard.get"]` | Admin-controlled allowlist |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Sidecar Author Contract
|
||||||
|
|
||||||
|
### Minimum Viable Sidecar (Python)
|
||||||
|
|
||||||
|
```python
|
||||||
|
from flask import Flask, request, jsonify
|
||||||
|
import requests, os, threading, time
|
||||||
|
|
||||||
|
app = Flask(__name__)
|
||||||
|
KERNEL = os.environ['ARMATURE_URL']
|
||||||
|
TOKEN = os.environ['ARMATURE_TOKEN']
|
||||||
|
|
||||||
|
def register():
|
||||||
|
requests.post(f'{KERNEL}/api/v1/sidecar/register',
|
||||||
|
json={
|
||||||
|
'package_id': 'my-sidecar',
|
||||||
|
'endpoint': f'http://localhost:8080',
|
||||||
|
'capabilities': [{
|
||||||
|
'name': 'echo',
|
||||||
|
'description': 'Echo input back',
|
||||||
|
'input_schema': {
|
||||||
|
'type': 'object',
|
||||||
|
'properties': {'text': {'type': 'string'}},
|
||||||
|
'required': ['text']
|
||||||
|
},
|
||||||
|
'timeout_seconds': 10
|
||||||
|
}]
|
||||||
|
},
|
||||||
|
headers={'Authorization': f'Bearer {TOKEN}'})
|
||||||
|
|
||||||
|
def heartbeat():
|
||||||
|
while True:
|
||||||
|
time.sleep(10)
|
||||||
|
r = requests.post(f'{KERNEL}/api/v1/sidecar/heartbeat',
|
||||||
|
json={'stats': {}},
|
||||||
|
headers={'Authorization': f'Bearer {TOKEN}'})
|
||||||
|
if r.status_code == 404:
|
||||||
|
register()
|
||||||
|
|
||||||
|
@app.route('/api/v1/exec/echo', methods=['POST'])
|
||||||
|
def exec_echo():
|
||||||
|
return jsonify({
|
||||||
|
'output': {'text': request.json['input']['text']},
|
||||||
|
'error': None
|
||||||
|
})
|
||||||
|
|
||||||
|
@app.route('/api/v1/health')
|
||||||
|
def health():
|
||||||
|
return jsonify({'status': 'ok'})
|
||||||
|
|
||||||
|
register()
|
||||||
|
threading.Thread(target=heartbeat, daemon=True).start()
|
||||||
|
app.run(port=8080)
|
||||||
|
```
|
||||||
|
|
||||||
|
Four things: register, heartbeat, `/exec/`, `/health`. That's the
|
||||||
|
contract.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Design Decisions
|
||||||
|
|
||||||
|
| Decision | Rationale |
|
||||||
|
|----------|-----------|
|
||||||
|
| Connect-inward | Eliminates K8s RBAC, service mesh, DNS discovery. Works on any deployment target. |
|
||||||
|
| Two auth modes | Token for simple. mTLS for production. Same internal identity. |
|
||||||
|
| Cluster registry pattern | Proven heartbeat/sweep. No new coordination mechanism. |
|
||||||
|
| HTTP/JSON, no gRPC | KISS. 4-endpoint contract. Every language has HTTP. |
|
||||||
|
| Kernel proxies all calls | Auth, rate limits, audit, timeouts at single control point. |
|
||||||
|
| Package-scoped access | Compromised sidecar can't read other packages. |
|
||||||
|
| User sidecars as v0.14.5 | Prove instance contract first (v0.14.0–v0.14.4), then extend to users. Same mechanism, new scope. |
|
||||||
|
| Three-layer user RBAC | Admin controls who (permission), what (capability allowlist), user controls visibility (private/shared). Defense in depth. |
|
||||||
|
| Capability allowlist for user sidecars | Admin decides what user processes can expose. `terminal.exec` is a deliberate escalation. |
|
||||||
|
| Private-by-default user capabilities | User sidecar can't affect other users unless explicitly shared. |
|
||||||
|
| Shared caps run with caller's perms | Prevents privilege escalation through shared capabilities. |
|
||||||
|
| Reference instance sidecar: embedding | Simpler than LLM. Proves contract. Completes local RAG story. |
|
||||||
|
| Reference user sidecar: file bridge | Most compelling user sidecar demo. "Find a file on my laptop and save it to notes" is visceral. |
|
||||||
|
| Lower resource limits for user sidecars | User sidecars are less trusted (running on personal devices). Lower defaults, admin can't be overridden by users. |
|
||||||
|
| Separate from polish (v0.15.x) | Sidecar is security-critical infrastructure. Polish is quality-of-life. Don't mix. |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Future Considerations
|
||||||
|
|
||||||
|
- **Streaming responses.** LLM sidecar needs token streaming. SSE or
|
||||||
|
chunked transfer. Deferred — batch covers embedding, classification,
|
||||||
|
transcoding.
|
||||||
|
- **SDK libraries.** Thin wrappers in Python, Go, Node, Rust for
|
||||||
|
registration + heartbeat + parsing. Convenience, not requirement.
|
||||||
|
- **Auto-scaling.** Multiple instances of same sidecar, kernel
|
||||||
|
load-balances. No schema change needed — registry supports multiple
|
||||||
|
rows per package.
|
||||||
|
- **GPU detection.** Kernel capability for manifest negotiation.
|
||||||
|
- **Sidecar-to-sidecar.** Direct calls for performance pipelines.
|
||||||
|
Currently goes through kernel.
|
||||||
|
- **User sidecar desktop app.** Electron/Tauri tray app that wraps the
|
||||||
|
user-bridge agent with a GUI for capability management and connection
|
||||||
|
status. Post-1.0.
|
||||||
@@ -248,6 +248,44 @@ Every surface uses one of three patterns:
|
|||||||
The shell provides the home link, notification bell, and user menu
|
The shell provides the home link, notification bell, and user menu
|
||||||
on every surface for free.
|
on every surface for free.
|
||||||
|
|
||||||
|
## `sw.forms` — Typed Forms (v0.9.5)
|
||||||
|
|
||||||
|
Any extension can render and validate typed forms using the `sw.forms` module.
|
||||||
|
|
||||||
|
### `sw.forms.render(container, template, opts)`
|
||||||
|
|
||||||
|
Renders a typed form into a DOM container. Supports flat forms and progressive
|
||||||
|
multi-step forms (fieldsets). Returns a control handle.
|
||||||
|
|
||||||
|
```js
|
||||||
|
const handle = sw.forms.render(document.getElementById('my-form'), template, {
|
||||||
|
values: { name: 'prefilled' },
|
||||||
|
onSubmit: (data) => { console.log('submitted', data); },
|
||||||
|
});
|
||||||
|
|
||||||
|
// Programmatic access
|
||||||
|
const data = handle.getData();
|
||||||
|
handle.setErrors([{ key: 'name', message: 'Name is taken' }]);
|
||||||
|
handle.destroy();
|
||||||
|
```
|
||||||
|
|
||||||
|
### `sw.forms.validate(template, data)`
|
||||||
|
|
||||||
|
Client-side validation (no network call). Returns `{ valid, errors }`.
|
||||||
|
|
||||||
|
```js
|
||||||
|
const { valid, errors } = sw.forms.validate(template, { name: '' });
|
||||||
|
// valid === false, errors === [{ key: 'name', message: 'Name is required' }]
|
||||||
|
```
|
||||||
|
|
||||||
|
### `sw.forms.validateRemote(template, data)`
|
||||||
|
|
||||||
|
Server-side validation via `POST /api/v1/forms/validate`. Returns a Promise.
|
||||||
|
|
||||||
|
```js
|
||||||
|
const result = await sw.forms.validateRemote(template, data);
|
||||||
|
```
|
||||||
|
|
||||||
## Extension CSS contract
|
## Extension CSS contract
|
||||||
|
|
||||||
Extensions must prefix all CSS classes with `.ext-{slug}-` to avoid
|
Extensions must prefix all CSS classes with `.ext-{slug}-` to avoid
|
||||||
|
|||||||
201
docs/MULTI-SURFACE-GUIDE.md
Normal file
201
docs/MULTI-SURFACE-GUIDE.md
Normal file
@@ -0,0 +1,201 @@
|
|||||||
|
# Multi-Surface Packages
|
||||||
|
|
||||||
|
A multi-surface package serves multiple pages from a single package, each
|
||||||
|
with its own URL path, access level, and layout. Before v0.9.0, a package
|
||||||
|
got one route (`/s/{id}`), one auth posture, and one layout. Now a single
|
||||||
|
package can serve a public submission form alongside an authenticated
|
||||||
|
dashboard and an admin settings page.
|
||||||
|
|
||||||
|
## When to Use Multi-Surface
|
||||||
|
|
||||||
|
Use multi-surface when your package has logically related pages that share
|
||||||
|
the same backend (Starlark hooks, ext API, database tables) but need:
|
||||||
|
|
||||||
|
- **Different access levels** — public intake form + authenticated dashboard
|
||||||
|
- **Multiple views** — list view, detail view, edit view, monitor view
|
||||||
|
- **Sub-pages** — settings, admin, or debug pages within the package
|
||||||
|
|
||||||
|
If your pages don't share backend state, use separate packages instead.
|
||||||
|
|
||||||
|
## Manifest Setup
|
||||||
|
|
||||||
|
Add a `surfaces` array to your manifest. Each entry declares a page:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": "workflow-builder",
|
||||||
|
"title": "Workflow Builder",
|
||||||
|
"type": "full",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"icon": "🔧",
|
||||||
|
"auth": "authenticated",
|
||||||
|
"layout": "single",
|
||||||
|
|
||||||
|
"surfaces": [
|
||||||
|
{ "path": "/", "title": "Workflows" },
|
||||||
|
{ "path": "/new", "title": "New Workflow", "nav": false },
|
||||||
|
{ "path": "/:id/edit", "title": "Edit Workflow", "nav": false },
|
||||||
|
{ "path": "/monitor", "title": "Monitor", "nav": true },
|
||||||
|
{ "path": "/monitor/:id", "title": "Instance Detail", "nav": false }
|
||||||
|
],
|
||||||
|
|
||||||
|
"hooks": ["surface"],
|
||||||
|
"permissions": ["workflow.access"]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
This generates five server routes, all under `/s/workflow-builder/`:
|
||||||
|
|
||||||
|
| Route | Access | Nav |
|
||||||
|
|-------|--------|-----|
|
||||||
|
| `/s/workflow-builder/` | authenticated | yes |
|
||||||
|
| `/s/workflow-builder/new` | authenticated | no |
|
||||||
|
| `/s/workflow-builder/:id/edit` | authenticated | no |
|
||||||
|
| `/s/workflow-builder/monitor` | authenticated | yes |
|
||||||
|
| `/s/workflow-builder/monitor/:id` | authenticated | no |
|
||||||
|
|
||||||
|
### Surface Entry Fields
|
||||||
|
|
||||||
|
| Field | Default | Description |
|
||||||
|
|----------|----------------|-------------|
|
||||||
|
| `path` | **required** | URL path relative to `/s/{id}`. Supports `:param` segments. |
|
||||||
|
| `access` | package `auth` | `public`, `authenticated`, `admin`, or `group:{name}`. |
|
||||||
|
| `title` | package `title`| Label shown in nav and page title. |
|
||||||
|
| `layout` | package `layout`| `single` or `editor`. |
|
||||||
|
| `nav` | `true` for `/`, `false` otherwise | Whether this surface appears in the sidebar. |
|
||||||
|
|
||||||
|
### Mixed Access Levels
|
||||||
|
|
||||||
|
A package can mix public and authenticated surfaces:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": "bug-tracker",
|
||||||
|
"auth": "authenticated",
|
||||||
|
"surfaces": [
|
||||||
|
{ "path": "/submit", "access": "public", "title": "Report a Bug" },
|
||||||
|
{ "path": "/", "title": "Dashboard" },
|
||||||
|
{ "path": "/:id", "title": "Bug Detail", "nav": false },
|
||||||
|
{ "path": "/admin", "access": "admin", "title": "Settings", "nav": false }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The kernel enforces access per-surface. Unauthenticated visitors can reach
|
||||||
|
`/submit` but are redirected to login if they try `/` or `/:id`.
|
||||||
|
|
||||||
|
## Frontend: Routing Within Your Package
|
||||||
|
|
||||||
|
### Reading the Current Surface
|
||||||
|
|
||||||
|
When your package JS loads, two globals tell you which surface was matched:
|
||||||
|
|
||||||
|
```js
|
||||||
|
const path = window.__SURFACE_PATH__ || '/';
|
||||||
|
const params = window.__SURFACE_PARAMS__ || {};
|
||||||
|
```
|
||||||
|
|
||||||
|
Use these to decide which view to render:
|
||||||
|
|
||||||
|
```js
|
||||||
|
function render() {
|
||||||
|
const path = window.__SURFACE_PATH__ || '/';
|
||||||
|
const params = window.__SURFACE_PARAMS__ || {};
|
||||||
|
|
||||||
|
const root = document.getElementById('surface-root');
|
||||||
|
|
||||||
|
switch (path) {
|
||||||
|
case '/': return renderList(root);
|
||||||
|
case '/new': return renderEditor(root, null);
|
||||||
|
case '/:id/edit': return renderEditor(root, params.id);
|
||||||
|
case '/monitor': return renderMonitor(root);
|
||||||
|
case '/monitor/:id':return renderDetail(root, params.id);
|
||||||
|
default: return render404(root);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
render();
|
||||||
|
```
|
||||||
|
|
||||||
|
### SPA Navigation with `sw.navigate()`
|
||||||
|
|
||||||
|
Navigate between surfaces without a full page reload:
|
||||||
|
|
||||||
|
```js
|
||||||
|
// Navigate to a static path
|
||||||
|
sw.navigate('/new');
|
||||||
|
|
||||||
|
// Navigate with params
|
||||||
|
sw.navigate('/:id/edit', { id: 'wf-42' });
|
||||||
|
|
||||||
|
// Navigate to monitor sub-page
|
||||||
|
sw.navigate('/monitor/:id', { id: 'inst-7' });
|
||||||
|
```
|
||||||
|
|
||||||
|
`sw.navigate()` does three things:
|
||||||
|
1. Updates `window.__SURFACE_PATH__` and `window.__SURFACE_PARAMS__`
|
||||||
|
2. Calls `history.pushState()` to update the URL
|
||||||
|
3. Emits a `surface.navigate` event
|
||||||
|
|
||||||
|
### Listening for Navigation Events
|
||||||
|
|
||||||
|
Re-render when the user navigates (including browser back/forward):
|
||||||
|
|
||||||
|
```js
|
||||||
|
sw.on('surface.navigate', ({ path, params }) => {
|
||||||
|
window.__SURFACE_PATH__ = path;
|
||||||
|
window.__SURFACE_PARAMS__ = params;
|
||||||
|
render();
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Links Between Surfaces
|
||||||
|
|
||||||
|
For simple `<a>` links that do full page loads:
|
||||||
|
|
||||||
|
```html
|
||||||
|
<a href="/s/workflow-builder/monitor">Monitor</a>
|
||||||
|
```
|
||||||
|
|
||||||
|
For SPA-style navigation:
|
||||||
|
|
||||||
|
```js
|
||||||
|
button.onclick = () => sw.navigate('/monitor');
|
||||||
|
```
|
||||||
|
|
||||||
|
## API Routes
|
||||||
|
|
||||||
|
All surfaces in a package share the same ext API. API calls go through
|
||||||
|
`/s/{id}/api/*` regardless of which surface is active:
|
||||||
|
|
||||||
|
```js
|
||||||
|
// These work from any surface in the package
|
||||||
|
const items = await sw.api.get('/items');
|
||||||
|
const item = await sw.api.get(`/items/${id}`);
|
||||||
|
await sw.api.post('/items', { title: 'New item' });
|
||||||
|
```
|
||||||
|
|
||||||
|
The ext API handler checks `package.status == "active"` — if the package
|
||||||
|
is in `pending_review`, all API calls return 403.
|
||||||
|
|
||||||
|
## Backward Compatibility
|
||||||
|
|
||||||
|
Packages without a `surfaces` array continue to work. The kernel
|
||||||
|
synthesizes a single-entry array from the legacy `auth` and `layout`
|
||||||
|
fields:
|
||||||
|
|
||||||
|
```
|
||||||
|
auth: "authenticated" + layout: "single"
|
||||||
|
→ surfaces: [{ "path": "/", "access": "authenticated", "layout": "single" }]
|
||||||
|
```
|
||||||
|
|
||||||
|
No migration is required for existing packages.
|
||||||
|
|
||||||
|
## Constraints
|
||||||
|
|
||||||
|
- **No cross-package routing.** Surface paths are relative to the package
|
||||||
|
mount point. A package cannot claim arbitrary top-level routes.
|
||||||
|
- **No per-surface Starlark hooks.** All surfaces share the same backend
|
||||||
|
hooks. Use the surface path in your hook logic to differentiate.
|
||||||
|
- **No SSR.** Surface rendering is client-side. The kernel serves the
|
||||||
|
shell template; your JS renders the content.
|
||||||
@@ -60,9 +60,10 @@ Only `manifest.json` is required. All other directories are optional and include
|
|||||||
| `description` | Short description |
|
| `description` | Short description |
|
||||||
| `icon` | Emoji for sidebar/menu display |
|
| `icon` | Emoji for sidebar/menu display |
|
||||||
| `author` | Package author |
|
| `author` | Package author |
|
||||||
| `route` | URL path for surfaces (e.g., `/s/my-package`) |
|
| `surfaces` | Array of surface entries with per-path access, title, layout (see below) |
|
||||||
| `auth` | `authenticated` or `public` |
|
| `route` | *(deprecated — use `surfaces`)* URL path for surfaces |
|
||||||
| `layout` | Surface layout mode (e.g., `single`) |
|
| `auth` | Default access level for all surfaces: `authenticated`, `public`, `admin` |
|
||||||
|
| `layout` | Default layout for all surfaces: `single`, `editor` |
|
||||||
| `permissions` | Array of required capabilities (`db.write`, `http`, `notifications`, `secrets`, `realtime.publish`) |
|
| `permissions` | Array of required capabilities (`db.write`, `http`, `notifications`, `secrets`, `realtime.publish`) |
|
||||||
| `api_routes` | Array of `{"method": "GET", "path": "/items"}` |
|
| `api_routes` | Array of `{"method": "GET", "path": "/items"}` |
|
||||||
| `db_tables` | Table definitions with columns and indexes |
|
| `db_tables` | Table definitions with columns and indexes |
|
||||||
@@ -74,6 +75,72 @@ Only `manifest.json` is required. All other directories are optional and include
|
|||||||
| `contributes` | Slot contributions this package injects into other surfaces |
|
| `contributes` | Slot contributions this package injects into other surfaces |
|
||||||
| `capabilities` | Environment requirements: `{"required": [...], "optional": [...]}` |
|
| `capabilities` | Environment requirements: `{"required": [...], "optional": [...]}` |
|
||||||
| `schema_version` | Integer for additive schema migrations |
|
| `schema_version` | Integer for additive schema migrations |
|
||||||
|
| `form_template` | Typed form template (v0.9.5). Fields array or fieldsets for progressive forms. Validated at install. |
|
||||||
|
|
||||||
|
## Multi-Surface Packages
|
||||||
|
|
||||||
|
A package can serve multiple pages, each with its own path, access level,
|
||||||
|
title, and layout. Declare a `surfaces` array in the manifest:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": "bug-tracker",
|
||||||
|
"title": "Bug Tracker",
|
||||||
|
"type": "full",
|
||||||
|
"auth": "authenticated",
|
||||||
|
"layout": "single",
|
||||||
|
"surfaces": [
|
||||||
|
{ "path": "/", "title": "Dashboard" },
|
||||||
|
{ "path": "/submit", "access": "public", "title": "Report a Bug" },
|
||||||
|
{ "path": "/:id", "title": "Bug Detail", "nav": false },
|
||||||
|
{ "path": "/admin", "access": "admin", "title": "Settings", "nav": false }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Surface Entry Fields
|
||||||
|
|
||||||
|
| Field | Type | Default | Description |
|
||||||
|
|----------|--------|----------------|-------------|
|
||||||
|
| `path` | string | **required** | Relative to `/s/{pkg-id}`. Supports `:param` segments. |
|
||||||
|
| `access` | string | package `auth` | `public`, `authenticated`, `admin`, `group:{name}`. |
|
||||||
|
| `title` | string | package `title`| Human label. Used in nav if `nav: true`. |
|
||||||
|
| `layout` | string | package `layout`| `single`, `editor`, or future layouts. |
|
||||||
|
| `nav` | bool | see rules | Show in sidebar navigation. |
|
||||||
|
|
||||||
|
### Nav Visibility Rules
|
||||||
|
|
||||||
|
- `path: "/"` defaults to `nav: true` (primary entry point)
|
||||||
|
- All others default to `nav: false` (sub-pages)
|
||||||
|
- Explicit `"nav": true` overrides — a package can put multiple entries in nav
|
||||||
|
- The sidebar links to the first surface with `nav: true`
|
||||||
|
|
||||||
|
### Backward Compatibility
|
||||||
|
|
||||||
|
If `surfaces` is absent, the kernel synthesizes one entry from the legacy
|
||||||
|
`auth` and `layout` fields. No existing packages break.
|
||||||
|
|
||||||
|
### Client-Side Navigation
|
||||||
|
|
||||||
|
Within a multi-surface package, use `sw.navigate()` for SPA-style routing:
|
||||||
|
|
||||||
|
```js
|
||||||
|
const path = window.__SURFACE_PATH__ || '/';
|
||||||
|
const params = window.__SURFACE_PARAMS__ || {};
|
||||||
|
|
||||||
|
if (path === '/') renderDashboard();
|
||||||
|
if (path === '/submit') renderSubmitForm();
|
||||||
|
if (path === '/:id') renderDetail(params.id);
|
||||||
|
|
||||||
|
// Navigate to another surface within the package
|
||||||
|
sw.navigate('/submit');
|
||||||
|
sw.navigate('/:id', { id: 'bug-42' });
|
||||||
|
|
||||||
|
// Listen for navigation events (including back/forward)
|
||||||
|
sw.on('surface.navigate', ({ path, params }) => {
|
||||||
|
renderView(path, params);
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
## Composability: Slots and Contributions
|
## Composability: Slots and Contributions
|
||||||
|
|
||||||
|
|||||||
171
docs/ROADMAP-v010x-shift.md
Normal file
171
docs/ROADMAP-v010x-shift.md
Normal file
@@ -0,0 +1,171 @@
|
|||||||
|
# ROADMAP — v0.10.x+ Version Shift (Final)
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
Four new series inserted after v0.9.x. Panels (kernel primitive), Notes
|
||||||
|
(first reference extension), Chat (second reference extension), and the
|
||||||
|
remaining reference libraries/extensions including `llm-bridge` with
|
||||||
|
the tool meta-tool pattern.
|
||||||
|
|
||||||
|
## Full Roadmap
|
||||||
|
|
||||||
|
| Series | Title | Versions | Design Doc |
|
||||||
|
|--------|-------|----------|------------|
|
||||||
|
| v0.9.x | Multi-Surface + Workflow Redesign | 10 | — |
|
||||||
|
| **v0.10.x** | **Panels + Composable Layout** | 5 | `DESIGN-panels.md` |
|
||||||
|
| **v0.11.x** | **Notes Reference Extension** | 11 | `DESIGN-notes-v011x.md` |
|
||||||
|
| **v0.12.x** | **Chat Reference Extension** | 9 | `DESIGN-chat-v012x.md` |
|
||||||
|
| **v0.13.x** | **Reference Libraries + Extensions** | 9 | — |
|
||||||
|
| v0.14.x | Sidecar Tier + Polish | 3 | — |
|
||||||
|
| v1.0.0 | Stable Release | — | — |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## v0.10.x — Panels + Composable Layout
|
||||||
|
|
||||||
|
| Version | Title |
|
||||||
|
|---------|-------|
|
||||||
|
| v0.10.0 | Panel Manifest + Lifecycle |
|
||||||
|
| v0.10.1 | FloatingPanel Primitive |
|
||||||
|
| v0.10.2 | Docked Panels + Mode Transitions |
|
||||||
|
| v0.10.3 | Panel Communication Patterns |
|
||||||
|
| v0.10.4 | Reference Panel: Notes (basic) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## v0.11.x — Notes Reference Extension
|
||||||
|
|
||||||
|
| Version | Title |
|
||||||
|
|---------|-------|
|
||||||
|
| v0.11.0 | UI/UX Foundation |
|
||||||
|
| v0.11.1 | Deep Folders + Navigation |
|
||||||
|
| v0.11.2 | Wikilinks + Backlinks |
|
||||||
|
| v0.11.3 | Live Preview + Rich Editing |
|
||||||
|
| v0.11.4 | Note Sharing + Permissions |
|
||||||
|
| v0.11.5 | Graph + Outline Hardening |
|
||||||
|
| v0.11.6 | Quick Switcher + Commands |
|
||||||
|
| v0.11.7 | Daily Notes + Templates |
|
||||||
|
| v0.11.8 | Transclusion + Embeds |
|
||||||
|
| v0.11.9 | Composability: Slots + Actions |
|
||||||
|
| v0.11.10 | Panel Enhancement + Quality Gate |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## v0.12.x — Chat Reference Extension
|
||||||
|
|
||||||
|
Human-to-human first. AI via `llm-bridge` (v0.13.x).
|
||||||
|
|
||||||
|
| Version | Title |
|
||||||
|
|---------|-------|
|
||||||
|
| v0.12.0 | UI/UX Foundation |
|
||||||
|
| v0.12.1 | Conversation Folders + Attributes |
|
||||||
|
| v0.12.2 | Reactions + Threads + Pins |
|
||||||
|
| v0.12.3 | Rich Compose + Attachments |
|
||||||
|
| v0.12.4 | @Mentions + Notifications |
|
||||||
|
| v0.12.5 | Link Previews + Message Formatting |
|
||||||
|
| v0.12.6 | Conversation Themes + Personality |
|
||||||
|
| v0.12.7 | Composability: Slots + Actions |
|
||||||
|
| v0.12.8 | Panels + Quality Gate |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## v0.13.x — Reference Libraries + Extensions
|
||||||
|
|
||||||
|
Core libraries and remaining extensions. `llm-bridge` is the key
|
||||||
|
delivery — extends both notes and chat through composability primitives,
|
||||||
|
and introduces the tool meta-tool pattern.
|
||||||
|
|
||||||
|
| Version | Title |
|
||||||
|
|---------|-------|
|
||||||
|
| v0.13.0 | `vector-store` Library |
|
||||||
|
| v0.13.1 | `llm-bridge` Core Library |
|
||||||
|
| v0.13.2 | `llm-bridge` → Chat: Multi-Persona Context + Tool Meta-Tool |
|
||||||
|
| v0.13.3 | `llm-bridge` → Notes: AI Toolbar Actions |
|
||||||
|
| v0.13.4 | `file-share` Extension |
|
||||||
|
| v0.13.5 | `code-workspace` Extension |
|
||||||
|
| v0.13.6 | `image-gen` + `image-edit` Extensions |
|
||||||
|
| v0.13.7 | Tool Meta-Tool Hardening + Scoping |
|
||||||
|
| v0.13.8 | Integration Quality Gate |
|
||||||
|
|
||||||
|
**v0.13.1 — `llm-bridge` Core:**
|
||||||
|
Model abstraction, provider BYOK via connections, `complete()`,
|
||||||
|
`embed()`, `classify()`. Persona CRUD (name, avatar, system_prompt,
|
||||||
|
visibility). Layered prompt architecture (6 layers: admin safety →
|
||||||
|
folder context → persona identity → conversation → tools → user).
|
||||||
|
Admin safety rails as non-overridable platform setting.
|
||||||
|
|
||||||
|
**v0.13.2 — Chat Integration:**
|
||||||
|
Contributes folder attributes (system_prompt, context_policy,
|
||||||
|
model_override, allowed_tools) to chat. Contributes "Invite AI" /
|
||||||
|
"Dismiss AI" to `chat:participant-actions`. Contributes "AI Reply" /
|
||||||
|
"AI Summarize" to `chat:message-actions`. Contributes "Ask AI" to
|
||||||
|
`chat:composer-tools`. Context archetype implementation (resident /
|
||||||
|
scoped / stateless). Session tracking, gap handling, memory extraction
|
||||||
|
on dismiss. **Tool meta-tool v1:** `sw.actions.list()` → LLM tool
|
||||||
|
definitions. AI calls extension actions, results flow back into chat.
|
||||||
|
|
||||||
|
**v0.13.3 — Notes Integration:**
|
||||||
|
Contributes "AI Summarize" / "AI Translate" / "AI Fix Grammar" to
|
||||||
|
`notes:toolbar-actions`. Contributes `/ai` to `notes:slash-commands`.
|
||||||
|
Uses `notes.get` / `notes.search` actions for context.
|
||||||
|
|
||||||
|
**v0.13.7 — Tool Meta-Tool Hardening:**
|
||||||
|
Three-tier tool scoping: admin blocklist (global), folder allowed_tools
|
||||||
|
(space-level), persona allow/deny (character-level). Tool result
|
||||||
|
rendering through `sw.renderers`. Rate limiting on tool calls. Audit
|
||||||
|
logging of tool use. Error handling (tool failure → graceful message).
|
||||||
|
|
||||||
|
**v0.13.8 — Integration Quality Gate:**
|
||||||
|
AI participant lifecycle tested end-to-end. Tool meta-tool demonstrated
|
||||||
|
with 3+ extensions. All three context archetypes tested. Persona
|
||||||
|
creation/sharing across visibility levels. Admin safety rails validated
|
||||||
|
(prompt injection resistance). Performance: completion latency measured.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## v0.14.x — Sidecar Tier + Polish
|
||||||
|
|
||||||
|
| Version | Title |
|
||||||
|
|---------|-------|
|
||||||
|
| v0.14.0 | Sidecar Tier |
|
||||||
|
| v0.14.1 | Native Dialog Audit |
|
||||||
|
| v0.14.2 | Stability + Migration Tooling |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## v1.0.0 Gate Criteria
|
||||||
|
|
||||||
|
- Notes reference extension shipped (all v0.11.x)
|
||||||
|
- Chat reference extension shipped (all v0.12.x)
|
||||||
|
- `llm-bridge` extends both notes and chat through composability
|
||||||
|
- Tool meta-tool demonstrated: AI uses 3+ extension actions in a
|
||||||
|
single conversation turn
|
||||||
|
- Notes and chat UI reviewed against design principles
|
||||||
|
- At least 2 panels consumed cross-package
|
||||||
|
- At least 2 slot contributions per host surface demonstrated
|
||||||
|
- Multi-persona context archetypes demonstrated
|
||||||
|
- Admin safety rails validated
|
||||||
|
- Note and conversation sharing functional end-to-end
|
||||||
|
- Headless E2E green on PG + SQLite
|
||||||
|
- All kernel Starlark modules documented
|
||||||
|
- All API routes covered by OpenAPI spec
|
||||||
|
- Upgrade path tested from v0.8.0 → v1.0.0
|
||||||
|
- Single-binary + Docker + K8s deployment paths documented
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Design Decisions Log
|
||||||
|
|
||||||
|
| Decision | Rationale |
|
||||||
|
|----------|-----------|
|
||||||
|
| Panels as kernel primitive (v0.10.x) | Z-index coordination, drag/resize, layout negotiation are kernel concerns. |
|
||||||
|
| UI/UX redesign as first version in each reference series | Every feature builds on the visual foundation. |
|
||||||
|
| Notes before chat | Notes is simpler (no realtime) and proves storage/rendering/composability. Chat adds realtime + llm-bridge story. |
|
||||||
|
| Chat human-to-human first | AI is an extension concern. Keeps chat testable and usable standalone. |
|
||||||
|
| Folder attributes as extension bridge | Chat stores attributes it doesn't understand. llm-bridge contributes definitions. Zero coupling. |
|
||||||
|
| Action registry as tool registry (meta-tool) | Dynamic, zero-config AI tool-use. Installed extensions = AI capabilities. Uniquely Armature. |
|
||||||
|
| Layered prompt architecture (6 layers) | Admin safety not overridable. Clear separation: platform → space → character → context → tools → user. |
|
||||||
|
| Personas in llm-bridge, not chat | Chat sees AI as just another participant_type. Persona identity is llm-bridge's concern. |
|
||||||
|
| Background images via CSS filter | No server-side processing. `filter: blur() brightness() saturate()` + `opacity` handles any image. |
|
||||||
|
| Soft panel deps for notes↔chat | Runtime-resolved, no circular dependency. Both function independently. |
|
||||||
|
| llm-bridge after notes + chat | Proves the composability hooks work without being designed for a specific consumer. |
|
||||||
157
docs/ROADMAP-v010x-v100.md
Normal file
157
docs/ROADMAP-v010x-v100.md
Normal file
@@ -0,0 +1,157 @@
|
|||||||
|
# ROADMAP — v0.10.x → v1.0.0 (Final)
|
||||||
|
|
||||||
|
## Full Roadmap
|
||||||
|
|
||||||
|
| Series | Title | Versions | Design Doc |
|
||||||
|
|--------|-------|----------|------------|
|
||||||
|
| v0.9.x | Multi-Surface + Workflow Redesign | 10 | — |
|
||||||
|
| **v0.10.x** | **Panels + Composable Layout** | 5 | `DESIGN-panels.md` |
|
||||||
|
| **v0.11.x** | **Notes Reference Extension** | 11 | `DESIGN-notes-v011x.md` |
|
||||||
|
| **v0.12.x** | **Chat Reference Extension** | 9 | `DESIGN-chat-v012x.md` |
|
||||||
|
| **v0.13.x** | **Reference Libraries + Extensions** | 9 | — |
|
||||||
|
| **v0.14.x** | **Sidecar Tier** | 6 | `DESIGN-sidecar-v014x.md` |
|
||||||
|
| **v0.15.x** | **Polish + Stability** | 3 | — |
|
||||||
|
| **v1.0.0** | **Stable Release** | — | — |
|
||||||
|
|
||||||
|
**Total: ~53 versions from v0.10.0 to v1.0.0**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## v0.10.x — Panels + Composable Layout
|
||||||
|
|
||||||
|
| Version | Title |
|
||||||
|
|---------|-------|
|
||||||
|
| v0.10.0 | Panel Manifest + Lifecycle |
|
||||||
|
| v0.10.1 | FloatingPanel Primitive |
|
||||||
|
| v0.10.2 | Docked Panels + Mode Transitions |
|
||||||
|
| v0.10.3 | Panel Communication Patterns |
|
||||||
|
| v0.10.4 | Reference Panel: Notes (basic) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## v0.11.x — Notes Reference Extension
|
||||||
|
|
||||||
|
| Version | Title |
|
||||||
|
|---------|-------|
|
||||||
|
| v0.11.0 | UI/UX Foundation |
|
||||||
|
| v0.11.1 | Deep Folders + Navigation |
|
||||||
|
| v0.11.2 | Wikilinks + Backlinks |
|
||||||
|
| v0.11.3 | Live Preview + Rich Editing |
|
||||||
|
| v0.11.4 | Note Sharing + Permissions |
|
||||||
|
| v0.11.5 | Graph + Outline Hardening |
|
||||||
|
| v0.11.6 | Quick Switcher + Commands |
|
||||||
|
| v0.11.7 | Daily Notes + Templates |
|
||||||
|
| v0.11.8 | Transclusion + Embeds |
|
||||||
|
| v0.11.9 | Composability: Slots + Actions |
|
||||||
|
| v0.11.10 | Panel Enhancement + Quality Gate |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## v0.12.x — Chat Reference Extension
|
||||||
|
|
||||||
|
| Version | Title |
|
||||||
|
|---------|-------|
|
||||||
|
| v0.12.0 | UI/UX Foundation |
|
||||||
|
| v0.12.1 | Conversation Folders + Attributes |
|
||||||
|
| v0.12.2 | Reactions + Threads + Pins |
|
||||||
|
| v0.12.3 | Rich Compose + Attachments |
|
||||||
|
| v0.12.4 | @Mentions + Notifications |
|
||||||
|
| v0.12.5 | Link Previews + Message Formatting |
|
||||||
|
| v0.12.6 | Conversation Themes + Personality |
|
||||||
|
| v0.12.7 | Composability: Slots + Actions |
|
||||||
|
| v0.12.8 | Panels + Quality Gate |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## v0.13.x — Reference Libraries + Extensions
|
||||||
|
|
||||||
|
| Version | Title |
|
||||||
|
|---------|-------|
|
||||||
|
| v0.13.0 | `vector-store` Library |
|
||||||
|
| v0.13.1 | `llm-bridge` Core Library |
|
||||||
|
| v0.13.2 | `llm-bridge` → Chat: Multi-Persona + Tool Meta-Tool |
|
||||||
|
| v0.13.3 | `llm-bridge` → Notes: AI Toolbar Actions |
|
||||||
|
| v0.13.4 | `file-share` Extension |
|
||||||
|
| v0.13.5 | `code-workspace` Extension |
|
||||||
|
| v0.13.6 | `image-gen` + `image-edit` Extensions |
|
||||||
|
| v0.13.7 | Tool Meta-Tool Hardening + Scoping |
|
||||||
|
| v0.13.8 | Integration Quality Gate |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## v0.14.x — Sidecar Tier
|
||||||
|
|
||||||
|
Connect-inward model. Instance sidecars (shared infrastructure) and
|
||||||
|
user sidecars (personal local tools).
|
||||||
|
|
||||||
|
| Version | Title |
|
||||||
|
|---------|-------|
|
||||||
|
| v0.14.0 | Sidecar Registry + Auth |
|
||||||
|
| v0.14.1 | Capability Registration + Execution |
|
||||||
|
| v0.14.2 | Kernel API Access + Event Bus |
|
||||||
|
| v0.14.3 | Manifest Integration + Admin Polish |
|
||||||
|
| v0.14.4 | Reference Sidecar (`armature-embed`) + Instance Gate |
|
||||||
|
| v0.14.5 | User Sidecars + Reference (`user-bridge`) + User Gate |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## v0.15.x — Polish + Stability
|
||||||
|
|
||||||
|
| Version | Title |
|
||||||
|
|---------|-------|
|
||||||
|
| v0.15.0 | Native Dialog Audit |
|
||||||
|
| v0.15.1 | Versioned Migrations + `armature migrate` CLI |
|
||||||
|
| v0.15.2 | Pre-1.0 Schema Freeze + Upgrade Path Validation |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## v1.0.0 Gate Criteria
|
||||||
|
|
||||||
|
**Reference extensions:**
|
||||||
|
- Notes shipped (all v0.11.x) with UI quality review
|
||||||
|
- Chat shipped (all v0.12.x) with UI quality review
|
||||||
|
- At least 3 reference extensions beyond notes/chat
|
||||||
|
|
||||||
|
**Composability:**
|
||||||
|
- `llm-bridge` extends both notes and chat through slots/actions
|
||||||
|
- Tool meta-tool: AI uses 3+ extension actions in a single turn
|
||||||
|
- At least 2 panels consumed cross-package
|
||||||
|
- At least 2 slot contributions per host surface
|
||||||
|
|
||||||
|
**Sidecar:**
|
||||||
|
- Instance sidecar (`armature-embed`) deployed and functional
|
||||||
|
- User sidecar (`user-bridge`) functional on macOS/Linux/Windows
|
||||||
|
- Token + mTLS auth both tested
|
||||||
|
- User sidecar RBAC enforced (permission, capability allowlist, scoping)
|
||||||
|
- Tool meta-tool includes user sidecar capabilities per-user
|
||||||
|
|
||||||
|
**Infrastructure:**
|
||||||
|
- Admin safety rails validated
|
||||||
|
- Multi-persona context archetypes demonstrated
|
||||||
|
- Sharing functional (notes + conversations)
|
||||||
|
- Headless E2E green on PG + SQLite
|
||||||
|
- All kernel Starlark modules documented
|
||||||
|
- All API routes covered by OpenAPI spec
|
||||||
|
- `armature migrate` CLI functional
|
||||||
|
- Upgrade path tested v0.8.0 → v1.0.0
|
||||||
|
- Single-binary + Docker + K8s deployment paths documented
|
||||||
|
- Monitoring dashboard with kernel + sidecar metrics
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Design Decisions Log
|
||||||
|
|
||||||
|
| Decision | Rationale |
|
||||||
|
|----------|-----------|
|
||||||
|
| Panels as kernel primitive | Z-index, drag, layout are kernel concerns |
|
||||||
|
| UI/UX-first for reference extensions | Every feature builds on visual foundation |
|
||||||
|
| Notes → Chat → Libraries → Sidecar | Each builds on proven patterns from previous |
|
||||||
|
| Chat human-to-human first | AI is llm-bridge's job |
|
||||||
|
| Folder attributes as extension bridge | Zero coupling between chat and llm-bridge |
|
||||||
|
| Action registry as tool registry | Installed extensions = AI capabilities |
|
||||||
|
| 6-layer prompt architecture | Admin safety not overridable |
|
||||||
|
| Sidecar connect-inward | No K8s RBAC, no service mesh, no DNS discovery |
|
||||||
|
| Sidecar HTTP/JSON, not gRPC | KISS. 4-endpoint contract |
|
||||||
|
| User sidecars in v0.14.5 | Prove instance contract first, extend to users with same mechanism |
|
||||||
|
| Three-layer user sidecar RBAC | Admin controls who + what, user controls visibility |
|
||||||
|
| Separate polish (v0.15.x) | Security-critical infra and quality-of-life shouldn't share focus |
|
||||||
@@ -84,6 +84,24 @@ Returns `True` if the user has the permission, `False` otherwise (including
|
|||||||
when the user is not found). Resolves the user's groups and merges granted
|
when the user is not found). Resolves the user's groups and merges granted
|
||||||
permissions — works for both kernel and extension-declared permissions.
|
permissions — works for both kernel and extension-declared permissions.
|
||||||
|
|
||||||
|
### routing
|
||||||
|
|
||||||
|
Generic rule-based decision engine. Evaluates an ordered list of conditions
|
||||||
|
against a data dict, returning the first matching rule's target string.
|
||||||
|
|
||||||
|
```python
|
||||||
|
result = routing.evaluate([
|
||||||
|
{"field": "priority", "op": "eq", "value": "critical", "target": "escalation"},
|
||||||
|
{"field": "amount", "op": "gt", "value": 10000, "target": "manager_review"},
|
||||||
|
{"field": "region", "op": "in", "value": ["EU", "UK"], "target": "gdpr_flow"},
|
||||||
|
], stage_data)
|
||||||
|
# Returns "escalation", "manager_review", "gdpr_flow", or None
|
||||||
|
```
|
||||||
|
|
||||||
|
Each rule is a dict with `field`, `op`, `value`, and `target`. Operators:
|
||||||
|
`exists`, `not_exists`, `eq`, `neq`, `gt`, `lt`, `gte`, `lte`, `in`,
|
||||||
|
`contains`. First-match-wins; returns `None` if no rule matches.
|
||||||
|
|
||||||
## Permission-gated modules
|
## Permission-gated modules
|
||||||
|
|
||||||
These modules are only available if the package has the corresponding
|
These modules are only available if the package has the corresponding
|
||||||
@@ -429,3 +447,30 @@ def on_run(ctx):
|
|||||||
|
|
||||||
return {"advance": True, "data": {"needs_review": False}}
|
return {"advance": True, "data": {"needs_review": False}}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## `forms` Module (v0.9.5)
|
||||||
|
|
||||||
|
**Permission:** `forms.validate`
|
||||||
|
|
||||||
|
Validates form data against a typed form template.
|
||||||
|
|
||||||
|
### `forms.validate(template, data)`
|
||||||
|
|
||||||
|
Validates `data` (a dict) against a `template` (a dict matching the TypedFormTemplate schema).
|
||||||
|
|
||||||
|
Returns a dict: `{"valid": True/False, "errors": [{"key": "...", "message": "..."}]}`.
|
||||||
|
|
||||||
|
```python
|
||||||
|
result = forms.validate(
|
||||||
|
{"fields": [{"key": "name", "type": "text", "label": "Name", "required": True}]},
|
||||||
|
{"name": "Alice"},
|
||||||
|
)
|
||||||
|
# result["valid"] == True
|
||||||
|
# result["errors"] == []
|
||||||
|
```
|
||||||
|
|
||||||
|
Field types: `text`, `email`, `select`, `number`, `date`, `textarea`, `checkbox`, `file`.
|
||||||
|
|
||||||
|
Supports: required checks, min/max length, pattern (regex), number range, date range, select option whitelist, conditional visibility (`condition.when`/`op`/`value`).
|
||||||
|
|||||||
@@ -1,227 +0,0 @@
|
|||||||
# Armature Usability Survey — Automated Checklist
|
|
||||||
|
|
||||||
> **Purpose**: Machine-auditable quality gate for the Armature UI.
|
|
||||||
> Run all scripts first, then walk each section. A FAIL in any section blocks release.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Prerequisites
|
|
||||||
|
|
||||||
Run these scripts from the project root and save their output:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
bash scripts/generate-ui-inventory.sh > ui-inventory.json
|
|
||||||
bash scripts/check-contrast.sh > contrast-report.txt
|
|
||||||
bash scripts/generate-coverage-matrix.sh > coverage-matrix.md
|
|
||||||
bash scripts/audit-touch-targets.sh > touch-targets-report.txt
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Section 1: Viewport Correctness
|
|
||||||
|
|
||||||
**Pass criteria:**
|
|
||||||
- No CSS file uses `100vh` (should be `100%` or `100dvh`)
|
|
||||||
- `.sw-shell` uses `height: 100%`, not `100vh`
|
|
||||||
- All extension surfaces use `height: 100%`
|
|
||||||
- No `transform: scale()` for zoom (should use CSS `zoom`)
|
|
||||||
|
|
||||||
**Files to inspect:**
|
|
||||||
- `src/css/sw-shell.css`
|
|
||||||
- `src/css/layout.css`
|
|
||||||
- `packages/*/css/main.css`
|
|
||||||
|
|
||||||
**How to check:**
|
|
||||||
```bash
|
|
||||||
grep -rn '100vh' src/css/ packages/*/css/ --include='*.css'
|
|
||||||
grep -rn 'transform.*scale' src/css/ packages/*/css/ --include='*.css'
|
|
||||||
```
|
|
||||||
|
|
||||||
**Result:** PASS if zero matches. FAIL if any `100vh` or `transform: scale()` for layout sizing.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Section 2: Banner Integration
|
|
||||||
|
|
||||||
**Pass criteria:**
|
|
||||||
- Banners are in-flow (no `position: fixed` on banner elements)
|
|
||||||
- `--banner-top-height` and `--banner-bottom-height` are defined in `:root`
|
|
||||||
- Shell layout accounts for banner height via CSS variables, not hardcoded px
|
|
||||||
- No surface hardcodes `28px` or other banner height values
|
|
||||||
|
|
||||||
**Files to inspect:**
|
|
||||||
- `src/css/sw-shell.css`
|
|
||||||
- `src/css/variables.css` (`:root` block)
|
|
||||||
- `src/js/sw/shell/app-shell.js`
|
|
||||||
|
|
||||||
**How to check:**
|
|
||||||
```bash
|
|
||||||
grep -n 'position.*fixed' src/css/sw-shell.css | grep -i banner
|
|
||||||
grep -n '28px' src/css/ -r --include='*.css'
|
|
||||||
grep -n 'banner-top-height\|banner-bottom-height' src/css/variables.css
|
|
||||||
```
|
|
||||||
|
|
||||||
**Result:** PASS if banners are in-flow and height is variable-driven. FAIL if fixed positioning or hardcoded heights.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Section 3: Responsive Behavior
|
|
||||||
|
|
||||||
**Pass criteria:**
|
|
||||||
- Kernel CSS uses `768px` (mobile) and `1024px` (tablet) breakpoints
|
|
||||||
- No hardcoded widths that break below 768px (except intentional min-widths on dialogs)
|
|
||||||
- Sidebar collapses on mobile
|
|
||||||
- Extension surfaces adapt to narrow viewports
|
|
||||||
|
|
||||||
**Files to inspect:**
|
|
||||||
- `src/css/layout.css`
|
|
||||||
- `src/css/surfaces.css`
|
|
||||||
- `packages/*/css/main.css`
|
|
||||||
|
|
||||||
**How to check:**
|
|
||||||
```bash
|
|
||||||
# Verify breakpoints used
|
|
||||||
grep -rn '@media.*max-width' src/css/ --include='*.css' | grep -v '768\|1024'
|
|
||||||
# Check for hardcoded widths
|
|
||||||
grep -rn 'width:.*[0-9]\+px' src/css/layout.css | grep -v 'max-width\|min-width\|--'
|
|
||||||
```
|
|
||||||
|
|
||||||
**Result:** PASS if only 768px and 1024px breakpoints. WARN if other breakpoints exist but are justified. FAIL if layout breaks below 768px.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Section 4: Styling Consistency
|
|
||||||
|
|
||||||
**Pass criteria:**
|
|
||||||
- All spacing uses `--sp-*` tokens (no raw px for padding/margin/gap > 3px)
|
|
||||||
- All `border-radius` uses `--radius-sm`, `--radius`, or `--radius-lg`
|
|
||||||
- All `font-family` uses `var(--font)` or `var(--mono)`
|
|
||||||
- No external font CDN imports (`@import url(` or Google Fonts references)
|
|
||||||
- No stale fallback colors (`#b38a4e` or other non-token hex in property values)
|
|
||||||
|
|
||||||
**Files to inspect:**
|
|
||||||
- All `src/css/*.css`
|
|
||||||
- `packages/*/css/main.css`
|
|
||||||
|
|
||||||
**How to check:**
|
|
||||||
```bash
|
|
||||||
# Raw px spacing (padding/margin/gap > 3px, not inside var())
|
|
||||||
grep -rnE '(padding|margin|gap):\s*[0-9]+(px|rem)' src/css/ packages/*/css/ --include='*.css' | grep -v 'var(--' | grep -v '0px\|1px\|2px\|3px'
|
|
||||||
# Raw border-radius
|
|
||||||
grep -rn 'border-radius:' src/css/ packages/*/css/ --include='*.css' | grep -v 'var(--radius'
|
|
||||||
# External fonts
|
|
||||||
grep -rn '@import url\|fonts.googleapis' src/css/ --include='*.css'
|
|
||||||
# Stale fallback gold color
|
|
||||||
grep -rn '#b38a4e' src/css/ packages/*/css/ --include='*.css'
|
|
||||||
```
|
|
||||||
|
|
||||||
**Result:** PASS if zero non-token values (excluding reset/keyframe contexts). WARN for 1-3 edge cases with justification. FAIL for systematic violations.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Section 5: Accessibility — Contrast
|
|
||||||
|
|
||||||
**Pass criteria:**
|
|
||||||
- `contrast-report.txt` shows all PASS for normal text (4.5:1 ratio)
|
|
||||||
- No FAIL results in either dark or light theme
|
|
||||||
|
|
||||||
**Files to inspect:**
|
|
||||||
- `contrast-report.txt` (generated above)
|
|
||||||
|
|
||||||
**How to check:**
|
|
||||||
```bash
|
|
||||||
grep 'FAIL' contrast-report.txt
|
|
||||||
```
|
|
||||||
|
|
||||||
**Result:** PASS if zero FAIL lines. FAIL if any contrast violation.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Section 6: Accessibility — Touch Targets
|
|
||||||
|
|
||||||
**Pass criteria:**
|
|
||||||
- `touch-targets-report.txt` shows zero violations
|
|
||||||
- All close buttons have `min-width: 44px; min-height: 44px` in `@media (max-width: 768px)`
|
|
||||||
- Menu items have `min-height: 44px` on mobile (already done in `sw-primitives.css`)
|
|
||||||
|
|
||||||
**Files to inspect:**
|
|
||||||
- `touch-targets-report.txt` (generated above)
|
|
||||||
- `src/css/sw-primitives.css` — close button rules
|
|
||||||
|
|
||||||
**How to check:**
|
|
||||||
```bash
|
|
||||||
grep 'FAIL\|MISSING' touch-targets-report.txt
|
|
||||||
```
|
|
||||||
|
|
||||||
**Result:** PASS if zero violations. FAIL if any close button lacks mobile touch target.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Section 7: Accessibility — Focus Indicators
|
|
||||||
|
|
||||||
**Pass criteria:**
|
|
||||||
- All interactive primitives have `:focus-visible` styles
|
|
||||||
- No `outline: none` without a replacement focus indicator
|
|
||||||
- Focus ring is visible on both dark and light themes
|
|
||||||
|
|
||||||
**Files to inspect:**
|
|
||||||
- `src/css/sw-primitives.css`
|
|
||||||
- `src/css/primitives.css`
|
|
||||||
- `src/css/variables.css`
|
|
||||||
|
|
||||||
**How to check:**
|
|
||||||
```bash
|
|
||||||
# Check for focus-visible on key primitives
|
|
||||||
for cls in sw-btn sw-input sw-dropdown__trigger sw-menu__item sw-tabs__tab; do
|
|
||||||
echo -n "$cls: "
|
|
||||||
grep -c "\.${cls}.*:focus-visible\|\.${cls}:focus-visible" src/css/sw-primitives.css src/css/primitives.css 2>/dev/null || echo "0"
|
|
||||||
done
|
|
||||||
# Check for outline:none without replacement
|
|
||||||
grep -n 'outline.*none\|outline.*0' src/css/*.css | grep -v 'focus-visible\|focus-within'
|
|
||||||
```
|
|
||||||
|
|
||||||
**Result:** PASS if all 5 key primitives have `:focus-visible`. WARN if outline:none exists with adequate replacement. FAIL if missing focus indicators.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Section 8: Component Uniformity
|
|
||||||
|
|
||||||
**Pass criteria:**
|
|
||||||
- `coverage-matrix.md` shows no deprecated component usage (no ⚠ in the deprecated row)
|
|
||||||
- All surfaces use `sw-*` primitives, not old `.btn-*`, `.toast`, `.popup-menu`
|
|
||||||
|
|
||||||
**Files to inspect:**
|
|
||||||
- `coverage-matrix.md` (generated above)
|
|
||||||
|
|
||||||
**How to check:**
|
|
||||||
```bash
|
|
||||||
grep '⚠' coverage-matrix.md
|
|
||||||
```
|
|
||||||
|
|
||||||
**Result:** PASS if zero ⚠ markers. FAIL if any deprecated component still in use.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Scoring
|
|
||||||
|
|
||||||
| Section | Weight | Result |
|
|
||||||
|---------|--------|--------|
|
|
||||||
| 1. Viewport Correctness | Required | |
|
|
||||||
| 2. Banner Integration | Required | |
|
|
||||||
| 3. Responsive Behavior | Required | |
|
|
||||||
| 4. Styling Consistency | Required | |
|
|
||||||
| 5. Contrast | Required | |
|
|
||||||
| 6. Touch Targets | Required | |
|
|
||||||
| 7. Focus Indicators | Required | |
|
|
||||||
| 8. Component Uniformity | Required | |
|
|
||||||
|
|
||||||
**Overall:** PASS requires all sections PASS or WARN. Any FAIL blocks the release.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## After the Survey
|
|
||||||
|
|
||||||
1. Fix all FAIL items
|
|
||||||
2. Re-run affected scripts to confirm fixes
|
|
||||||
3. Re-run the full survey
|
|
||||||
4. Tag `v0.6.16` only after a clean survey pass
|
|
||||||
@@ -19,7 +19,6 @@
|
|||||||
"ordinal": 0,
|
"ordinal": 0,
|
||||||
"stage_mode": "form",
|
"stage_mode": "form",
|
||||||
"audience": "public",
|
"audience": "public",
|
||||||
"stage_type": "simple",
|
|
||||||
"auto_transition": false,
|
"auto_transition": false,
|
||||||
"form_template": {
|
"form_template": {
|
||||||
"fieldsets": [
|
"fieldsets": [
|
||||||
@@ -48,7 +47,6 @@
|
|||||||
"ordinal": 1,
|
"ordinal": 1,
|
||||||
"stage_mode": "form",
|
"stage_mode": "form",
|
||||||
"audience": "team",
|
"audience": "team",
|
||||||
"stage_type": "simple",
|
|
||||||
"auto_transition": false,
|
"auto_transition": false,
|
||||||
"form_template": {
|
"form_template": {
|
||||||
"fieldsets": [
|
"fieldsets": [
|
||||||
@@ -71,7 +69,6 @@
|
|||||||
"ordinal": 2,
|
"ordinal": 2,
|
||||||
"stage_mode": "form",
|
"stage_mode": "form",
|
||||||
"audience": "team",
|
"audience": "team",
|
||||||
"stage_type": "simple",
|
|
||||||
"auto_transition": false,
|
"auto_transition": false,
|
||||||
"sla_seconds": 3600,
|
"sla_seconds": 3600,
|
||||||
"form_template": {
|
"form_template": {
|
||||||
@@ -91,7 +88,6 @@
|
|||||||
"ordinal": 3,
|
"ordinal": 3,
|
||||||
"stage_mode": "form",
|
"stage_mode": "form",
|
||||||
"audience": "team",
|
"audience": "team",
|
||||||
"stage_type": "simple",
|
|
||||||
"auto_transition": false,
|
"auto_transition": false,
|
||||||
"form_template": {
|
"form_template": {
|
||||||
"fieldsets": [
|
"fieldsets": [
|
||||||
@@ -108,9 +104,8 @@
|
|||||||
{
|
{
|
||||||
"name": "verify",
|
"name": "verify",
|
||||||
"ordinal": 4,
|
"ordinal": 4,
|
||||||
"stage_mode": "review",
|
"stage_mode": "form",
|
||||||
"audience": "team",
|
"audience": "team",
|
||||||
"stage_type": "simple",
|
|
||||||
"auto_transition": false,
|
"auto_transition": false,
|
||||||
"form_template": {
|
"form_template": {
|
||||||
"fieldsets": [
|
"fieldsets": [
|
||||||
|
|||||||
@@ -19,7 +19,6 @@
|
|||||||
"ordinal": 0,
|
"ordinal": 0,
|
||||||
"stage_mode": "form",
|
"stage_mode": "form",
|
||||||
"audience": "team",
|
"audience": "team",
|
||||||
"stage_type": "simple",
|
|
||||||
"auto_transition": false,
|
"auto_transition": false,
|
||||||
"form_template": {
|
"form_template": {
|
||||||
"fieldsets": [
|
"fieldsets": [
|
||||||
@@ -37,9 +36,8 @@
|
|||||||
{
|
{
|
||||||
"name": "review",
|
"name": "review",
|
||||||
"ordinal": 1,
|
"ordinal": 1,
|
||||||
"stage_mode": "review",
|
"stage_mode": "form",
|
||||||
"audience": "team",
|
"audience": "team",
|
||||||
"stage_type": "simple",
|
|
||||||
"auto_transition": false,
|
"auto_transition": false,
|
||||||
"stage_config": {
|
"stage_config": {
|
||||||
"validation": {
|
"validation": {
|
||||||
@@ -53,7 +51,6 @@
|
|||||||
"ordinal": 2,
|
"ordinal": 2,
|
||||||
"stage_mode": "form",
|
"stage_mode": "form",
|
||||||
"audience": "team",
|
"audience": "team",
|
||||||
"stage_type": "simple",
|
|
||||||
"auto_transition": false,
|
"auto_transition": false,
|
||||||
"form_template": {
|
"form_template": {
|
||||||
"fieldsets": [
|
"fieldsets": [
|
||||||
@@ -76,7 +73,6 @@
|
|||||||
"ordinal": 3,
|
"ordinal": 3,
|
||||||
"stage_mode": "form",
|
"stage_mode": "form",
|
||||||
"audience": "team",
|
"audience": "team",
|
||||||
"stage_type": "simple",
|
|
||||||
"auto_transition": false,
|
"auto_transition": false,
|
||||||
"form_template": {
|
"form_template": {
|
||||||
"fieldsets": [
|
"fieldsets": [
|
||||||
|
|||||||
@@ -40,7 +40,6 @@
|
|||||||
"ordinal": 0,
|
"ordinal": 0,
|
||||||
"stage_mode": "form",
|
"stage_mode": "form",
|
||||||
"audience": "team",
|
"audience": "team",
|
||||||
"stage_type": "simple",
|
|
||||||
"auto_transition": false,
|
"auto_transition": false,
|
||||||
"form_template": {
|
"form_template": {
|
||||||
"fieldsets": [
|
"fieldsets": [
|
||||||
@@ -69,16 +68,14 @@
|
|||||||
"ordinal": 1,
|
"ordinal": 1,
|
||||||
"stage_mode": "automated",
|
"stage_mode": "automated",
|
||||||
"audience": "system",
|
"audience": "system",
|
||||||
"stage_type": "automated",
|
|
||||||
"auto_transition": true,
|
"auto_transition": true,
|
||||||
"starlark_hook": "employee-onboarding:on_provision"
|
"starlark_hook": "employee-onboarding:on_provision"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "manager-signoff",
|
"name": "manager-signoff",
|
||||||
"ordinal": 2,
|
"ordinal": 2,
|
||||||
"stage_mode": "review",
|
"stage_mode": "form",
|
||||||
"audience": "team",
|
"audience": "team",
|
||||||
"stage_type": "simple",
|
|
||||||
"auto_transition": false,
|
"auto_transition": false,
|
||||||
"stage_config": {
|
"stage_config": {
|
||||||
"validation": {
|
"validation": {
|
||||||
@@ -93,7 +90,6 @@
|
|||||||
"ordinal": 3,
|
"ordinal": 3,
|
||||||
"stage_mode": "form",
|
"stage_mode": "form",
|
||||||
"audience": "team",
|
"audience": "team",
|
||||||
"stage_type": "simple",
|
|
||||||
"auto_transition": false,
|
"auto_transition": false,
|
||||||
"form_template": {
|
"form_template": {
|
||||||
"fieldsets": [
|
"fieldsets": [
|
||||||
@@ -113,7 +109,6 @@
|
|||||||
"ordinal": 4,
|
"ordinal": 4,
|
||||||
"stage_mode": "automated",
|
"stage_mode": "automated",
|
||||||
"audience": "system",
|
"audience": "system",
|
||||||
"stage_type": "automated",
|
|
||||||
"auto_transition": true,
|
"auto_transition": true,
|
||||||
"starlark_hook": "employee-onboarding:on_welcome"
|
"starlark_hook": "employee-onboarding:on_welcome"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -82,7 +82,7 @@
|
|||||||
name: 'Team Intake',
|
name: 'Team Intake',
|
||||||
ordinal: 0,
|
ordinal: 0,
|
||||||
history_mode: 'full',
|
history_mode: 'full',
|
||||||
stage_mode: 'chat_only'
|
stage_mode: 'form'
|
||||||
});
|
});
|
||||||
T.assertShape(d, T.S.workflowStage, 'stage');
|
T.assertShape(d, T.S.workflowStage, 'stage');
|
||||||
T.assert(d.name === 'Team Intake', 'name mismatch');
|
T.assert(d.name === 'Team Intake', 'name mismatch');
|
||||||
@@ -94,7 +94,7 @@
|
|||||||
name: 'Team Review',
|
name: 'Team Review',
|
||||||
ordinal: 1,
|
ordinal: 1,
|
||||||
history_mode: 'summary',
|
history_mode: 'summary',
|
||||||
stage_mode: 'review'
|
stage_mode: 'form'
|
||||||
});
|
});
|
||||||
T.assertShape(d, T.S.workflowStage, 'stage');
|
T.assertShape(d, T.S.workflowStage, 'stage');
|
||||||
stageIds.push(d.id);
|
stageIds.push(d.id);
|
||||||
@@ -111,7 +111,7 @@
|
|||||||
name: 'Team Intake (updated)',
|
name: 'Team Intake (updated)',
|
||||||
ordinal: 0,
|
ordinal: 0,
|
||||||
history_mode: 'full',
|
history_mode: 'full',
|
||||||
stage_mode: 'form_chat',
|
stage_mode: 'form',
|
||||||
form_template: { fields: [{ key: 'name', type: 'text', label: 'Name', required: true }] }
|
form_template: { fields: [{ key: 'name', type: 'text', label: 'Name', required: true }] }
|
||||||
});
|
});
|
||||||
T.assert(typeof d === 'object', 'expected object');
|
T.assert(typeof d === 'object', 'expected object');
|
||||||
|
|||||||
@@ -408,7 +408,7 @@
|
|||||||
var fwfSlug = testTag + '-form-wf';
|
var fwfSlug = testTag + '-form-wf';
|
||||||
var fChannelId = null;
|
var fChannelId = null;
|
||||||
|
|
||||||
await T.test('crud', 'workflows', 'form: create form_only workflow', async function () {
|
await T.test('crud', 'workflows', 'form: create form workflow', async function () {
|
||||||
var wf = await T.apiPost('/workflows', {
|
var wf = await T.apiPost('/workflows', {
|
||||||
name: testTag + ' Form WF',
|
name: testTag + ' Form WF',
|
||||||
slug: fwfSlug,
|
slug: fwfSlug,
|
||||||
@@ -422,7 +422,7 @@
|
|||||||
name: 'Contact Info',
|
name: 'Contact Info',
|
||||||
ordinal: 0,
|
ordinal: 0,
|
||||||
history_mode: 'full',
|
history_mode: 'full',
|
||||||
stage_mode: 'form_only',
|
stage_mode: 'form',
|
||||||
form_template: {
|
form_template: {
|
||||||
fields: [
|
fields: [
|
||||||
{ key: 'name', type: 'text', label: 'Full Name', required: true, validation: { min_length: 2 } },
|
{ key: 'name', type: 'text', label: 'Full Name', required: true, validation: { min_length: 2 } },
|
||||||
@@ -436,7 +436,7 @@
|
|||||||
});
|
});
|
||||||
|
|
||||||
await T.apiPost('/workflows/' + fwfId + '/stages', {
|
await T.apiPost('/workflows/' + fwfId + '/stages', {
|
||||||
name: 'Done', ordinal: 1, history_mode: 'full', stage_mode: 'chat_only'
|
name: 'Done', ordinal: 1, history_mode: 'full', stage_mode: 'form'
|
||||||
});
|
});
|
||||||
|
|
||||||
await T.apiPatch('/workflows/' + fwfId, { is_active: true });
|
await T.apiPatch('/workflows/' + fwfId, { is_active: true });
|
||||||
@@ -456,7 +456,7 @@
|
|||||||
var d = await T.sessionGet('/w/' + fChannelId + '/form');
|
var d = await T.sessionGet('/w/' + fChannelId + '/form');
|
||||||
T.assert(d._status === 200, 'expected 200, got ' + d._status);
|
T.assert(d._status === 200, 'expected 200, got ' + d._status);
|
||||||
T.assertHasKey(d, 'stage_mode', 'form response');
|
T.assertHasKey(d, 'stage_mode', 'form response');
|
||||||
T.assert(d.stage_mode === 'form_only', 'expected form_only, got ' + d.stage_mode);
|
T.assert(d.stage_mode === 'form', 'expected form, got ' + d.stage_mode);
|
||||||
T.assertHasKey(d, 'form_template', 'form response');
|
T.assertHasKey(d, 'form_template', 'form response');
|
||||||
T.assert(d.form_template.fields && d.form_template.fields.length === 3,
|
T.assert(d.form_template.fields && d.form_template.fields.length === 3,
|
||||||
'expected 3 fields, got ' + (d.form_template.fields ? d.form_template.fields.length : 0));
|
'expected 3 fields, got ' + (d.form_template.fields ? d.form_template.fields.length : 0));
|
||||||
@@ -520,7 +520,7 @@
|
|||||||
var xwfSlug = testTag + '-xvisitor';
|
var xwfSlug = testTag + '-xvisitor';
|
||||||
var chA = null, chB = null;
|
var chA = null, chB = null;
|
||||||
|
|
||||||
await T.test('crud', 'workflows', 'xvisitor: setup form_only workflow', async function () {
|
await T.test('crud', 'workflows', 'xvisitor: setup form workflow', async function () {
|
||||||
var wf = await T.apiPost('/workflows', {
|
var wf = await T.apiPost('/workflows', {
|
||||||
name: testTag + ' XVisitor',
|
name: testTag + ' XVisitor',
|
||||||
slug: xwfSlug,
|
slug: xwfSlug,
|
||||||
@@ -530,7 +530,7 @@
|
|||||||
T.registerCleanup(function () { if (xwfId) return T.safeDelete('/workflows/' + xwfId); });
|
T.registerCleanup(function () { if (xwfId) return T.safeDelete('/workflows/' + xwfId); });
|
||||||
|
|
||||||
await T.apiPost('/workflows/' + xwfId + '/stages', {
|
await T.apiPost('/workflows/' + xwfId + '/stages', {
|
||||||
name: 'Form', ordinal: 0, stage_mode: 'form_only', history_mode: 'full',
|
name: 'Form', ordinal: 0, stage_mode: 'form', history_mode: 'full',
|
||||||
form_template: {
|
form_template: {
|
||||||
fields: [{ key: 'name', type: 'text', label: 'Name', required: true }]
|
fields: [{ key: 'name', type: 'text', label: 'Name', required: true }]
|
||||||
}
|
}
|
||||||
@@ -627,7 +627,7 @@
|
|||||||
var s1 = await T.apiPost('/workflows/' + wpWfId + '/stages', {
|
var s1 = await T.apiPost('/workflows/' + wpWfId + '/stages', {
|
||||||
name: 'Intake',
|
name: 'Intake',
|
||||||
ordinal: 0,
|
ordinal: 0,
|
||||||
stage_mode: 'form_only',
|
stage_mode: 'form',
|
||||||
history_mode: 'full',
|
history_mode: 'full',
|
||||||
form_template: { fields: [{ key: 'name', type: 'text', label: 'Full Name', required: true }] }
|
form_template: { fields: [{ key: 'name', type: 'text', label: 'Full Name', required: true }] }
|
||||||
});
|
});
|
||||||
@@ -637,7 +637,7 @@
|
|||||||
var s2 = await T.apiPost('/workflows/' + wpWfId + '/stages', {
|
var s2 = await T.apiPost('/workflows/' + wpWfId + '/stages', {
|
||||||
name: 'Review',
|
name: 'Review',
|
||||||
ordinal: 1,
|
ordinal: 1,
|
||||||
stage_mode: 'review',
|
stage_mode: 'form',
|
||||||
history_mode: 'summary'
|
history_mode: 'summary'
|
||||||
});
|
});
|
||||||
T.assertShape(s2, T.S.workflowStage, 'stage2');
|
T.assertShape(s2, T.S.workflowStage, 'stage2');
|
||||||
@@ -650,7 +650,7 @@
|
|||||||
// Use the ICD test runner's own package ID (always installed when tests run).
|
// Use the ICD test runner's own package ID (always installed when tests run).
|
||||||
var realPkgId = 'icd-test-runner';
|
var realPkgId = 'icd-test-runner';
|
||||||
var stageBase = {
|
var stageBase = {
|
||||||
name: 'Intake', ordinal: 0, stage_mode: 'form_only', history_mode: 'full',
|
name: 'Intake', ordinal: 0, stage_mode: 'form', history_mode: 'full',
|
||||||
form_template: { fields: [{ key: 'name', type: 'text', label: 'Full Name', required: true }] }
|
form_template: { fields: [{ key: 'name', type: 'text', label: 'Full Name', required: true }] }
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -720,8 +720,8 @@
|
|||||||
T.assert(stList.length === 2, 'should have 2 stages, got ' + stList.length);
|
T.assert(stList.length === 2, 'should have 2 stages, got ' + stList.length);
|
||||||
T.assert(stList[0].name === 'Intake', 'stage 0 name should be Intake');
|
T.assert(stList[0].name === 'Intake', 'stage 0 name should be Intake');
|
||||||
T.assert(stList[1].name === 'Review', 'stage 1 name should be Review');
|
T.assert(stList[1].name === 'Review', 'stage 1 name should be Review');
|
||||||
T.assert(stList[0].stage_mode === 'form_only', 'stage 0 mode should be form_only');
|
T.assert(stList[0].stage_mode === 'form', 'stage 0 mode should be form');
|
||||||
T.assert(stList[1].stage_mode === 'review', 'stage 1 mode should be review');
|
T.assert(stList[1].stage_mode === 'form', 'stage 1 mode should be form');
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -30,7 +30,6 @@
|
|||||||
"ordinal": 0,
|
"ordinal": 0,
|
||||||
"stage_mode": "form",
|
"stage_mode": "form",
|
||||||
"audience": "team",
|
"audience": "team",
|
||||||
"stage_type": "simple",
|
|
||||||
"auto_transition": false,
|
"auto_transition": false,
|
||||||
"form_template": {
|
"form_template": {
|
||||||
"fieldsets": [
|
"fieldsets": [
|
||||||
@@ -50,16 +49,14 @@
|
|||||||
"ordinal": 1,
|
"ordinal": 1,
|
||||||
"stage_mode": "automated",
|
"stage_mode": "automated",
|
||||||
"audience": "system",
|
"audience": "system",
|
||||||
"stage_type": "automated",
|
|
||||||
"auto_transition": true,
|
"auto_transition": true,
|
||||||
"starlark_hook": "webhook-notifier:on_fire"
|
"starlark_hook": "webhook-notifier:on_fire"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "result",
|
"name": "result",
|
||||||
"ordinal": 2,
|
"ordinal": 2,
|
||||||
"stage_mode": "review",
|
"stage_mode": "form",
|
||||||
"audience": "team",
|
"audience": "team",
|
||||||
"stage_type": "simple",
|
|
||||||
"auto_transition": false,
|
"auto_transition": false,
|
||||||
"form_template": {
|
"form_template": {
|
||||||
"fieldsets": [
|
"fieldsets": [
|
||||||
|
|||||||
17
server/database/migrations/postgres/016_team_user_roles.sql
Normal file
17
server/database/migrations/postgres/016_team_user_roles.sql
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
-- ==========================================
|
||||||
|
-- Armature — 016 Team User Roles (many-to-many)
|
||||||
|
-- ==========================================
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS team_user_roles (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
team_id UUID NOT NULL REFERENCES teams(id) ON DELETE CASCADE,
|
||||||
|
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
role VARCHAR(50) NOT NULL,
|
||||||
|
assigned_by UUID REFERENCES users(id) ON DELETE SET NULL,
|
||||||
|
assigned_at TIMESTAMPTZ DEFAULT NOW(),
|
||||||
|
UNIQUE(team_id, user_id, role)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_team_user_roles_team_user ON team_user_roles(team_id, user_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_team_user_roles_team ON team_user_roles(team_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_team_user_roles_user ON team_user_roles(user_id);
|
||||||
21
server/database/migrations/postgres/017_package_adoption.sql
Normal file
21
server/database/migrations/postgres/017_package_adoption.sql
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
-- ==========================================
|
||||||
|
-- Armature — 017 Package Adoption + Role Catalog
|
||||||
|
-- ==========================================
|
||||||
|
|
||||||
|
-- Add adoptable flag and adoption lineage to packages
|
||||||
|
ALTER TABLE packages ADD COLUMN IF NOT EXISTS adoptable BOOLEAN NOT NULL DEFAULT false;
|
||||||
|
ALTER TABLE packages ADD COLUMN IF NOT EXISTS adopted_from TEXT;
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_packages_adoptable ON packages(adoptable) WHERE adoptable = true;
|
||||||
|
|
||||||
|
-- Team role catalog: known roles sourced from adopted packages
|
||||||
|
CREATE TABLE IF NOT EXISTS team_role_catalog (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
team_id UUID NOT NULL REFERENCES teams(id) ON DELETE CASCADE,
|
||||||
|
role VARCHAR(50) NOT NULL,
|
||||||
|
source_package_id TEXT REFERENCES packages(id) ON DELETE SET NULL,
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||||
|
UNIQUE(team_id, role)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_team_role_catalog_team ON team_role_catalog(team_id);
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
-- Armature — 018 Stage Mode Collapse
|
||||||
|
-- Deprecate stage_type validation. Collapse stage_mode: review → form.
|
||||||
|
|
||||||
|
-- 1. Migrate existing review rows to form
|
||||||
|
UPDATE workflow_stages SET stage_mode = 'form' WHERE stage_mode = 'review';
|
||||||
|
|
||||||
|
-- 2. Replace CHECK constraint on stage_mode (drop review)
|
||||||
|
ALTER TABLE workflow_stages DROP CONSTRAINT IF EXISTS workflow_stages_stage_mode_check;
|
||||||
|
ALTER TABLE workflow_stages ADD CONSTRAINT workflow_stages_stage_mode_check
|
||||||
|
CHECK (stage_mode IN ('form', 'delegated', 'automated'));
|
||||||
|
|
||||||
|
-- 3. Drop CHECK constraint on stage_type (any value accepted)
|
||||||
|
ALTER TABLE workflow_stages DROP CONSTRAINT IF EXISTS workflow_stages_stage_type_check;
|
||||||
17
server/database/migrations/sqlite/016_team_user_roles.sql
Normal file
17
server/database/migrations/sqlite/016_team_user_roles.sql
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
-- ==========================================
|
||||||
|
-- Armature — 016 Team User Roles (many-to-many, SQLite)
|
||||||
|
-- ==========================================
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS team_user_roles (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
team_id TEXT NOT NULL REFERENCES teams(id) ON DELETE CASCADE,
|
||||||
|
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
role TEXT NOT NULL,
|
||||||
|
assigned_by TEXT REFERENCES users(id) ON DELETE SET NULL,
|
||||||
|
assigned_at TEXT DEFAULT (datetime('now')),
|
||||||
|
UNIQUE(team_id, user_id, role)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_team_user_roles_team_user ON team_user_roles(team_id, user_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_team_user_roles_team ON team_user_roles(team_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_team_user_roles_user ON team_user_roles(user_id);
|
||||||
21
server/database/migrations/sqlite/017_package_adoption.sql
Normal file
21
server/database/migrations/sqlite/017_package_adoption.sql
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
-- ==========================================
|
||||||
|
-- Armature — 017 Package Adoption + Role Catalog (SQLite)
|
||||||
|
-- ==========================================
|
||||||
|
|
||||||
|
-- Add adoptable flag and adoption lineage to packages
|
||||||
|
ALTER TABLE packages ADD COLUMN adoptable INTEGER NOT NULL DEFAULT 0;
|
||||||
|
ALTER TABLE packages ADD COLUMN adopted_from TEXT;
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_packages_adoptable ON packages(adoptable);
|
||||||
|
|
||||||
|
-- Team role catalog: known roles sourced from adopted packages
|
||||||
|
CREATE TABLE IF NOT EXISTS team_role_catalog (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
team_id TEXT NOT NULL REFERENCES teams(id) ON DELETE CASCADE,
|
||||||
|
role TEXT NOT NULL,
|
||||||
|
source_package_id TEXT REFERENCES packages(id) ON DELETE SET NULL,
|
||||||
|
created_at TEXT DEFAULT (datetime('now')),
|
||||||
|
UNIQUE(team_id, role)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_team_role_catalog_team ON team_role_catalog(team_id);
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
-- Armature — 018 Stage Mode Collapse
|
||||||
|
-- Deprecate stage_type validation. Collapse stage_mode: review → form.
|
||||||
|
-- SQLite cannot ALTER CHECK constraints; app layer prevents new "review" inserts.
|
||||||
|
|
||||||
|
UPDATE workflow_stages SET stage_mode = 'form' WHERE stage_mode = 'review';
|
||||||
347
server/forms/forms.go
Normal file
347
server/forms/forms.go
Normal file
@@ -0,0 +1,347 @@
|
|||||||
|
package forms
|
||||||
|
|
||||||
|
// forms.go
|
||||||
|
//
|
||||||
|
// Standalone typed form system extracted from models/workflow.go (v0.9.5).
|
||||||
|
// Any package can declare and validate forms — not just workflow stages.
|
||||||
|
//
|
||||||
|
// Schema:
|
||||||
|
// TypedFormTemplate → []FormField | []FormFieldset
|
||||||
|
// FormField → key, type, label, validation, condition, …
|
||||||
|
// FormValidation → type-specific rules (length, pattern, range, date, file)
|
||||||
|
// FieldCondition → conditional visibility (eq, neq, in, exists)
|
||||||
|
// FormHooks → Starlark validate / on_submit entry points
|
||||||
|
//
|
||||||
|
// Functions:
|
||||||
|
// ParseTypedFormTemplate(raw) → *TypedFormTemplate
|
||||||
|
// ValidateFormData(tpl, data) → []FieldError
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ── Types ────────────────────────────────────
|
||||||
|
|
||||||
|
// TypedFormTemplate is the structured form_template schema.
|
||||||
|
type TypedFormTemplate struct {
|
||||||
|
Fields []FormField `json:"fields"`
|
||||||
|
Fieldsets []FormFieldset `json:"fieldsets,omitempty"`
|
||||||
|
Hooks *FormHooks `json:"hooks,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// FormFieldset groups fields into a labelled step for progressive forms.
|
||||||
|
// When fieldsets is present, top-level fields is ignored.
|
||||||
|
type FormFieldset struct {
|
||||||
|
Label string `json:"label"`
|
||||||
|
Fields []FormField `json:"fields"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// FieldCondition controls conditional visibility of a form field.
|
||||||
|
// When set, the field is shown/validated only if the condition is met.
|
||||||
|
type FieldCondition struct {
|
||||||
|
When string `json:"when"` // key of the field to check
|
||||||
|
Op string `json:"op,omitempty"` // eq (default) | neq | in | exists
|
||||||
|
Value any `json:"value,omitempty"` // value to compare against
|
||||||
|
}
|
||||||
|
|
||||||
|
// FormField is a single field in a typed form template.
|
||||||
|
type FormField struct {
|
||||||
|
Key string `json:"key"`
|
||||||
|
Type string `json:"type"` // text | email | select | number | date | textarea | checkbox | file
|
||||||
|
Label string `json:"label"`
|
||||||
|
Placeholder string `json:"placeholder,omitempty"`
|
||||||
|
Required bool `json:"required,omitempty"`
|
||||||
|
Default interface{} `json:"default,omitempty"`
|
||||||
|
Options []FormOption `json:"options,omitempty"`
|
||||||
|
Validation *FormValidation `json:"validation,omitempty"`
|
||||||
|
Condition *FieldCondition `json:"condition,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// FormOption is a choice for select fields.
|
||||||
|
type FormOption struct {
|
||||||
|
Value string `json:"value"`
|
||||||
|
Label string `json:"label"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// FormValidation holds type-specific validation rules.
|
||||||
|
type FormValidation struct {
|
||||||
|
MinLength *int `json:"min_length,omitempty"` // text, textarea, email
|
||||||
|
MaxLength *int `json:"max_length,omitempty"` // text, textarea, email
|
||||||
|
Pattern string `json:"pattern,omitempty"` // text, email (regex)
|
||||||
|
Min *float64 `json:"min,omitempty"` // number
|
||||||
|
Max *float64 `json:"max,omitempty"` // number
|
||||||
|
Step *float64 `json:"step,omitempty"` // number
|
||||||
|
MinDate string `json:"min_date,omitempty"` // date (YYYY-MM-DD)
|
||||||
|
MaxDate string `json:"max_date,omitempty"` // date (YYYY-MM-DD)
|
||||||
|
Accept string `json:"accept,omitempty"` // file (MIME types)
|
||||||
|
MaxSize *int64 `json:"max_size_bytes,omitempty"` // file
|
||||||
|
}
|
||||||
|
|
||||||
|
// FormHooks wires Starlark validation/submission hooks for a form.
|
||||||
|
type FormHooks struct {
|
||||||
|
PackageID string `json:"package_id"`
|
||||||
|
Validate string `json:"validate,omitempty"` // entry point name
|
||||||
|
OnSubmit string `json:"on_submit,omitempty"` // entry point name
|
||||||
|
}
|
||||||
|
|
||||||
|
// ValidFormFieldTypes is the set of recognized field types.
|
||||||
|
var ValidFormFieldTypes = map[string]bool{
|
||||||
|
"text": true, "email": true, "select": true, "number": true,
|
||||||
|
"date": true, "textarea": true, "checkbox": true, "file": true,
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Parsing ──────────────────────────────────
|
||||||
|
|
||||||
|
// ParseTypedFormTemplate parses a raw JSON form_template into the typed schema.
|
||||||
|
// Returns nil if the template is empty, legacy format, or not a typed template.
|
||||||
|
func ParseTypedFormTemplate(raw json.RawMessage) *TypedFormTemplate {
|
||||||
|
if len(raw) == 0 || string(raw) == "{}" || string(raw) == "null" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
var tpl TypedFormTemplate
|
||||||
|
if err := json.Unmarshal(raw, &tpl); err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(tpl.Fieldsets) > 0 {
|
||||||
|
var allFields []FormField
|
||||||
|
for _, fs := range tpl.Fieldsets {
|
||||||
|
allFields = append(allFields, fs.Fields...)
|
||||||
|
}
|
||||||
|
if len(allFields) > 0 {
|
||||||
|
tpl.Fields = allFields
|
||||||
|
return &tpl
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(tpl.Fields) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
// Verify this is actually the typed format (first field must have key+type)
|
||||||
|
if tpl.Fields[0].Key == "" || tpl.Fields[0].Type == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return &tpl
|
||||||
|
}
|
||||||
|
|
||||||
|
// AllFields returns all fields from the template, whether from top-level fields or fieldsets.
|
||||||
|
func (t *TypedFormTemplate) AllFields() []FormField {
|
||||||
|
return t.Fields // ParseTypedFormTemplate already flattens fieldsets into Fields
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Validation ───────────────────────────────
|
||||||
|
|
||||||
|
// FieldError is a validation error for a specific form field.
|
||||||
|
type FieldError struct {
|
||||||
|
Key string `json:"key"`
|
||||||
|
Message string `json:"message"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ValidateFormData validates submitted data against a typed form template.
|
||||||
|
func ValidateFormData(tpl *TypedFormTemplate, data map[string]interface{}) []FieldError {
|
||||||
|
var errs []FieldError
|
||||||
|
for _, f := range tpl.Fields {
|
||||||
|
if f.Condition != nil && !evaluateFieldCondition(f.Condition, data) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
val, present := data[f.Key]
|
||||||
|
|
||||||
|
// Required check
|
||||||
|
if f.Required && (!present || val == nil || val == "") {
|
||||||
|
errs = append(errs, FieldError{Key: f.Key, Message: f.Label + " is required"})
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !present || val == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
switch f.Type {
|
||||||
|
case "text", "textarea":
|
||||||
|
errs = append(errs, validateString(f, val)...)
|
||||||
|
case "email":
|
||||||
|
errs = append(errs, validateEmail(f, val)...)
|
||||||
|
case "number":
|
||||||
|
errs = append(errs, validateNumber(f, val)...)
|
||||||
|
case "date":
|
||||||
|
errs = append(errs, validateDate(f, val)...)
|
||||||
|
case "select":
|
||||||
|
errs = append(errs, validateSelect(f, val)...)
|
||||||
|
case "checkbox":
|
||||||
|
if _, ok := val.(bool); !ok {
|
||||||
|
errs = append(errs, FieldError{Key: f.Key, Message: f.Label + " must be true or false"})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return errs
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateString(f FormField, val interface{}) []FieldError {
|
||||||
|
s, ok := val.(string)
|
||||||
|
if !ok {
|
||||||
|
return []FieldError{{Key: f.Key, Message: f.Label + " must be text"}}
|
||||||
|
}
|
||||||
|
var errs []FieldError
|
||||||
|
v := f.Validation
|
||||||
|
if v == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if v.MinLength != nil && len(s) < *v.MinLength {
|
||||||
|
errs = append(errs, FieldError{Key: f.Key, Message: fmt.Sprintf("%s must be at least %d characters", f.Label, *v.MinLength)})
|
||||||
|
}
|
||||||
|
if v.MaxLength != nil && len(s) > *v.MaxLength {
|
||||||
|
errs = append(errs, FieldError{Key: f.Key, Message: fmt.Sprintf("%s must be at most %d characters", f.Label, *v.MaxLength)})
|
||||||
|
}
|
||||||
|
if v.Pattern != "" {
|
||||||
|
if re, err := regexp.Compile(v.Pattern); err == nil && !re.MatchString(s) {
|
||||||
|
errs = append(errs, FieldError{Key: f.Key, Message: f.Label + " does not match the required pattern"})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return errs
|
||||||
|
}
|
||||||
|
|
||||||
|
var emailRe = regexp.MustCompile(`^[^@\s]+@[^@\s]+\.[^@\s]+$`)
|
||||||
|
|
||||||
|
func validateEmail(f FormField, val interface{}) []FieldError {
|
||||||
|
s, ok := val.(string)
|
||||||
|
if !ok {
|
||||||
|
return []FieldError{{Key: f.Key, Message: f.Label + " must be text"}}
|
||||||
|
}
|
||||||
|
if !emailRe.MatchString(s) {
|
||||||
|
return []FieldError{{Key: f.Key, Message: f.Label + " must be a valid email address"}}
|
||||||
|
}
|
||||||
|
var errs []FieldError
|
||||||
|
if f.Validation != nil {
|
||||||
|
if f.Validation.Pattern != "" {
|
||||||
|
if re, err := regexp.Compile(f.Validation.Pattern); err == nil && !re.MatchString(s) {
|
||||||
|
errs = append(errs, FieldError{Key: f.Key, Message: f.Label + " does not match the required pattern"})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return errs
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateNumber(f FormField, val interface{}) []FieldError {
|
||||||
|
var n float64
|
||||||
|
switch v := val.(type) {
|
||||||
|
case float64:
|
||||||
|
n = v
|
||||||
|
case int:
|
||||||
|
n = float64(v)
|
||||||
|
case json.Number:
|
||||||
|
var err error
|
||||||
|
n, err = v.Float64()
|
||||||
|
if err != nil {
|
||||||
|
return []FieldError{{Key: f.Key, Message: f.Label + " must be a number"}}
|
||||||
|
}
|
||||||
|
case string:
|
||||||
|
return []FieldError{{Key: f.Key, Message: f.Label + " must be a number"}}
|
||||||
|
default:
|
||||||
|
return []FieldError{{Key: f.Key, Message: f.Label + " must be a number"}}
|
||||||
|
}
|
||||||
|
var errs []FieldError
|
||||||
|
if f.Validation != nil {
|
||||||
|
if f.Validation.Min != nil && n < *f.Validation.Min {
|
||||||
|
errs = append(errs, FieldError{Key: f.Key, Message: fmt.Sprintf("%s must be at least %g", f.Label, *f.Validation.Min)})
|
||||||
|
}
|
||||||
|
if f.Validation.Max != nil && n > *f.Validation.Max {
|
||||||
|
errs = append(errs, FieldError{Key: f.Key, Message: fmt.Sprintf("%s must be at most %g", f.Label, *f.Validation.Max)})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return errs
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateDate(f FormField, val interface{}) []FieldError {
|
||||||
|
s, ok := val.(string)
|
||||||
|
if !ok {
|
||||||
|
return []FieldError{{Key: f.Key, Message: f.Label + " must be a date string (YYYY-MM-DD)"}}
|
||||||
|
}
|
||||||
|
_, err := time.Parse("2006-01-02", s)
|
||||||
|
if err != nil {
|
||||||
|
return []FieldError{{Key: f.Key, Message: f.Label + " must be a valid date (YYYY-MM-DD)"}}
|
||||||
|
}
|
||||||
|
if f.Validation != nil {
|
||||||
|
if f.Validation.MinDate != "" {
|
||||||
|
if minD, e := time.Parse("2006-01-02", f.Validation.MinDate); e == nil {
|
||||||
|
if d, _ := time.Parse("2006-01-02", s); d.Before(minD) {
|
||||||
|
return []FieldError{{Key: f.Key, Message: fmt.Sprintf("%s must be on or after %s", f.Label, f.Validation.MinDate)}}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if f.Validation.MaxDate != "" {
|
||||||
|
if maxD, e := time.Parse("2006-01-02", f.Validation.MaxDate); e == nil {
|
||||||
|
if d, _ := time.Parse("2006-01-02", s); d.After(maxD) {
|
||||||
|
return []FieldError{{Key: f.Key, Message: fmt.Sprintf("%s must be on or before %s", f.Label, f.Validation.MaxDate)}}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateSelect(f FormField, val interface{}) []FieldError {
|
||||||
|
s, ok := val.(string)
|
||||||
|
if !ok {
|
||||||
|
return []FieldError{{Key: f.Key, Message: f.Label + " must be a string"}}
|
||||||
|
}
|
||||||
|
if len(f.Options) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
for _, o := range f.Options {
|
||||||
|
if strings.EqualFold(o.Value, s) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return []FieldError{{Key: f.Key, Message: f.Label + " is not a valid option"}}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Field Condition Evaluator ─────
|
||||||
|
|
||||||
|
// EvaluateFieldCondition checks if a conditional field should be visible/validated.
|
||||||
|
func EvaluateFieldCondition(cond *FieldCondition, data map[string]interface{}) bool {
|
||||||
|
return evaluateFieldCondition(cond, data)
|
||||||
|
}
|
||||||
|
|
||||||
|
func evaluateFieldCondition(cond *FieldCondition, data map[string]interface{}) bool {
|
||||||
|
val, exists := data[cond.When]
|
||||||
|
op := cond.Op
|
||||||
|
if op == "" {
|
||||||
|
op = "eq"
|
||||||
|
}
|
||||||
|
|
||||||
|
switch op {
|
||||||
|
case "exists":
|
||||||
|
return exists
|
||||||
|
case "eq":
|
||||||
|
if !exists {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%v", val) == fmt.Sprintf("%v", cond.Value)
|
||||||
|
case "neq":
|
||||||
|
if !exists {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%v", val) != fmt.Sprintf("%v", cond.Value)
|
||||||
|
case "in":
|
||||||
|
if !exists {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
arr, ok := cond.Value.([]interface{})
|
||||||
|
if !ok {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
vs := fmt.Sprintf("%v", val)
|
||||||
|
for _, item := range arr {
|
||||||
|
if fmt.Sprintf("%v", item) == vs {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
default:
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
322
server/forms/forms_test.go
Normal file
322
server/forms/forms_test.go
Normal file
@@ -0,0 +1,322 @@
|
|||||||
|
package forms
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ── Parse Tests ──────────────────────────────
|
||||||
|
|
||||||
|
func TestParseTypedFormTemplate_Empty(t *testing.T) {
|
||||||
|
for _, input := range []string{"", "{}", "null"} {
|
||||||
|
if tpl := ParseTypedFormTemplate(json.RawMessage(input)); tpl != nil {
|
||||||
|
t.Errorf("expected nil for %q, got %+v", input, tpl)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseTypedFormTemplate_InvalidJSON(t *testing.T) {
|
||||||
|
if tpl := ParseTypedFormTemplate(json.RawMessage(`{bad`)); tpl != nil {
|
||||||
|
t.Fatal("expected nil for invalid JSON")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseTypedFormTemplate_FlatFields(t *testing.T) {
|
||||||
|
raw := json.RawMessage(`{
|
||||||
|
"fields": [
|
||||||
|
{"key": "name", "type": "text", "label": "Name"},
|
||||||
|
{"key": "email", "type": "email", "label": "Email"}
|
||||||
|
]
|
||||||
|
}`)
|
||||||
|
tpl := ParseTypedFormTemplate(raw)
|
||||||
|
if tpl == nil {
|
||||||
|
t.Fatal("expected non-nil template")
|
||||||
|
}
|
||||||
|
if len(tpl.Fields) != 2 {
|
||||||
|
t.Fatalf("expected 2 fields, got %d", len(tpl.Fields))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseTypedFormTemplate_Fieldsets(t *testing.T) {
|
||||||
|
raw := json.RawMessage(`{
|
||||||
|
"fieldsets": [
|
||||||
|
{"label": "Step 1", "fields": [{"key": "a", "type": "text", "label": "A"}]},
|
||||||
|
{"label": "Step 2", "fields": [{"key": "b", "type": "number", "label": "B"}]}
|
||||||
|
]
|
||||||
|
}`)
|
||||||
|
tpl := ParseTypedFormTemplate(raw)
|
||||||
|
if tpl == nil {
|
||||||
|
t.Fatal("expected non-nil template")
|
||||||
|
}
|
||||||
|
if len(tpl.Fields) != 2 {
|
||||||
|
t.Fatalf("expected 2 flattened fields, got %d", len(tpl.Fields))
|
||||||
|
}
|
||||||
|
if len(tpl.Fieldsets) != 2 {
|
||||||
|
t.Fatalf("expected 2 fieldsets, got %d", len(tpl.Fieldsets))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseTypedFormTemplate_LegacyFormat(t *testing.T) {
|
||||||
|
// Legacy format: fields present but missing key/type → returns nil
|
||||||
|
raw := json.RawMessage(`{"fields": [{"label": "Name"}]}`)
|
||||||
|
if tpl := ParseTypedFormTemplate(raw); tpl != nil {
|
||||||
|
t.Fatal("expected nil for legacy format")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Validation Tests ─────────────────────────
|
||||||
|
|
||||||
|
func tpl(fields ...FormField) *TypedFormTemplate {
|
||||||
|
return &TypedFormTemplate{Fields: fields}
|
||||||
|
}
|
||||||
|
|
||||||
|
func intPtr(n int) *int { return &n }
|
||||||
|
func floatPtr(n float64) *float64 { return &n }
|
||||||
|
|
||||||
|
func TestValidateFormData_Required(t *testing.T) {
|
||||||
|
template := tpl(FormField{Key: "name", Type: "text", Label: "Name", Required: true})
|
||||||
|
|
||||||
|
// Missing
|
||||||
|
errs := ValidateFormData(template, map[string]interface{}{})
|
||||||
|
if len(errs) != 1 || errs[0].Key != "name" {
|
||||||
|
t.Fatalf("expected 1 error for missing required field, got %+v", errs)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Present
|
||||||
|
errs = ValidateFormData(template, map[string]interface{}{"name": "Alice"})
|
||||||
|
if len(errs) != 0 {
|
||||||
|
t.Fatalf("expected 0 errors, got %+v", errs)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Empty string counts as missing
|
||||||
|
errs = ValidateFormData(template, map[string]interface{}{"name": ""})
|
||||||
|
if len(errs) != 1 {
|
||||||
|
t.Fatalf("expected 1 error for empty required field, got %+v", errs)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateFormData_Text(t *testing.T) {
|
||||||
|
template := tpl(FormField{
|
||||||
|
Key: "bio", Type: "text", Label: "Bio",
|
||||||
|
Validation: &FormValidation{MinLength: intPtr(3), MaxLength: intPtr(10), Pattern: `^[a-z]+$`},
|
||||||
|
})
|
||||||
|
|
||||||
|
// Too short
|
||||||
|
errs := ValidateFormData(template, map[string]interface{}{"bio": "ab"})
|
||||||
|
if len(errs) != 1 {
|
||||||
|
t.Fatalf("expected min_length error, got %+v", errs)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Too long
|
||||||
|
errs = ValidateFormData(template, map[string]interface{}{"bio": "abcdefghijk"})
|
||||||
|
if len(errs) != 1 {
|
||||||
|
t.Fatalf("expected max_length error, got %+v", errs)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pattern mismatch
|
||||||
|
errs = ValidateFormData(template, map[string]interface{}{"bio": "ABC"})
|
||||||
|
if len(errs) != 1 {
|
||||||
|
t.Fatalf("expected pattern error, got %+v", errs)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Valid
|
||||||
|
errs = ValidateFormData(template, map[string]interface{}{"bio": "hello"})
|
||||||
|
if len(errs) != 0 {
|
||||||
|
t.Fatalf("expected 0 errors, got %+v", errs)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateFormData_Email(t *testing.T) {
|
||||||
|
template := tpl(FormField{Key: "email", Type: "email", Label: "Email"})
|
||||||
|
|
||||||
|
errs := ValidateFormData(template, map[string]interface{}{"email": "bad"})
|
||||||
|
if len(errs) != 1 {
|
||||||
|
t.Fatalf("expected email error, got %+v", errs)
|
||||||
|
}
|
||||||
|
|
||||||
|
errs = ValidateFormData(template, map[string]interface{}{"email": "a@b.c"})
|
||||||
|
if len(errs) != 0 {
|
||||||
|
t.Fatalf("expected 0 errors, got %+v", errs)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateFormData_Number(t *testing.T) {
|
||||||
|
template := tpl(FormField{
|
||||||
|
Key: "age", Type: "number", Label: "Age",
|
||||||
|
Validation: &FormValidation{Min: floatPtr(0), Max: floatPtr(150)},
|
||||||
|
})
|
||||||
|
|
||||||
|
// Below min
|
||||||
|
errs := ValidateFormData(template, map[string]interface{}{"age": float64(-1)})
|
||||||
|
if len(errs) != 1 {
|
||||||
|
t.Fatalf("expected min error, got %+v", errs)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Above max
|
||||||
|
errs = ValidateFormData(template, map[string]interface{}{"age": float64(200)})
|
||||||
|
if len(errs) != 1 {
|
||||||
|
t.Fatalf("expected max error, got %+v", errs)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Non-number
|
||||||
|
errs = ValidateFormData(template, map[string]interface{}{"age": "thirty"})
|
||||||
|
if len(errs) != 1 {
|
||||||
|
t.Fatalf("expected type error, got %+v", errs)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Valid
|
||||||
|
errs = ValidateFormData(template, map[string]interface{}{"age": float64(25)})
|
||||||
|
if len(errs) != 0 {
|
||||||
|
t.Fatalf("expected 0 errors, got %+v", errs)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateFormData_Date(t *testing.T) {
|
||||||
|
template := tpl(FormField{
|
||||||
|
Key: "start", Type: "date", Label: "Start",
|
||||||
|
Validation: &FormValidation{MinDate: "2024-01-01", MaxDate: "2024-12-31"},
|
||||||
|
})
|
||||||
|
|
||||||
|
// Invalid format
|
||||||
|
errs := ValidateFormData(template, map[string]interface{}{"start": "Jan 1"})
|
||||||
|
if len(errs) != 1 {
|
||||||
|
t.Fatalf("expected format error, got %+v", errs)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Before min
|
||||||
|
errs = ValidateFormData(template, map[string]interface{}{"start": "2023-12-31"})
|
||||||
|
if len(errs) != 1 {
|
||||||
|
t.Fatalf("expected min_date error, got %+v", errs)
|
||||||
|
}
|
||||||
|
|
||||||
|
// After max
|
||||||
|
errs = ValidateFormData(template, map[string]interface{}{"start": "2025-01-01"})
|
||||||
|
if len(errs) != 1 {
|
||||||
|
t.Fatalf("expected max_date error, got %+v", errs)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Valid
|
||||||
|
errs = ValidateFormData(template, map[string]interface{}{"start": "2024-06-15"})
|
||||||
|
if len(errs) != 0 {
|
||||||
|
t.Fatalf("expected 0 errors, got %+v", errs)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateFormData_Select(t *testing.T) {
|
||||||
|
template := tpl(FormField{
|
||||||
|
Key: "color", Type: "select", Label: "Color",
|
||||||
|
Options: []FormOption{{Value: "red", Label: "Red"}, {Value: "blue", Label: "Blue"}},
|
||||||
|
})
|
||||||
|
|
||||||
|
errs := ValidateFormData(template, map[string]interface{}{"color": "green"})
|
||||||
|
if len(errs) != 1 {
|
||||||
|
t.Fatalf("expected invalid option error, got %+v", errs)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Case-insensitive match
|
||||||
|
errs = ValidateFormData(template, map[string]interface{}{"color": "Red"})
|
||||||
|
if len(errs) != 0 {
|
||||||
|
t.Fatalf("expected 0 errors (case-insensitive), got %+v", errs)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateFormData_Checkbox(t *testing.T) {
|
||||||
|
template := tpl(FormField{Key: "agree", Type: "checkbox", Label: "Agree"})
|
||||||
|
|
||||||
|
errs := ValidateFormData(template, map[string]interface{}{"agree": "yes"})
|
||||||
|
if len(errs) != 1 {
|
||||||
|
t.Fatalf("expected type error, got %+v", errs)
|
||||||
|
}
|
||||||
|
|
||||||
|
errs = ValidateFormData(template, map[string]interface{}{"agree": true})
|
||||||
|
if len(errs) != 0 {
|
||||||
|
t.Fatalf("expected 0 errors, got %+v", errs)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateFormData_Condition(t *testing.T) {
|
||||||
|
template := tpl(
|
||||||
|
FormField{Key: "type", Type: "select", Label: "Type", Options: []FormOption{{Value: "normal", Label: "Normal"}, {Value: "other", Label: "Other"}}},
|
||||||
|
FormField{
|
||||||
|
Key: "details", Type: "text", Label: "Details", Required: true,
|
||||||
|
Condition: &FieldCondition{When: "type", Op: "eq", Value: "other"},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
// Condition not met → required field skipped
|
||||||
|
errs := ValidateFormData(template, map[string]interface{}{"type": "normal"})
|
||||||
|
if len(errs) != 0 {
|
||||||
|
t.Fatalf("expected 0 errors (condition not met), got %+v", errs)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Condition met → required enforced
|
||||||
|
errs = ValidateFormData(template, map[string]interface{}{"type": "other"})
|
||||||
|
if len(errs) != 1 || errs[0].Key != "details" {
|
||||||
|
t.Fatalf("expected 1 error for conditional required field, got %+v", errs)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Condition met + value provided
|
||||||
|
errs = ValidateFormData(template, map[string]interface{}{"type": "other", "details": "More info"})
|
||||||
|
if len(errs) != 0 {
|
||||||
|
t.Fatalf("expected 0 errors, got %+v", errs)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEvaluateFieldCondition_Operators(t *testing.T) {
|
||||||
|
data := map[string]interface{}{"status": "active", "role": "admin"}
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
cond FieldCondition
|
||||||
|
expect bool
|
||||||
|
}{
|
||||||
|
{"eq match", FieldCondition{When: "status", Op: "eq", Value: "active"}, true},
|
||||||
|
{"eq mismatch", FieldCondition{When: "status", Op: "eq", Value: "inactive"}, false},
|
||||||
|
{"neq match", FieldCondition{When: "status", Op: "neq", Value: "inactive"}, true},
|
||||||
|
{"neq mismatch", FieldCondition{When: "status", Op: "neq", Value: "active"}, false},
|
||||||
|
{"exists present", FieldCondition{When: "status", Op: "exists"}, true},
|
||||||
|
{"exists absent", FieldCondition{When: "missing", Op: "exists"}, false},
|
||||||
|
{"in match", FieldCondition{When: "role", Op: "in", Value: []interface{}{"admin", "mod"}}, true},
|
||||||
|
{"in mismatch", FieldCondition{When: "role", Op: "in", Value: []interface{}{"user", "guest"}}, false},
|
||||||
|
{"default op is eq", FieldCondition{When: "status", Value: "active"}, true},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
got := EvaluateFieldCondition(&tt.cond, data)
|
||||||
|
if got != tt.expect {
|
||||||
|
t.Errorf("expected %v, got %v", tt.expect, got)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAllFields(t *testing.T) {
|
||||||
|
raw := json.RawMessage(`{
|
||||||
|
"fieldsets": [
|
||||||
|
{"label": "A", "fields": [{"key": "x", "type": "text", "label": "X"}]},
|
||||||
|
{"label": "B", "fields": [{"key": "y", "type": "text", "label": "Y"}, {"key": "z", "type": "text", "label": "Z"}]}
|
||||||
|
]
|
||||||
|
}`)
|
||||||
|
tpl := ParseTypedFormTemplate(raw)
|
||||||
|
if tpl == nil {
|
||||||
|
t.Fatal("expected non-nil template")
|
||||||
|
}
|
||||||
|
all := tpl.AllFields()
|
||||||
|
if len(all) != 3 {
|
||||||
|
t.Fatalf("expected 3 fields, got %d", len(all))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateFormData_OptionalSkipped(t *testing.T) {
|
||||||
|
template := tpl(FormField{
|
||||||
|
Key: "note", Type: "text", Label: "Note",
|
||||||
|
Validation: &FormValidation{MinLength: intPtr(5)},
|
||||||
|
})
|
||||||
|
|
||||||
|
// Not present and not required → no error
|
||||||
|
errs := ValidateFormData(template, map[string]interface{}{})
|
||||||
|
if len(errs) != 0 {
|
||||||
|
t.Fatalf("expected 0 errors for absent optional field, got %+v", errs)
|
||||||
|
}
|
||||||
|
}
|
||||||
63
server/handlers/forms.go
Normal file
63
server/handlers/forms.go
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
|
||||||
|
"armature/forms"
|
||||||
|
)
|
||||||
|
|
||||||
|
// FormsHandler provides REST endpoints for the standalone forms system.
|
||||||
|
type FormsHandler struct{}
|
||||||
|
|
||||||
|
// formsValidateRequest is the request body for POST /api/v1/forms/validate.
|
||||||
|
type formsValidateRequest struct {
|
||||||
|
Template json.RawMessage `json:"template"`
|
||||||
|
Data map[string]interface{} `json:"data"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// formsValidateResponse is the response body for POST /api/v1/forms/validate.
|
||||||
|
type formsValidateResponse struct {
|
||||||
|
Valid bool `json:"valid"`
|
||||||
|
Errors []forms.FieldError `json:"errors"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate validates form data against a typed form template.
|
||||||
|
//
|
||||||
|
// POST /api/v1/forms/validate
|
||||||
|
// Body: { "template": {...}, "data": {...} }
|
||||||
|
// Response: { "valid": true/false, "errors": [...] }
|
||||||
|
func (h *FormsHandler) Validate(c *gin.Context) {
|
||||||
|
var req formsValidateRequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request body"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(req.Template) == 0 {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "template is required"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
tpl := forms.ParseTypedFormTemplate(req.Template)
|
||||||
|
if tpl == nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "template is invalid or empty"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if req.Data == nil {
|
||||||
|
req.Data = make(map[string]interface{})
|
||||||
|
}
|
||||||
|
|
||||||
|
errs := forms.ValidateFormData(tpl, req.Data)
|
||||||
|
if errs == nil {
|
||||||
|
errs = []forms.FieldError{}
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, formsValidateResponse{
|
||||||
|
Valid: len(errs) == 0,
|
||||||
|
Errors: errs,
|
||||||
|
})
|
||||||
|
}
|
||||||
284
server/handlers/package_adopt.go
Normal file
284
server/handlers/package_adopt.go
Normal file
@@ -0,0 +1,284 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
|
||||||
|
"armature/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
// PackageAdoptHandler manages team package adoption endpoints.
|
||||||
|
type PackageAdoptHandler struct {
|
||||||
|
stores store.Stores
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewPackageAdoptHandler(s store.Stores) *PackageAdoptHandler {
|
||||||
|
return &PackageAdoptHandler{stores: s}
|
||||||
|
}
|
||||||
|
|
||||||
|
// AdoptPackage clones a global adoptable package into the team.
|
||||||
|
// POST /api/v1/teams/:teamId/packages/:id/adopt
|
||||||
|
func (h *PackageAdoptHandler) AdoptPackage(c *gin.Context) {
|
||||||
|
ctx := c.Request.Context()
|
||||||
|
teamID := c.Param("teamId")
|
||||||
|
sourceID := c.Param("id")
|
||||||
|
userID := c.GetString("user_id")
|
||||||
|
|
||||||
|
// Load source package
|
||||||
|
src, err := h.stores.Packages.Get(ctx, sourceID)
|
||||||
|
if err != nil || src == nil {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "package not found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify adoptable + global
|
||||||
|
if !src.Adoptable {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "package is not adoptable"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if src.Scope != "global" {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "only global packages can be adopted"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Idempotency: check if already adopted
|
||||||
|
existing, err := h.stores.Packages.GetByAdoptedFrom(ctx, sourceID, teamID)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to check adoption"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if existing != nil {
|
||||||
|
// Already adopted — return the existing record
|
||||||
|
roles, _ := h.stores.Teams.ListRoleCatalog(ctx, teamID)
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"package": existing,
|
||||||
|
"roles_added": filterRolesBySource(roles, existing.ID),
|
||||||
|
"already_adopted": true,
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate adopted package ID: {sourceID}--{teamID[:8]}
|
||||||
|
shortTeam := teamID
|
||||||
|
if len(shortTeam) > 8 {
|
||||||
|
shortTeam = shortTeam[:8]
|
||||||
|
}
|
||||||
|
adoptedID := sourceID + "--" + shortTeam
|
||||||
|
|
||||||
|
// Create team-scoped package row referencing the original
|
||||||
|
adoptedPkg := &store.PackageRegistration{
|
||||||
|
ID: adoptedID,
|
||||||
|
Title: src.Title,
|
||||||
|
Type: src.Type,
|
||||||
|
Version: src.Version,
|
||||||
|
Description: src.Description,
|
||||||
|
Author: src.Author,
|
||||||
|
Tier: src.Tier,
|
||||||
|
IsSystem: false,
|
||||||
|
Scope: "team",
|
||||||
|
TeamID: &teamID,
|
||||||
|
InstalledBy: &userID,
|
||||||
|
Manifest: src.Manifest,
|
||||||
|
Enabled: true,
|
||||||
|
Status: "active",
|
||||||
|
SchemaVersion: src.SchemaVersion,
|
||||||
|
PackageSettings: src.PackageSettings,
|
||||||
|
Source: "extension",
|
||||||
|
Adoptable: false,
|
||||||
|
AdoptedFrom: &sourceID,
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := h.stores.Packages.Create(ctx, adoptedPkg); err != nil {
|
||||||
|
log.Printf("[packages] adopt: failed to create adopted package %s: %v", adoptedID, err)
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to adopt package"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auto-populate role catalog from requires_roles
|
||||||
|
var rolesAdded []string
|
||||||
|
if rr, ok := src.Manifest["requires_roles"].([]any); ok {
|
||||||
|
for _, v := range rr {
|
||||||
|
if role, ok := v.(string); ok && role != "" {
|
||||||
|
if err := h.stores.Teams.AddRoleToCatalog(ctx, teamID, role, adoptedID); err != nil {
|
||||||
|
log.Printf("[packages] adopt: failed to add role %q to catalog: %v", role, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
rolesAdded = append(rolesAdded, role)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If source is a workflow package, clone the workflow + stages
|
||||||
|
if src.Type == "workflow" {
|
||||||
|
h.cloneWorkflowForAdoption(c, src, adoptedPkg)
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Printf("[packages] adopted %s → %s for team %s (roles: %v)", sourceID, adoptedID, teamID, rolesAdded)
|
||||||
|
c.JSON(http.StatusCreated, gin.H{
|
||||||
|
"package": adoptedPkg,
|
||||||
|
"roles_added": rolesAdded,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// cloneWorkflowForAdoption copies the workflow definition from the source
|
||||||
|
// package into a team-scoped workflow. Best-effort — logs errors but does
|
||||||
|
// not abort the adoption.
|
||||||
|
func (h *PackageAdoptHandler) cloneWorkflowForAdoption(c *gin.Context, src, adopted *store.PackageRegistration) {
|
||||||
|
ctx := c.Request.Context()
|
||||||
|
teamID := *adopted.TeamID
|
||||||
|
|
||||||
|
// Extract workflow_definition from manifest
|
||||||
|
wfDef, ok := src.Manifest["workflow_definition"].(map[string]any)
|
||||||
|
if !ok {
|
||||||
|
log.Printf("[packages] adopt: no workflow_definition in manifest for %s", src.ID)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use the InstallWorkflowFromManifest path if available, or
|
||||||
|
// fall back to looking up the workflow by package ID
|
||||||
|
wfSlug, _ := wfDef["slug"].(string)
|
||||||
|
if wfSlug == "" {
|
||||||
|
wfSlug = src.ID
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find the global workflow installed from this package
|
||||||
|
wf, err := h.stores.Workflows.GetBySlug(ctx, nil, wfSlug)
|
||||||
|
if err != nil || wf == nil {
|
||||||
|
log.Printf("[packages] adopt: source workflow %q not found, skipping clone", wfSlug)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
stages, err := h.stores.Workflows.ListStages(ctx, wf.ID)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("[packages] adopt: failed to load stages for %s: %v", wf.ID, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clone workflow into team scope
|
||||||
|
clone := *wf
|
||||||
|
clone.ID = ""
|
||||||
|
clone.TeamID = &teamID
|
||||||
|
clone.IsActive = false
|
||||||
|
clone.Version = 0
|
||||||
|
clone.CreatedBy = c.GetString("user_id")
|
||||||
|
|
||||||
|
if err := h.stores.Workflows.Create(ctx, &clone); err != nil {
|
||||||
|
// Slug conflict — append short ID
|
||||||
|
clone.Slug = wfSlug + "-" + store.NewID()[:6]
|
||||||
|
if err2 := h.stores.Workflows.Create(ctx, &clone); err2 != nil {
|
||||||
|
log.Printf("[packages] adopt: failed to clone workflow: %v", err2)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clone stages
|
||||||
|
for _, st := range stages {
|
||||||
|
cloneSt := st
|
||||||
|
cloneSt.ID = ""
|
||||||
|
cloneSt.WorkflowID = clone.ID
|
||||||
|
if err := h.stores.Workflows.CreateStage(ctx, &cloneSt); err != nil {
|
||||||
|
log.Printf("[packages] adopt: failed to clone stage %s: %v", st.Name, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
log.Printf("[packages] adopt: cloned workflow %s → %s for team %s", wf.ID, clone.ID, teamID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListAdoptablePackages returns global adoptable packages, annotating which
|
||||||
|
// ones have already been adopted by this team.
|
||||||
|
// GET /api/v1/teams/:teamId/packages/adoptable
|
||||||
|
func (h *PackageAdoptHandler) ListAdoptablePackages(c *gin.Context) {
|
||||||
|
ctx := c.Request.Context()
|
||||||
|
teamID := c.Param("teamId")
|
||||||
|
|
||||||
|
pkgs, err := h.stores.Packages.ListAdoptable(ctx)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list packages"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
type adoptableItem struct {
|
||||||
|
store.PackageRegistration
|
||||||
|
Adopted bool `json:"adopted"`
|
||||||
|
}
|
||||||
|
|
||||||
|
var result []adoptableItem
|
||||||
|
for _, pkg := range pkgs {
|
||||||
|
item := adoptableItem{PackageRegistration: pkg}
|
||||||
|
existing, _ := h.stores.Packages.GetByAdoptedFrom(ctx, pkg.ID, teamID)
|
||||||
|
item.Adopted = existing != nil
|
||||||
|
result = append(result, item)
|
||||||
|
}
|
||||||
|
if result == nil {
|
||||||
|
result = []adoptableItem{}
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"data": result})
|
||||||
|
}
|
||||||
|
|
||||||
|
// UnadoptPackage removes a team's adopted package and cleans up role catalog.
|
||||||
|
// DELETE /api/v1/teams/:teamId/packages/:id/unadopt
|
||||||
|
func (h *PackageAdoptHandler) UnadoptPackage(c *gin.Context) {
|
||||||
|
ctx := c.Request.Context()
|
||||||
|
teamID := c.Param("teamId")
|
||||||
|
pkgID := c.Param("id")
|
||||||
|
|
||||||
|
pkg, err := h.stores.Packages.Get(ctx, pkgID)
|
||||||
|
if err != nil || pkg == nil {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "package not found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify it's an adopted package belonging to this team
|
||||||
|
if pkg.AdoptedFrom == nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "package was not adopted"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if pkg.TeamID == nil || *pkg.TeamID != teamID {
|
||||||
|
c.JSON(http.StatusForbidden, gin.H{"error": "package does not belong to this team"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clean up role catalog entries sourced from this package
|
||||||
|
if err := h.stores.Teams.RemoveRoleCatalogBySource(ctx, teamID, pkgID); err != nil {
|
||||||
|
log.Printf("[packages] unadopt: failed to clean role catalog: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete the adopted package row
|
||||||
|
if err := h.stores.Packages.Delete(ctx, pkgID); err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to remove package"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Printf("[packages] unadopted %s from team %s", pkgID, teamID)
|
||||||
|
c.JSON(http.StatusOK, gin.H{"message": "package unadopted"})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListRoleCatalog returns all known roles for a team.
|
||||||
|
// GET /api/v1/teams/:teamId/roles/catalog
|
||||||
|
func (h *PackageAdoptHandler) ListRoleCatalog(c *gin.Context) {
|
||||||
|
ctx := c.Request.Context()
|
||||||
|
teamID := c.Param("teamId")
|
||||||
|
|
||||||
|
roles, err := h.stores.Teams.ListRoleCatalog(ctx, teamID)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list role catalog"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if roles == nil {
|
||||||
|
roles = []store.TeamRoleCatalogEntry{}
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"data": roles})
|
||||||
|
}
|
||||||
|
|
||||||
|
// filterRolesBySource returns only roles from a specific package.
|
||||||
|
func filterRolesBySource(roles []store.TeamRoleCatalogEntry, pkgID string) []string {
|
||||||
|
var result []string
|
||||||
|
for _, r := range roles {
|
||||||
|
if r.SourcePackageID == pkgID {
|
||||||
|
result = append(result, r.Role)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
353
server/handlers/package_adopt_test.go
Normal file
353
server/handlers/package_adopt_test.go
Normal file
@@ -0,0 +1,353 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
|
||||||
|
"armature/database"
|
||||||
|
"armature/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ── Manifest: adoptable parsing ──────────────
|
||||||
|
|
||||||
|
func TestValidateManifest_Adoptable(t *testing.T) {
|
||||||
|
m := map[string]any{
|
||||||
|
"id": "adopt-wf",
|
||||||
|
"title": "Adoptable Workflow",
|
||||||
|
"type": "workflow",
|
||||||
|
"adoptable": true,
|
||||||
|
"workflow_definition": map[string]any{
|
||||||
|
"slug": "adopt-wf",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
info, err := ValidateManifest(m)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if !info.Adoptable {
|
||||||
|
t.Error("expected Adoptable to be true")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateManifest_Adoptable_Default(t *testing.T) {
|
||||||
|
m := map[string]any{
|
||||||
|
"id": "plain-surface",
|
||||||
|
"title": "Plain Surface",
|
||||||
|
"type": "surface",
|
||||||
|
}
|
||||||
|
info, err := ValidateManifest(m)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if info.Adoptable {
|
||||||
|
t.Error("expected Adoptable to be false by default")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateManifest_Adoptable_LibraryRejected(t *testing.T) {
|
||||||
|
m := map[string]any{
|
||||||
|
"id": "adopt-lib",
|
||||||
|
"title": "Adoptable Library",
|
||||||
|
"type": "library",
|
||||||
|
"adoptable": true,
|
||||||
|
"exports": []any{"module_a"},
|
||||||
|
}
|
||||||
|
_, err := ValidateManifest(m)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for adoptable library")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateManifest_Adoptable_TestRunnerRejected(t *testing.T) {
|
||||||
|
m := map[string]any{
|
||||||
|
"id": "adopt-runner",
|
||||||
|
"title": "Adoptable Runner",
|
||||||
|
"type": "test-runner",
|
||||||
|
"adoptable": true,
|
||||||
|
}
|
||||||
|
_, err := ValidateManifest(m)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for adoptable test-runner")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateManifest_Adoptable_SurfaceAllowed(t *testing.T) {
|
||||||
|
m := map[string]any{
|
||||||
|
"id": "adopt-surface",
|
||||||
|
"title": "Adoptable Surface",
|
||||||
|
"type": "surface",
|
||||||
|
"adoptable": true,
|
||||||
|
}
|
||||||
|
info, err := ValidateManifest(m)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if !info.Adoptable {
|
||||||
|
t.Error("expected Adoptable to be true for surface")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Store: role catalog CRUD ──────────────
|
||||||
|
|
||||||
|
// seedMinimalPackage creates a minimal package for FK references in tests.
|
||||||
|
func seedMinimalPackage(t *testing.T, stores store.Stores, id string) {
|
||||||
|
t.Helper()
|
||||||
|
pkg := &store.PackageRegistration{
|
||||||
|
ID: id, Title: id, Type: "surface", Version: "1.0.0",
|
||||||
|
Scope: "global", Tier: "browser", Manifest: map[string]any{"id": id, "title": id},
|
||||||
|
Enabled: true, Status: "active", Source: "extension",
|
||||||
|
}
|
||||||
|
if err := stores.Packages.Create(context.Background(), pkg); err != nil {
|
||||||
|
t.Fatalf("seed minimal package %s: %v", id, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRoleCatalog_AddAndList(t *testing.T) {
|
||||||
|
database.RequireTestDB(t)
|
||||||
|
stores := testStores(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
teamID, _, _ := seedTeamAndMember(t, stores)
|
||||||
|
pkgID := "rc-pkg-" + store.NewID()[:6]
|
||||||
|
seedMinimalPackage(t, stores, pkgID)
|
||||||
|
|
||||||
|
// Add two roles
|
||||||
|
if err := stores.Teams.AddRoleToCatalog(ctx, teamID, "approver", pkgID); err != nil {
|
||||||
|
t.Fatalf("AddRoleToCatalog: %v", err)
|
||||||
|
}
|
||||||
|
if err := stores.Teams.AddRoleToCatalog(ctx, teamID, "reviewer", pkgID); err != nil {
|
||||||
|
t.Fatalf("AddRoleToCatalog: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
roles, err := stores.Teams.ListRoleCatalog(ctx, teamID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ListRoleCatalog: %v", err)
|
||||||
|
}
|
||||||
|
if len(roles) != 2 {
|
||||||
|
t.Fatalf("expected 2 roles, got %d: %v", len(roles), roles)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRoleCatalog_AddIdempotent(t *testing.T) {
|
||||||
|
database.RequireTestDB(t)
|
||||||
|
stores := testStores(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
teamID, _, _ := seedTeamAndMember(t, stores)
|
||||||
|
pkgA := "rc-idem-a-" + store.NewID()[:6]
|
||||||
|
pkgB := "rc-idem-b-" + store.NewID()[:6]
|
||||||
|
seedMinimalPackage(t, stores, pkgA)
|
||||||
|
seedMinimalPackage(t, stores, pkgB)
|
||||||
|
|
||||||
|
if err := stores.Teams.AddRoleToCatalog(ctx, teamID, "approver", pkgA); err != nil {
|
||||||
|
t.Fatalf("first add: %v", err)
|
||||||
|
}
|
||||||
|
// Same role from different source — should be idempotent (unique on team_id, role)
|
||||||
|
if err := stores.Teams.AddRoleToCatalog(ctx, teamID, "approver", pkgB); err != nil {
|
||||||
|
t.Fatalf("idempotent add should not error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
roles, _ := stores.Teams.ListRoleCatalog(ctx, teamID)
|
||||||
|
if len(roles) != 1 {
|
||||||
|
t.Errorf("expected 1 role after idempotent add, got %d", len(roles))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRoleCatalog_RemoveBySource(t *testing.T) {
|
||||||
|
database.RequireTestDB(t)
|
||||||
|
stores := testStores(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
teamID, _, _ := seedTeamAndMember(t, stores)
|
||||||
|
pkgRm := "rc-rm-" + store.NewID()[:6]
|
||||||
|
pkgKeep := "rc-keep-" + store.NewID()[:6]
|
||||||
|
seedMinimalPackage(t, stores, pkgRm)
|
||||||
|
seedMinimalPackage(t, stores, pkgKeep)
|
||||||
|
|
||||||
|
stores.Teams.AddRoleToCatalog(ctx, teamID, "approver", pkgRm)
|
||||||
|
stores.Teams.AddRoleToCatalog(ctx, teamID, "reviewer", pkgRm)
|
||||||
|
stores.Teams.AddRoleToCatalog(ctx, teamID, "admin-role", pkgKeep)
|
||||||
|
|
||||||
|
if err := stores.Teams.RemoveRoleCatalogBySource(ctx, teamID, pkgRm); err != nil {
|
||||||
|
t.Fatalf("RemoveRoleCatalogBySource: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
roles, _ := stores.Teams.ListRoleCatalog(ctx, teamID)
|
||||||
|
if len(roles) != 1 {
|
||||||
|
t.Fatalf("expected 1 role remaining, got %d: %v", len(roles), roles)
|
||||||
|
}
|
||||||
|
if roles[0].Role != "admin-role" {
|
||||||
|
t.Errorf("expected 'admin-role' remaining, got %q", roles[0].Role)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Store: package adoption ──────────────
|
||||||
|
|
||||||
|
func seedAdoptablePackage(t *testing.T, stores store.Stores, id string, roles []string) {
|
||||||
|
t.Helper()
|
||||||
|
manifest := map[string]any{
|
||||||
|
"id": id,
|
||||||
|
"title": "Adoptable " + id,
|
||||||
|
"type": "surface",
|
||||||
|
}
|
||||||
|
if len(roles) > 0 {
|
||||||
|
rr := make([]any, len(roles))
|
||||||
|
for i, r := range roles {
|
||||||
|
rr[i] = r
|
||||||
|
}
|
||||||
|
manifest["requires_roles"] = rr
|
||||||
|
}
|
||||||
|
pkg := &store.PackageRegistration{
|
||||||
|
ID: id,
|
||||||
|
Title: "Adoptable " + id,
|
||||||
|
Type: "surface",
|
||||||
|
Version: "1.0.0",
|
||||||
|
Tier: "browser",
|
||||||
|
Scope: "global",
|
||||||
|
Manifest: manifest,
|
||||||
|
Enabled: true,
|
||||||
|
Status: "active",
|
||||||
|
Source: "extension",
|
||||||
|
Adoptable: true,
|
||||||
|
}
|
||||||
|
if err := stores.Packages.Create(context.Background(), pkg); err != nil {
|
||||||
|
t.Fatalf("seed adoptable package: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAdoptPackage_Success(t *testing.T) {
|
||||||
|
database.RequireTestDB(t)
|
||||||
|
stores := testStores(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
teamID, userID, _ := seedTeamAndMember(t, stores)
|
||||||
|
pkgID := "adopt-test-" + store.NewID()[:6]
|
||||||
|
seedAdoptablePackage(t, stores, pkgID, []string{"approver", "reviewer"})
|
||||||
|
|
||||||
|
// Set up handler
|
||||||
|
h := NewPackageAdoptHandler(stores)
|
||||||
|
gin.SetMode(gin.TestMode)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
c, r := gin.CreateTestContext(w)
|
||||||
|
r.POST("/teams/:teamId/packages/:id/adopt", func(c *gin.Context) {
|
||||||
|
c.Set("user_id", userID)
|
||||||
|
h.AdoptPackage(c)
|
||||||
|
})
|
||||||
|
|
||||||
|
req := httptest.NewRequest("POST", "/teams/"+teamID+"/packages/"+pkgID+"/adopt", nil)
|
||||||
|
c.Request = req
|
||||||
|
r.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusCreated {
|
||||||
|
t.Fatalf("expected 201, got %d: %s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
var resp map[string]any
|
||||||
|
json.NewDecoder(w.Body).Decode(&resp)
|
||||||
|
|
||||||
|
// Verify roles_added
|
||||||
|
rolesAdded, ok := resp["roles_added"].([]any)
|
||||||
|
if !ok || len(rolesAdded) != 2 {
|
||||||
|
t.Errorf("expected 2 roles_added, got %v", resp["roles_added"])
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify team package was created
|
||||||
|
shortTeam := teamID
|
||||||
|
if len(shortTeam) > 8 {
|
||||||
|
shortTeam = shortTeam[:8]
|
||||||
|
}
|
||||||
|
adopted, _ := stores.Packages.Get(ctx, pkgID+"--"+shortTeam)
|
||||||
|
if adopted == nil {
|
||||||
|
t.Fatal("adopted package not found in DB")
|
||||||
|
}
|
||||||
|
if adopted.Scope != "team" {
|
||||||
|
t.Errorf("expected scope 'team', got %q", adopted.Scope)
|
||||||
|
}
|
||||||
|
if adopted.AdoptedFrom == nil || *adopted.AdoptedFrom != pkgID {
|
||||||
|
t.Errorf("expected adopted_from=%q, got %v", pkgID, adopted.AdoptedFrom)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify role catalog populated
|
||||||
|
roles, _ := stores.Teams.ListRoleCatalog(ctx, teamID)
|
||||||
|
found := 0
|
||||||
|
for _, r := range roles {
|
||||||
|
if r.SourcePackageID == pkgID+"--"+shortTeam {
|
||||||
|
found++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if found != 2 {
|
||||||
|
t.Errorf("expected 2 catalog roles from adoption, got %d", found)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAdoptPackage_Idempotent(t *testing.T) {
|
||||||
|
database.RequireTestDB(t)
|
||||||
|
stores := testStores(t)
|
||||||
|
|
||||||
|
teamID, userID, _ := seedTeamAndMember(t, stores)
|
||||||
|
pkgID := "adopt-idem-" + store.NewID()[:6]
|
||||||
|
seedAdoptablePackage(t, stores, pkgID, nil)
|
||||||
|
|
||||||
|
h := NewPackageAdoptHandler(stores)
|
||||||
|
gin.SetMode(gin.TestMode)
|
||||||
|
|
||||||
|
doAdopt := func() int {
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
_, r := gin.CreateTestContext(w)
|
||||||
|
r.POST("/teams/:teamId/packages/:id/adopt", func(c *gin.Context) {
|
||||||
|
c.Set("user_id", userID)
|
||||||
|
h.AdoptPackage(c)
|
||||||
|
})
|
||||||
|
req := httptest.NewRequest("POST", "/teams/"+teamID+"/packages/"+pkgID+"/adopt", nil)
|
||||||
|
r.ServeHTTP(w, req)
|
||||||
|
return w.Code
|
||||||
|
}
|
||||||
|
|
||||||
|
code1 := doAdopt()
|
||||||
|
if code1 != http.StatusCreated {
|
||||||
|
t.Fatalf("first adopt: expected 201, got %d", code1)
|
||||||
|
}
|
||||||
|
code2 := doAdopt()
|
||||||
|
if code2 != http.StatusOK {
|
||||||
|
t.Fatalf("second adopt: expected 200, got %d", code2)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAdoptPackage_NotAdoptable(t *testing.T) {
|
||||||
|
database.RequireTestDB(t)
|
||||||
|
stores := testStores(t)
|
||||||
|
|
||||||
|
teamID, userID, _ := seedTeamAndMember(t, stores)
|
||||||
|
|
||||||
|
// Seed a non-adoptable package
|
||||||
|
pkg := &store.PackageRegistration{
|
||||||
|
ID: "no-adopt-" + store.NewID()[:6], Title: "Not Adoptable",
|
||||||
|
Type: "surface", Version: "1.0.0", Tier: "browser", Scope: "global",
|
||||||
|
Manifest: map[string]any{"id": "no-adopt", "title": "Not Adoptable"},
|
||||||
|
Enabled: true, Status: "active", Source: "extension",
|
||||||
|
Adoptable: false,
|
||||||
|
}
|
||||||
|
if err := stores.Packages.Create(context.Background(), pkg); err != nil {
|
||||||
|
t.Fatalf("seed non-adoptable: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
h := NewPackageAdoptHandler(stores)
|
||||||
|
gin.SetMode(gin.TestMode)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
_, r := gin.CreateTestContext(w)
|
||||||
|
r.POST("/teams/:teamId/packages/:id/adopt", func(c *gin.Context) {
|
||||||
|
c.Set("user_id", userID)
|
||||||
|
h.AdoptPackage(c)
|
||||||
|
})
|
||||||
|
req := httptest.NewRequest("POST", "/teams/"+teamID+"/packages/"+pkg.ID+"/adopt", nil)
|
||||||
|
r.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusBadRequest {
|
||||||
|
t.Fatalf("expected 400 for non-adoptable, got %d", w.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,6 +4,9 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"regexp"
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"armature/forms"
|
||||||
)
|
)
|
||||||
|
|
||||||
// validManifestID matches lowercase alphanumeric slugs with optional hyphens.
|
// validManifestID matches lowercase alphanumeric slugs with optional hyphens.
|
||||||
@@ -21,6 +24,7 @@ type ManifestInfo struct {
|
|||||||
Tier string
|
Tier string
|
||||||
SchemaVersion int
|
SchemaVersion int
|
||||||
HasRoute bool
|
HasRoute bool
|
||||||
|
HasSurfaces bool
|
||||||
HasTools bool
|
HasTools bool
|
||||||
HasPipes bool
|
HasPipes bool
|
||||||
HasHooks bool
|
HasHooks bool
|
||||||
@@ -31,6 +35,9 @@ type ManifestInfo struct {
|
|||||||
Signature string // reserved for future package signing
|
Signature string // reserved for future package signing
|
||||||
UserPermissions []string // user-facing permissions declared by the extension
|
UserPermissions []string // user-facing permissions declared by the extension
|
||||||
GatePermission string // if set, checks this user permission before calling on_request
|
GatePermission string // if set, checks this user permission before calling on_request
|
||||||
|
RequiresRoles []string // team roles needed to access this package (advisory, OR semantics)
|
||||||
|
Adoptable bool // if true, teams can adopt this package to get a team-scoped copy
|
||||||
|
HasFormTemplate bool // if true, package declares a form_template at the top level
|
||||||
}
|
}
|
||||||
|
|
||||||
// ValidateManifest parses a manifest map and validates all required fields,
|
// ValidateManifest parses a manifest map and validates all required fields,
|
||||||
@@ -81,7 +88,64 @@ func ValidateManifest(manifest map[string]any) (*ManifestInfo, error) {
|
|||||||
}
|
}
|
||||||
info.Signature, _ = manifest["signature"].(string)
|
info.Signature, _ = manifest["signature"].(string)
|
||||||
|
|
||||||
info.HasRoute = manifest["route"] != nil || manifest["routes"] != nil
|
// ── Surfaces validation ─────────────────────────────────────
|
||||||
|
if rawSurfaces, ok := manifest["surfaces"].([]any); ok {
|
||||||
|
if len(rawSurfaces) == 0 {
|
||||||
|
return nil, fmt.Errorf("surfaces array cannot be empty")
|
||||||
|
}
|
||||||
|
seen := map[string]bool{}
|
||||||
|
for i, raw := range rawSurfaces {
|
||||||
|
s, ok := raw.(map[string]any)
|
||||||
|
if !ok {
|
||||||
|
return nil, fmt.Errorf("surfaces[%d] must be an object", i)
|
||||||
|
}
|
||||||
|
path, _ := s["path"].(string)
|
||||||
|
if path == "" {
|
||||||
|
return nil, fmt.Errorf("surfaces[%d] requires a path", i)
|
||||||
|
}
|
||||||
|
if !strings.HasPrefix(path, "/") {
|
||||||
|
return nil, fmt.Errorf("surfaces[%d] path must start with /", i)
|
||||||
|
}
|
||||||
|
if seen[path] {
|
||||||
|
return nil, fmt.Errorf("duplicate surface path: %s", path)
|
||||||
|
}
|
||||||
|
seen[path] = true
|
||||||
|
if access, ok := s["access"].(string); ok {
|
||||||
|
if !validAccessLevels(access) {
|
||||||
|
return nil, fmt.Errorf("surfaces[%d] invalid access: %s", i, access)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
info.HasSurfaces = true
|
||||||
|
info.HasRoute = true
|
||||||
|
} else if manifest["surfaces"] != nil {
|
||||||
|
// surfaces key present but not an array
|
||||||
|
return nil, fmt.Errorf("surfaces must be an array")
|
||||||
|
} else {
|
||||||
|
// No surfaces declared — synthesize from legacy fields.
|
||||||
|
// This ensures every routable package always has a surfaces array.
|
||||||
|
if info.Type == "surface" || info.Type == "full" {
|
||||||
|
auth, _ := manifest["auth"].(string)
|
||||||
|
if auth == "" {
|
||||||
|
auth = "authenticated"
|
||||||
|
}
|
||||||
|
layout, _ := manifest["layout"].(string)
|
||||||
|
if layout == "" {
|
||||||
|
layout = "single"
|
||||||
|
}
|
||||||
|
manifest["surfaces"] = []any{
|
||||||
|
map[string]any{
|
||||||
|
"path": "/",
|
||||||
|
"access": auth,
|
||||||
|
"layout": layout,
|
||||||
|
"title": info.Title,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
info.HasSurfaces = true
|
||||||
|
}
|
||||||
|
info.HasRoute = manifest["route"] != nil || manifest["routes"] != nil || info.HasSurfaces
|
||||||
|
}
|
||||||
|
|
||||||
info.HasTools = manifest["tools"] != nil
|
info.HasTools = manifest["tools"] != nil
|
||||||
info.HasPipes = manifest["pipes"] != nil
|
info.HasPipes = manifest["pipes"] != nil
|
||||||
info.HasHooks = manifest["hooks"] != nil
|
info.HasHooks = manifest["hooks"] != nil
|
||||||
@@ -109,6 +173,32 @@ func ValidateManifest(manifest map[string]any) (*ManifestInfo, error) {
|
|||||||
}
|
}
|
||||||
info.GatePermission, _ = manifest["gate_permission"].(string)
|
info.GatePermission, _ = manifest["gate_permission"].(string)
|
||||||
|
|
||||||
|
// v0.9.3: requires_roles — team roles needed to access this package (advisory)
|
||||||
|
if rr, ok := manifest["requires_roles"].([]any); ok {
|
||||||
|
for _, v := range rr {
|
||||||
|
if s, ok := v.(string); ok && s != "" && len(s) <= 50 {
|
||||||
|
info.RequiresRoles = append(info.RequiresRoles, s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// v0.9.4: adoptable — teams can adopt this package to get a team-scoped copy
|
||||||
|
if v, ok := manifest["adoptable"].(bool); ok {
|
||||||
|
info.Adoptable = v
|
||||||
|
}
|
||||||
|
|
||||||
|
// v0.9.5: form_template — any package can declare a typed form template
|
||||||
|
if manifest["form_template"] != nil {
|
||||||
|
raw, err := json.Marshal(manifest["form_template"])
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("form_template is invalid JSON")
|
||||||
|
}
|
||||||
|
if tpl := forms.ParseTypedFormTemplate(raw); tpl == nil {
|
||||||
|
return nil, fmt.Errorf("form_template is invalid or empty")
|
||||||
|
}
|
||||||
|
info.HasFormTemplate = true
|
||||||
|
}
|
||||||
|
|
||||||
info.SchemaVersion = ParseSchemaVersion(manifest)
|
info.SchemaVersion = ParseSchemaVersion(manifest)
|
||||||
|
|
||||||
// ── Type-specific constraints ────────────────────────────────
|
// ── Type-specific constraints ────────────────────────────────
|
||||||
@@ -147,15 +237,35 @@ func ValidateManifest(manifest map[string]any) (*ManifestInfo, error) {
|
|||||||
if info.HasRoute {
|
if info.HasRoute {
|
||||||
return nil, fmt.Errorf("library packages cannot have a route")
|
return nil, fmt.Errorf("library packages cannot have a route")
|
||||||
}
|
}
|
||||||
|
if info.Adoptable {
|
||||||
|
return nil, fmt.Errorf("library packages cannot be adoptable")
|
||||||
|
}
|
||||||
case "test-runner":
|
case "test-runner":
|
||||||
// Test runners are surface-like packages discovered by type.
|
// Test runners are surface-like packages discovered by type.
|
||||||
// They are not shown in navigation (extensionNavItems filters for surface/full).
|
// They are not shown in navigation (extensionNavItems filters for surface/full).
|
||||||
// They may have a route but it's optional — the registry surface provides access.
|
// They may have a route but it's optional — the registry surface provides access.
|
||||||
|
if info.Adoptable {
|
||||||
|
return nil, fmt.Errorf("test-runner packages cannot be adoptable")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return info, nil
|
return info, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// validAccessLevels checks whether an access string is a recognized value.
|
||||||
|
func validAccessLevels(access string) bool {
|
||||||
|
switch {
|
||||||
|
case access == "public", access == "authenticated", access == "admin":
|
||||||
|
return true
|
||||||
|
case strings.HasPrefix(access, "group:"):
|
||||||
|
return strings.TrimPrefix(access, "group:") != ""
|
||||||
|
case strings.HasPrefix(access, "role:"):
|
||||||
|
return strings.TrimPrefix(access, "role:") != ""
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ValidateManifestJSON parses raw JSON bytes into a manifest map and validates.
|
// ValidateManifestJSON parses raw JSON bytes into a manifest map and validates.
|
||||||
func ValidateManifestJSON(data []byte) (map[string]any, *ManifestInfo, error) {
|
func ValidateManifestJSON(data []byte) (map[string]any, *ManifestInfo, error) {
|
||||||
var manifest map[string]any
|
var manifest map[string]any
|
||||||
|
|||||||
@@ -198,3 +198,154 @@ func TestValidateManifest_WorkflowNoDef(t *testing.T) {
|
|||||||
t.Fatal("expected error for workflow without workflow_definition")
|
t.Fatal("expected error for workflow without workflow_definition")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Surfaces validation ─────────────────────────────────────
|
||||||
|
|
||||||
|
func TestValidateManifest_ValidSurfaces(t *testing.T) {
|
||||||
|
m := map[string]any{
|
||||||
|
"id": "bug-tracker",
|
||||||
|
"title": "Bug Tracker",
|
||||||
|
"type": "full",
|
||||||
|
"hooks": []any{"surface"},
|
||||||
|
"surfaces": []any{
|
||||||
|
map[string]any{"path": "/", "title": "Dashboard"},
|
||||||
|
map[string]any{"path": "/submit", "access": "public", "title": "Report a Bug"},
|
||||||
|
map[string]any{"path": "/:id", "title": "Bug Detail", "nav": false},
|
||||||
|
map[string]any{"path": "/admin", "access": "admin", "title": "Settings"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
info, err := ValidateManifest(m)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if !info.HasSurfaces {
|
||||||
|
t.Error("expected HasSurfaces to be true")
|
||||||
|
}
|
||||||
|
if !info.HasRoute {
|
||||||
|
t.Error("expected HasRoute to be true")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateManifest_EmptySurfaces(t *testing.T) {
|
||||||
|
m := map[string]any{
|
||||||
|
"id": "my-pkg",
|
||||||
|
"title": "My Package",
|
||||||
|
"type": "surface",
|
||||||
|
"surfaces": []any{},
|
||||||
|
}
|
||||||
|
_, err := ValidateManifest(m)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for empty surfaces array")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateManifest_SurfaceMissingPath(t *testing.T) {
|
||||||
|
m := map[string]any{
|
||||||
|
"id": "my-pkg",
|
||||||
|
"title": "My Package",
|
||||||
|
"type": "surface",
|
||||||
|
"surfaces": []any{
|
||||||
|
map[string]any{"title": "No Path"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
_, err := ValidateManifest(m)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for surface without path")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateManifest_SurfacePathNoSlash(t *testing.T) {
|
||||||
|
m := map[string]any{
|
||||||
|
"id": "my-pkg",
|
||||||
|
"title": "My Package",
|
||||||
|
"type": "surface",
|
||||||
|
"surfaces": []any{
|
||||||
|
map[string]any{"path": "submit"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
_, err := ValidateManifest(m)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for path not starting with /")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateManifest_SurfaceDuplicatePath(t *testing.T) {
|
||||||
|
m := map[string]any{
|
||||||
|
"id": "my-pkg",
|
||||||
|
"title": "My Package",
|
||||||
|
"type": "surface",
|
||||||
|
"surfaces": []any{
|
||||||
|
map[string]any{"path": "/"},
|
||||||
|
map[string]any{"path": "/"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
_, err := ValidateManifest(m)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for duplicate surface path")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateManifest_SurfaceInvalidAccess(t *testing.T) {
|
||||||
|
m := map[string]any{
|
||||||
|
"id": "my-pkg",
|
||||||
|
"title": "My Package",
|
||||||
|
"type": "surface",
|
||||||
|
"surfaces": []any{
|
||||||
|
map[string]any{"path": "/", "access": "superuser"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
_, err := ValidateManifest(m)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for invalid access level")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateManifest_SurfaceGroupAccess(t *testing.T) {
|
||||||
|
m := map[string]any{
|
||||||
|
"id": "my-pkg",
|
||||||
|
"title": "My Package",
|
||||||
|
"type": "surface",
|
||||||
|
"surfaces": []any{
|
||||||
|
map[string]any{"path": "/", "access": "group:engineering"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
info, err := ValidateManifest(m)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if !info.HasSurfaces {
|
||||||
|
t.Error("expected HasSurfaces to be true")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateManifest_AutoSynthesizeSurfaces(t *testing.T) {
|
||||||
|
m := map[string]any{
|
||||||
|
"id": "legacy-pkg",
|
||||||
|
"title": "Legacy Package",
|
||||||
|
"type": "surface",
|
||||||
|
"auth": "public",
|
||||||
|
"layout": "editor",
|
||||||
|
}
|
||||||
|
info, err := ValidateManifest(m)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if !info.HasSurfaces {
|
||||||
|
t.Error("expected HasSurfaces after auto-synthesis")
|
||||||
|
}
|
||||||
|
// Verify the synthesized surfaces array was injected into the manifest
|
||||||
|
surfaces, ok := m["surfaces"].([]any)
|
||||||
|
if !ok || len(surfaces) != 1 {
|
||||||
|
t.Fatalf("expected 1 synthesized surface, got %v", m["surfaces"])
|
||||||
|
}
|
||||||
|
s := surfaces[0].(map[string]any)
|
||||||
|
if s["path"] != "/" {
|
||||||
|
t.Errorf("expected path '/', got %q", s["path"])
|
||||||
|
}
|
||||||
|
if s["access"] != "public" {
|
||||||
|
t.Errorf("expected access 'public', got %q", s["access"])
|
||||||
|
}
|
||||||
|
if s["layout"] != "editor" {
|
||||||
|
t.Errorf("expected layout 'editor', got %q", s["layout"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -591,6 +591,18 @@ func (h *PackageHandler) registerPackage(c *gin.Context, pkgID string, mInfo *Ma
|
|||||||
if err := h.stores.Packages.Update(c.Request.Context(), pkgID, pkg); err != nil {
|
if err := h.stores.Packages.Update(c.Request.Context(), pkgID, pkg); err != nil {
|
||||||
log.Printf("[packages] Failed to update package metadata for %s: %v", pkgID, err)
|
log.Printf("[packages] Failed to update package metadata for %s: %v", pkgID, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// v0.9.4: persist adoptable flag (Update doesn't cover it — separate column)
|
||||||
|
if mInfo.Adoptable {
|
||||||
|
q := `UPDATE packages SET adoptable = true WHERE id = $1`
|
||||||
|
if !database.IsPostgres() {
|
||||||
|
q = `UPDATE packages SET adoptable = 1 WHERE id = ?`
|
||||||
|
}
|
||||||
|
if _, err := database.DB.ExecContext(c.Request.Context(), q, pkgID); err != nil {
|
||||||
|
log.Printf("[packages] Failed to set adoptable for %s: %v", pkgID, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return preservedEnabled, nil
|
return preservedEnabled, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -917,7 +929,7 @@ func (h *PackageHandler) TestTool(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Build a call dict identical to what executeExtensionTool sends.
|
// Build a call dict identical to what executeExtensionTool sends.
|
||||||
// Build the arguments as an interface{} map for jsonToStarlark.
|
// Build the arguments as an interface{} map for GoToStarlark.
|
||||||
var args interface{}
|
var args interface{}
|
||||||
if req.Arguments != nil {
|
if req.Arguments != nil {
|
||||||
argsJSON, _ := json.Marshal(req.Arguments)
|
argsJSON, _ := json.Marshal(req.Arguments)
|
||||||
@@ -927,7 +939,7 @@ func (h *PackageHandler) TestTool(c *gin.Context) {
|
|||||||
callDict := starlark.NewDict(3)
|
callDict := starlark.NewDict(3)
|
||||||
_ = callDict.SetKey(starlark.String("tool_name"), starlark.String(req.ToolName))
|
_ = callDict.SetKey(starlark.String("tool_name"), starlark.String(req.ToolName))
|
||||||
_ = callDict.SetKey(starlark.String("tool_call_id"), starlark.String("test-"+pkgID))
|
_ = callDict.SetKey(starlark.String("tool_call_id"), starlark.String("test-"+pkgID))
|
||||||
_ = callDict.SetKey(starlark.String("arguments"), jsonToStarlark(args))
|
_ = callDict.SetKey(starlark.String("arguments"), sandbox.GoToStarlark(args))
|
||||||
|
|
||||||
userID, _ := c.Get("user_id")
|
userID, _ := c.Get("user_id")
|
||||||
rc := &sandbox.RunContext{UserID: fmt.Sprintf("%v", userID)}
|
rc := &sandbox.RunContext{UserID: fmt.Sprintf("%v", userID)}
|
||||||
@@ -945,7 +957,7 @@ func (h *PackageHandler) TestTool(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Serialize the Starlark return value to Go types.
|
// Serialize the Starlark return value to Go types.
|
||||||
result := starlarkValueToGo(val)
|
result := sandbox.StarlarkToGo(val)
|
||||||
c.JSON(http.StatusOK, gin.H{
|
c.JSON(http.StatusOK, gin.H{
|
||||||
"result": result,
|
"result": result,
|
||||||
"output": output,
|
"output": output,
|
||||||
|
|||||||
@@ -2,77 +2,9 @@ package handlers
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
|
||||||
"log"
|
"log"
|
||||||
|
|
||||||
"go.starlark.net/starlark"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// jsonToStarlark converts an arbitrary Go value (typically from JSON unmarshal)
|
|
||||||
// to a Starlark value for passing into extension scripts.
|
|
||||||
func jsonToStarlark(v any) starlark.Value {
|
|
||||||
switch val := v.(type) {
|
|
||||||
case nil:
|
|
||||||
return starlark.None
|
|
||||||
case bool:
|
|
||||||
return starlark.Bool(val)
|
|
||||||
case float64:
|
|
||||||
if val == float64(int64(val)) {
|
|
||||||
return starlark.MakeInt64(int64(val))
|
|
||||||
}
|
|
||||||
return starlark.Float(val)
|
|
||||||
case string:
|
|
||||||
return starlark.String(val)
|
|
||||||
case []any:
|
|
||||||
elems := make([]starlark.Value, len(val))
|
|
||||||
for i, e := range val {
|
|
||||||
elems[i] = jsonToStarlark(e)
|
|
||||||
}
|
|
||||||
return starlark.NewList(elems)
|
|
||||||
case map[string]any:
|
|
||||||
d := starlark.NewDict(len(val))
|
|
||||||
for k, v := range val {
|
|
||||||
_ = d.SetKey(starlark.String(k), jsonToStarlark(v))
|
|
||||||
}
|
|
||||||
return d
|
|
||||||
default:
|
|
||||||
return starlark.String(fmt.Sprintf("%v", val))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// starlarkValueToGo converts a Starlark value back to a Go value suitable
|
|
||||||
// for JSON serialization.
|
|
||||||
func starlarkValueToGo(v starlark.Value) any {
|
|
||||||
switch val := v.(type) {
|
|
||||||
case starlark.NoneType:
|
|
||||||
return nil
|
|
||||||
case starlark.Bool:
|
|
||||||
return bool(val)
|
|
||||||
case starlark.Int:
|
|
||||||
i, _ := val.Int64()
|
|
||||||
return i
|
|
||||||
case starlark.Float:
|
|
||||||
return float64(val)
|
|
||||||
case starlark.String:
|
|
||||||
return string(val)
|
|
||||||
case *starlark.List:
|
|
||||||
out := make([]any, val.Len())
|
|
||||||
for i := 0; i < val.Len(); i++ {
|
|
||||||
out[i] = starlarkValueToGo(val.Index(i))
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
case *starlark.Dict:
|
|
||||||
out := make(map[string]any)
|
|
||||||
for _, item := range val.Items() {
|
|
||||||
k, _ := item[0].(starlark.String)
|
|
||||||
out[string(k)] = starlarkValueToGo(item[1])
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
default:
|
|
||||||
return v.String()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ParseSchemaVersion extracts the schema_version from a package manifest.
|
// ParseSchemaVersion extracts the schema_version from a package manifest.
|
||||||
func ParseSchemaVersion(manifest any) int {
|
func ParseSchemaVersion(manifest any) int {
|
||||||
switch m := manifest.(type) {
|
switch m := manifest.(type) {
|
||||||
|
|||||||
360
server/handlers/team_roles_test.go
Normal file
360
server/handlers/team_roles_test.go
Normal file
@@ -0,0 +1,360 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
|
||||||
|
"armature/database"
|
||||||
|
"armature/middleware"
|
||||||
|
"armature/models"
|
||||||
|
"armature/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ── Manifest: requires_roles parsing ──────────
|
||||||
|
|
||||||
|
func TestValidateManifest_RequiresRoles(t *testing.T) {
|
||||||
|
m := map[string]any{
|
||||||
|
"id": "role-gated",
|
||||||
|
"title": "Role Gated Ext",
|
||||||
|
"type": "surface",
|
||||||
|
"requires_roles": []any{"approver", "reviewer"},
|
||||||
|
}
|
||||||
|
info, err := ValidateManifest(m)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if len(info.RequiresRoles) != 2 {
|
||||||
|
t.Fatalf("expected 2 requires_roles, got %d", len(info.RequiresRoles))
|
||||||
|
}
|
||||||
|
if info.RequiresRoles[0] != "approver" || info.RequiresRoles[1] != "reviewer" {
|
||||||
|
t.Errorf("unexpected roles: %v", info.RequiresRoles)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateManifest_RequiresRoles_Empty(t *testing.T) {
|
||||||
|
m := map[string]any{
|
||||||
|
"id": "no-roles",
|
||||||
|
"title": "No Roles",
|
||||||
|
"type": "surface",
|
||||||
|
}
|
||||||
|
info, err := ValidateManifest(m)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if len(info.RequiresRoles) != 0 {
|
||||||
|
t.Errorf("expected empty requires_roles, got %v", info.RequiresRoles)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateManifest_RequiresRoles_SkipsInvalid(t *testing.T) {
|
||||||
|
m := map[string]any{
|
||||||
|
"id": "bad-roles",
|
||||||
|
"title": "Bad Roles",
|
||||||
|
"type": "surface",
|
||||||
|
"requires_roles": []any{"ok", "", 42, "also-ok"},
|
||||||
|
}
|
||||||
|
info, err := ValidateManifest(m)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if len(info.RequiresRoles) != 2 {
|
||||||
|
t.Fatalf("expected 2 valid roles, got %d: %v", len(info.RequiresRoles), info.RequiresRoles)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Store: team_user_roles CRUD ──────────────
|
||||||
|
|
||||||
|
func seedTeamAndMember(t *testing.T, stores store.Stores) (teamID, userID, memberID string) {
|
||||||
|
t.Helper()
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
userID = seedRoleUser(t, "roleuser", "roleuser@test.com")
|
||||||
|
creatorID := seedRoleUser(t, "creator", "creator@test.com")
|
||||||
|
|
||||||
|
team := &models.Team{Name: "role-test-" + store.NewID()[:8], CreatedBy: creatorID, IsActive: true}
|
||||||
|
if err := stores.Teams.Create(ctx, team); err != nil {
|
||||||
|
t.Fatalf("create team: %v", err)
|
||||||
|
}
|
||||||
|
teamID = team.ID
|
||||||
|
|
||||||
|
mid, err := stores.Teams.AddMemberReturningID(ctx, teamID, userID, "member")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("add member: %v", err)
|
||||||
|
}
|
||||||
|
memberID = mid
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUserRoles_AddAndList(t *testing.T) {
|
||||||
|
database.RequireTestDB(t)
|
||||||
|
stores := testStores(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
teamID, userID, _ := seedTeamAndMember(t, stores)
|
||||||
|
|
||||||
|
// Initially: only the primary role
|
||||||
|
roles, err := stores.Teams.GetMemberRoles(ctx, teamID, userID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetMemberRoles: %v", err)
|
||||||
|
}
|
||||||
|
if len(roles) != 1 || roles[0] != "member" {
|
||||||
|
t.Errorf("expected [member], got %v", roles)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add two additional roles
|
||||||
|
if err := stores.Teams.AddUserRole(ctx, teamID, userID, "reviewer", userID); err != nil {
|
||||||
|
t.Fatalf("AddUserRole: %v", err)
|
||||||
|
}
|
||||||
|
if err := stores.Teams.AddUserRole(ctx, teamID, userID, "approver", userID); err != nil {
|
||||||
|
t.Fatalf("AddUserRole: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
roles, _ = stores.Teams.GetMemberRoles(ctx, teamID, userID)
|
||||||
|
if len(roles) != 3 {
|
||||||
|
t.Fatalf("expected 3 roles, got %d: %v", len(roles), roles)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListUserRoles returns only additional
|
||||||
|
extra, _ := stores.Teams.ListUserRoles(ctx, teamID, userID)
|
||||||
|
if len(extra) != 2 {
|
||||||
|
t.Fatalf("expected 2 extra roles, got %d: %v", len(extra), extra)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUserRoles_AddIdempotent(t *testing.T) {
|
||||||
|
database.RequireTestDB(t)
|
||||||
|
stores := testStores(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
teamID, userID, _ := seedTeamAndMember(t, stores)
|
||||||
|
|
||||||
|
// Add same role twice — should not error
|
||||||
|
if err := stores.Teams.AddUserRole(ctx, teamID, userID, "reviewer", userID); err != nil {
|
||||||
|
t.Fatalf("first add: %v", err)
|
||||||
|
}
|
||||||
|
if err := stores.Teams.AddUserRole(ctx, teamID, userID, "reviewer", userID); err != nil {
|
||||||
|
t.Fatalf("idempotent add should not error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
extra, _ := stores.Teams.ListUserRoles(ctx, teamID, userID)
|
||||||
|
if len(extra) != 1 {
|
||||||
|
t.Errorf("expected 1 extra role after idempotent add, got %d", len(extra))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUserRoles_HasRole(t *testing.T) {
|
||||||
|
database.RequireTestDB(t)
|
||||||
|
stores := testStores(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
teamID, userID, _ := seedTeamAndMember(t, stores)
|
||||||
|
|
||||||
|
// Primary role
|
||||||
|
has, _ := stores.Teams.HasRole(ctx, teamID, userID, "member")
|
||||||
|
if !has {
|
||||||
|
t.Error("expected HasRole=true for primary role 'member'")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Non-existent role
|
||||||
|
has, _ = stores.Teams.HasRole(ctx, teamID, userID, "reviewer")
|
||||||
|
if has {
|
||||||
|
t.Error("expected HasRole=false for unassigned role")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add and check
|
||||||
|
stores.Teams.AddUserRole(ctx, teamID, userID, "reviewer", userID)
|
||||||
|
has, _ = stores.Teams.HasRole(ctx, teamID, userID, "reviewer")
|
||||||
|
if !has {
|
||||||
|
t.Error("expected HasRole=true after adding role")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUserRoles_Remove(t *testing.T) {
|
||||||
|
database.RequireTestDB(t)
|
||||||
|
stores := testStores(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
teamID, userID, _ := seedTeamAndMember(t, stores)
|
||||||
|
|
||||||
|
stores.Teams.AddUserRole(ctx, teamID, userID, "reviewer", userID)
|
||||||
|
stores.Teams.RemoveUserRole(ctx, teamID, userID, "reviewer")
|
||||||
|
|
||||||
|
extra, _ := stores.Teams.ListUserRoles(ctx, teamID, userID)
|
||||||
|
if len(extra) != 0 {
|
||||||
|
t.Errorf("expected 0 extra roles after remove, got %d", len(extra))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUserRoles_RemoveAllOnMemberDelete(t *testing.T) {
|
||||||
|
database.RequireTestDB(t)
|
||||||
|
stores := testStores(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
teamID, userID, _ := seedTeamAndMember(t, stores)
|
||||||
|
|
||||||
|
stores.Teams.AddUserRole(ctx, teamID, userID, "reviewer", userID)
|
||||||
|
stores.Teams.AddUserRole(ctx, teamID, userID, "approver", userID)
|
||||||
|
|
||||||
|
// Cleanup (as the handler does)
|
||||||
|
stores.Teams.RemoveAllUserRoles(ctx, teamID, userID)
|
||||||
|
|
||||||
|
extra, _ := stores.Teams.ListUserRoles(ctx, teamID, userID)
|
||||||
|
if len(extra) != 0 {
|
||||||
|
t.Errorf("expected 0 extra roles after RemoveAll, got %d", len(extra))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Middleware: RequireRole ───────────────────
|
||||||
|
|
||||||
|
func TestRequireRole_Allowed(t *testing.T) {
|
||||||
|
database.RequireTestDB(t)
|
||||||
|
stores := testStores(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
teamID, userID, _ := seedTeamAndMember(t, stores)
|
||||||
|
stores.Teams.AddUserRole(ctx, teamID, userID, "reviewer", userID)
|
||||||
|
|
||||||
|
gin.SetMode(gin.TestMode)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
_, r := gin.CreateTestContext(w)
|
||||||
|
|
||||||
|
called := false
|
||||||
|
r.Use(func(c *gin.Context) { c.Set("user_id", userID); c.Next() })
|
||||||
|
r.GET("/teams/:teamId/test",
|
||||||
|
middleware.RequireRole(stores.Teams, []string{"reviewer"}, stores),
|
||||||
|
func(c *gin.Context) {
|
||||||
|
called = true
|
||||||
|
c.Status(http.StatusOK)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
req := httptest.NewRequest("GET", "/teams/"+teamID+"/test", nil)
|
||||||
|
r.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusOK || !called {
|
||||||
|
t.Errorf("expected 200 + handler called, got %d called=%v", w.Code, called)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRequireRole_Denied(t *testing.T) {
|
||||||
|
database.RequireTestDB(t)
|
||||||
|
stores := testStores(t)
|
||||||
|
|
||||||
|
teamID, userID, _ := seedTeamAndMember(t, stores)
|
||||||
|
|
||||||
|
gin.SetMode(gin.TestMode)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
c, r := gin.CreateTestContext(w)
|
||||||
|
|
||||||
|
r.Use(func(c *gin.Context) { c.Set("user_id", userID); c.Next() })
|
||||||
|
r.GET("/teams/:teamId/test",
|
||||||
|
middleware.RequireRole(stores.Teams, []string{"approver"}, stores),
|
||||||
|
func(c *gin.Context) { c.Status(http.StatusOK) },
|
||||||
|
)
|
||||||
|
c.Request = httptest.NewRequest("GET", "/teams/"+teamID+"/test", nil)
|
||||||
|
r.ServeHTTP(w, c.Request)
|
||||||
|
|
||||||
|
if w.Code != http.StatusForbidden {
|
||||||
|
t.Errorf("expected 403 for missing role, got %d", w.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Store: HasRoleInAnyTeam ──────────────────
|
||||||
|
|
||||||
|
func TestHasRoleInAnyTeam_PrimaryRole(t *testing.T) {
|
||||||
|
database.RequireTestDB(t)
|
||||||
|
stores := testStores(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
_, userID, _ := seedTeamAndMember(t, stores)
|
||||||
|
|
||||||
|
// "member" is the primary role assigned during seedTeamAndMember
|
||||||
|
has, err := stores.Teams.HasRoleInAnyTeam(ctx, userID, "member")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("HasRoleInAnyTeam: %v", err)
|
||||||
|
}
|
||||||
|
if !has {
|
||||||
|
t.Error("expected true for primary role 'member'")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHasRoleInAnyTeam_AdditionalRole(t *testing.T) {
|
||||||
|
database.RequireTestDB(t)
|
||||||
|
stores := testStores(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
teamID, userID, _ := seedTeamAndMember(t, stores)
|
||||||
|
stores.Teams.AddUserRole(ctx, teamID, userID, "reviewer", userID)
|
||||||
|
|
||||||
|
has, err := stores.Teams.HasRoleInAnyTeam(ctx, userID, "reviewer")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("HasRoleInAnyTeam: %v", err)
|
||||||
|
}
|
||||||
|
if !has {
|
||||||
|
t.Error("expected true for additional role 'reviewer'")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHasRoleInAnyTeam_NoMatch(t *testing.T) {
|
||||||
|
database.RequireTestDB(t)
|
||||||
|
stores := testStores(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
_, userID, _ := seedTeamAndMember(t, stores)
|
||||||
|
|
||||||
|
has, err := stores.Teams.HasRoleInAnyTeam(ctx, userID, "nonexistent")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("HasRoleInAnyTeam: %v", err)
|
||||||
|
}
|
||||||
|
if has {
|
||||||
|
t.Error("expected false for non-existent role")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Manifest: role access validation ────────
|
||||||
|
|
||||||
|
func TestValidateManifest_SurfaceRoleAccess(t *testing.T) {
|
||||||
|
m := map[string]any{
|
||||||
|
"id": "role-pkg",
|
||||||
|
"title": "Role Gated",
|
||||||
|
"type": "surface",
|
||||||
|
"surfaces": []any{
|
||||||
|
map[string]any{"path": "/", "access": "role:approver"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
info, err := ValidateManifest(m)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error for role:approver access: %v", err)
|
||||||
|
}
|
||||||
|
if !info.HasSurfaces {
|
||||||
|
t.Error("expected HasSurfaces to be true")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateManifest_SurfaceRoleAccessEmpty(t *testing.T) {
|
||||||
|
m := map[string]any{
|
||||||
|
"id": "role-pkg",
|
||||||
|
"title": "Role Gated",
|
||||||
|
"type": "surface",
|
||||||
|
"surfaces": []any{
|
||||||
|
map[string]any{"path": "/", "access": "role:"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
_, err := ValidateManifest(m)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for empty role name 'role:'")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── helpers ──────────────────────────────────
|
||||||
|
|
||||||
|
func seedRoleUser(t *testing.T, username, email string) string {
|
||||||
|
t.Helper()
|
||||||
|
uname := username + store.NewID()[:6]
|
||||||
|
q := `INSERT INTO users (username, email, password_hash, display_name, is_active, auth_source, handle)
|
||||||
|
VALUES ($1, $2, 'hash', $3, true, 'builtin', $4) RETURNING id`
|
||||||
|
return seedInsertReturningID(t, q, uname, email+store.NewID()[:6], uname, uname)
|
||||||
|
}
|
||||||
@@ -360,6 +360,11 @@ func (h *TeamHandler) RemoveMember(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Clean up additional roles from team_user_roles
|
||||||
|
if affectedUID != "" {
|
||||||
|
_ = h.stores.Teams.RemoveAllUserRoles(c.Request.Context(), teamID, affectedUID)
|
||||||
|
}
|
||||||
|
|
||||||
c.JSON(http.StatusOK, gin.H{"ok": true})
|
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||||
if affectedUID != "" {
|
if affectedUID != "" {
|
||||||
h.notifyAuthChanged(affectedUID, "team_member_removed")
|
h.notifyAuthChanged(affectedUID, "team_member_removed")
|
||||||
@@ -449,6 +454,106 @@ func (h *TeamHandler) ListTeamAuditActions(c *gin.Context) {
|
|||||||
c.JSON(http.StatusOK, gin.H{"actions": actions})
|
c.JSON(http.StatusOK, gin.H{"actions": actions})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Member User Roles (many-to-many) ─────────
|
||||||
|
|
||||||
|
// ListMemberRoles returns the full effective role set for a team member.
|
||||||
|
// GET /api/v1/teams/:teamId/members/:memberId/roles
|
||||||
|
func (h *TeamHandler) ListMemberRoles(c *gin.Context) {
|
||||||
|
teamID := getTeamID(c)
|
||||||
|
memberID := c.Param("memberId")
|
||||||
|
|
||||||
|
uid := h.memberUserID(c.Request.Context(), teamID, memberID)
|
||||||
|
if uid == "" {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "member not found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
roles, err := h.stores.Teams.GetMemberRoles(c.Request.Context(), teamID, uid)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "query failed"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Also fetch the primary role for the response
|
||||||
|
member, _ := h.stores.Teams.GetMember(c.Request.Context(), teamID, uid)
|
||||||
|
primary := ""
|
||||||
|
if member != nil {
|
||||||
|
primary = member.Role
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{"data": roles, "primary": primary})
|
||||||
|
}
|
||||||
|
|
||||||
|
// AddMemberRole assigns an additional role to a team member.
|
||||||
|
// POST /api/v1/teams/:teamId/members/:memberId/roles
|
||||||
|
func (h *TeamHandler) AddMemberRole(c *gin.Context) {
|
||||||
|
teamID := getTeamID(c)
|
||||||
|
memberID := c.Param("memberId")
|
||||||
|
|
||||||
|
var req struct {
|
||||||
|
Role string `json:"role" binding:"required,min=1,max=50"`
|
||||||
|
}
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := validateTeamRole(c.Request.Context(), h.stores, teamID, req.Role); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
uid := h.memberUserID(c.Request.Context(), teamID, memberID)
|
||||||
|
if uid == "" {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "member not found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
assignedBy := getUserID(c)
|
||||||
|
if err := h.stores.Teams.AddUserRole(c.Request.Context(), teamID, uid, req.Role, assignedBy); err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to add role"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusCreated, gin.H{"ok": true})
|
||||||
|
h.notifyAuthChanged(uid, "team_role_added")
|
||||||
|
AuditLog(h.stores.Audit, c, "team.add_member_role", "team", teamID, map[string]interface{}{
|
||||||
|
"member_id": memberID, "user_id": uid, "role": req.Role,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// RemoveMemberRole removes an additional role from a team member.
|
||||||
|
// DELETE /api/v1/teams/:teamId/members/:memberId/roles/:role
|
||||||
|
func (h *TeamHandler) RemoveMemberRole(c *gin.Context) {
|
||||||
|
teamID := getTeamID(c)
|
||||||
|
memberID := c.Param("memberId")
|
||||||
|
role := c.Param("role")
|
||||||
|
|
||||||
|
uid := h.memberUserID(c.Request.Context(), teamID, memberID)
|
||||||
|
if uid == "" {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "member not found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Don't allow removing the primary role via this endpoint
|
||||||
|
member, _ := h.stores.Teams.GetMember(c.Request.Context(), teamID, uid)
|
||||||
|
if member != nil && member.Role == role {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "cannot remove primary role; use member update instead"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := h.stores.Teams.RemoveUserRole(c.Request.Context(), teamID, uid, role); err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to remove role"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||||
|
h.notifyAuthChanged(uid, "team_role_removed")
|
||||||
|
AuditLog(h.stores.Audit, c, "team.remove_member_role", "team", teamID, map[string]interface{}{
|
||||||
|
"member_id": memberID, "user_id": uid, "role": role,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// ── Team Roles API ─────────────────
|
// ── Team Roles API ─────────────────
|
||||||
|
|
||||||
// builtinRoles are always present in every team's role list.
|
// builtinRoles are always present in every team's role list.
|
||||||
|
|||||||
@@ -262,7 +262,7 @@ func (h *WorkflowAssignmentHandler) enrichAssignments(ctx context.Context, assig
|
|||||||
// Resolve stage name and SLA from the published version snapshot
|
// Resolve stage name and SLA from the published version snapshot
|
||||||
ver, _ := h.stores.Workflows.GetVersion(ctx, inst.WorkflowID, inst.WorkflowVersion)
|
ver, _ := h.stores.Workflows.GetVersion(ctx, inst.WorkflowID, inst.WorkflowVersion)
|
||||||
if ver != nil {
|
if ver != nil {
|
||||||
stages := parseSnapshotStagesForEnrich(ver.Snapshot)
|
stages, _ := models.ParseSnapshotStages(ver.Snapshot)
|
||||||
for _, s := range stages {
|
for _, s := range stages {
|
||||||
if s.Name == a.Stage {
|
if s.Name == a.Stage {
|
||||||
v.StageName = s.Name
|
v.StageName = s.Name
|
||||||
@@ -287,15 +287,3 @@ func (h *WorkflowAssignmentHandler) enrichAssignments(ctx context.Context, assig
|
|||||||
return views
|
return views
|
||||||
}
|
}
|
||||||
|
|
||||||
// parseSnapshotStagesForEnrich is a lightweight stage parser for enrichment.
|
|
||||||
func parseSnapshotStagesForEnrich(snapshot json.RawMessage) []models.WorkflowStage {
|
|
||||||
var wrapped struct {
|
|
||||||
Stages []models.WorkflowStage `json:"stages"`
|
|
||||||
}
|
|
||||||
if err := json.Unmarshal(snapshot, &wrapped); err == nil && len(wrapped.Stages) > 0 {
|
|
||||||
return wrapped.Stages
|
|
||||||
}
|
|
||||||
var stages []models.WorkflowStage
|
|
||||||
json.Unmarshal(snapshot, &stages)
|
|
||||||
return stages
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ func testEngine(t *testing.T) *workflow.Engine {
|
|||||||
return workflow.NewEngine(s, nil, nil)
|
return workflow.NewEngine(s, nil, nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
// seedEngineFixture creates a 3-stage workflow (form → review → form),
|
// seedEngineFixture creates a 3-stage workflow (form → form → form),
|
||||||
// publishes it, and returns (workflowID, userID, teamID).
|
// publishes it, and returns (workflowID, userID, teamID).
|
||||||
func seedEngineFixture(t *testing.T, slug string) (string, string, string) {
|
func seedEngineFixture(t *testing.T, slug string) (string, string, string) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
@@ -42,7 +42,7 @@ func seedEngineFixture(t *testing.T, slug string) (string, string, string) {
|
|||||||
|
|
||||||
stages := []models.WorkflowStage{
|
stages := []models.WorkflowStage{
|
||||||
{WorkflowID: wf.ID, Ordinal: 0, Name: "intake", StageMode: models.StageModeForm, Audience: models.AudienceTeam, StageType: models.StageTypeSimple},
|
{WorkflowID: wf.ID, Ordinal: 0, Name: "intake", StageMode: models.StageModeForm, Audience: models.AudienceTeam, StageType: models.StageTypeSimple},
|
||||||
{WorkflowID: wf.ID, Ordinal: 1, Name: "review", StageMode: models.StageModeReview, Audience: models.AudienceTeam, StageType: models.StageTypeSimple, AssignmentTeamID: &teamID},
|
{WorkflowID: wf.ID, Ordinal: 1, Name: "review", StageMode: models.StageModeForm, Audience: models.AudienceTeam, StageType: models.StageTypeSimple, AssignmentTeamID: &teamID},
|
||||||
{WorkflowID: wf.ID, Ordinal: 2, Name: "final", StageMode: models.StageModeForm, Audience: models.AudienceTeam, StageType: models.StageTypeSimple},
|
{WorkflowID: wf.ID, Ordinal: 2, Name: "final", StageMode: models.StageModeForm, Audience: models.AudienceTeam, StageType: models.StageTypeSimple},
|
||||||
}
|
}
|
||||||
for i := range stages {
|
for i := range stages {
|
||||||
@@ -51,7 +51,7 @@ func seedEngineFixture(t *testing.T, slug string) (string, string, string) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
snapshot, _ := json.Marshal(stages)
|
snapshot, _ := json.Marshal(map[string]any{"stages": stages})
|
||||||
ver := &models.WorkflowVersion{WorkflowID: wf.ID, VersionNumber: 1, Snapshot: snapshot}
|
ver := &models.WorkflowVersion{WorkflowID: wf.ID, VersionNumber: 1, Snapshot: snapshot}
|
||||||
if err := s.Workflows.Publish(ctx, ver); err != nil {
|
if err := s.Workflows.Publish(ctx, ver); err != nil {
|
||||||
t.Fatalf("publish: %v", err)
|
t.Fatalf("publish: %v", err)
|
||||||
@@ -134,13 +134,13 @@ func TestEngine_BranchRouting(t *testing.T) {
|
|||||||
|
|
||||||
stages := []models.WorkflowStage{
|
stages := []models.WorkflowStage{
|
||||||
{WorkflowID: wf.ID, Ordinal: 0, Name: "intake", StageMode: models.StageModeForm, Audience: models.AudienceTeam, StageType: models.StageTypeSimple, BranchRules: branchRules},
|
{WorkflowID: wf.ID, Ordinal: 0, Name: "intake", StageMode: models.StageModeForm, Audience: models.AudienceTeam, StageType: models.StageTypeSimple, BranchRules: branchRules},
|
||||||
{WorkflowID: wf.ID, Ordinal: 1, Name: "normal-review", StageMode: models.StageModeReview, Audience: models.AudienceTeam, StageType: models.StageTypeSimple},
|
{WorkflowID: wf.ID, Ordinal: 1, Name: "normal-review", StageMode: models.StageModeForm, Audience: models.AudienceTeam, StageType: models.StageTypeSimple},
|
||||||
{WorkflowID: wf.ID, Ordinal: 2, Name: "escalation", StageMode: models.StageModeReview, Audience: models.AudienceTeam, StageType: models.StageTypeSimple},
|
{WorkflowID: wf.ID, Ordinal: 2, Name: "escalation", StageMode: models.StageModeForm, Audience: models.AudienceTeam, StageType: models.StageTypeSimple},
|
||||||
}
|
}
|
||||||
for i := range stages {
|
for i := range stages {
|
||||||
s.Workflows.CreateStage(ctx, &stages[i])
|
s.Workflows.CreateStage(ctx, &stages[i])
|
||||||
}
|
}
|
||||||
snapshot, _ := json.Marshal(stages)
|
snapshot, _ := json.Marshal(map[string]any{"stages": stages})
|
||||||
s.Workflows.Publish(ctx, &models.WorkflowVersion{WorkflowID: wf.ID, VersionNumber: 1, Snapshot: snapshot})
|
s.Workflows.Publish(ctx, &models.WorkflowVersion{WorkflowID: wf.ID, VersionNumber: 1, Snapshot: snapshot})
|
||||||
|
|
||||||
// Test: priority=high → escalation
|
// Test: priority=high → escalation
|
||||||
@@ -184,12 +184,12 @@ func TestEngine_PublicEntry(t *testing.T) {
|
|||||||
|
|
||||||
stages := []models.WorkflowStage{
|
stages := []models.WorkflowStage{
|
||||||
{WorkflowID: wf.ID, Ordinal: 0, Name: "public-form", StageMode: models.StageModeForm, Audience: models.AudiencePublic, StageType: models.StageTypeSimple},
|
{WorkflowID: wf.ID, Ordinal: 0, Name: "public-form", StageMode: models.StageModeForm, Audience: models.AudiencePublic, StageType: models.StageTypeSimple},
|
||||||
{WorkflowID: wf.ID, Ordinal: 1, Name: "team-review", StageMode: models.StageModeReview, Audience: models.AudienceTeam, StageType: models.StageTypeSimple},
|
{WorkflowID: wf.ID, Ordinal: 1, Name: "team-review", StageMode: models.StageModeForm, Audience: models.AudienceTeam, StageType: models.StageTypeSimple},
|
||||||
}
|
}
|
||||||
for i := range stages {
|
for i := range stages {
|
||||||
s.Workflows.CreateStage(ctx, &stages[i])
|
s.Workflows.CreateStage(ctx, &stages[i])
|
||||||
}
|
}
|
||||||
snapshot, _ := json.Marshal(stages)
|
snapshot, _ := json.Marshal(map[string]any{"stages": stages})
|
||||||
s.Workflows.Publish(ctx, &models.WorkflowVersion{WorkflowID: wf.ID, VersionNumber: 1, Snapshot: snapshot})
|
s.Workflows.Publish(ctx, &models.WorkflowVersion{WorkflowID: wf.ID, VersionNumber: 1, Snapshot: snapshot})
|
||||||
|
|
||||||
// StartPublic
|
// StartPublic
|
||||||
@@ -253,13 +253,13 @@ func TestEngine_SignoffGate(t *testing.T) {
|
|||||||
"validation": map[string]any{"required_approvals": 2},
|
"validation": map[string]any{"required_approvals": 2},
|
||||||
})
|
})
|
||||||
stages := []models.WorkflowStage{
|
stages := []models.WorkflowStage{
|
||||||
{WorkflowID: wf.ID, Ordinal: 0, Name: "gated-stage", StageMode: models.StageModeReview, Audience: models.AudienceTeam, StageType: models.StageTypeSimple, StageConfig: validationCfg},
|
{WorkflowID: wf.ID, Ordinal: 0, Name: "gated-stage", StageMode: models.StageModeForm, Audience: models.AudienceTeam, StageType: models.StageTypeSimple, StageConfig: validationCfg},
|
||||||
{WorkflowID: wf.ID, Ordinal: 1, Name: "done", StageMode: models.StageModeForm, Audience: models.AudienceTeam, StageType: models.StageTypeSimple},
|
{WorkflowID: wf.ID, Ordinal: 1, Name: "done", StageMode: models.StageModeForm, Audience: models.AudienceTeam, StageType: models.StageTypeSimple},
|
||||||
}
|
}
|
||||||
for i := range stages {
|
for i := range stages {
|
||||||
s.Workflows.CreateStage(ctx, &stages[i])
|
s.Workflows.CreateStage(ctx, &stages[i])
|
||||||
}
|
}
|
||||||
snapshot, _ := json.Marshal(stages)
|
snapshot, _ := json.Marshal(map[string]any{"stages": stages})
|
||||||
s.Workflows.Publish(ctx, &models.WorkflowVersion{WorkflowID: wf.ID, VersionNumber: 1, Snapshot: snapshot})
|
s.Workflows.Publish(ctx, &models.WorkflowVersion{WorkflowID: wf.ID, VersionNumber: 1, Snapshot: snapshot})
|
||||||
|
|
||||||
inst, _ := eng.Start(ctx, wf.ID, json.RawMessage(`{}`), user1)
|
inst, _ := eng.Start(ctx, wf.ID, json.RawMessage(`{}`), user1)
|
||||||
@@ -307,13 +307,13 @@ func TestEngine_SignoffRejection(t *testing.T) {
|
|||||||
"validation": map[string]any{"required_approvals": 1, "reject_action": "cancel"},
|
"validation": map[string]any{"required_approvals": 1, "reject_action": "cancel"},
|
||||||
})
|
})
|
||||||
stages := []models.WorkflowStage{
|
stages := []models.WorkflowStage{
|
||||||
{WorkflowID: wf.ID, Ordinal: 0, Name: "review", StageMode: models.StageModeReview, Audience: models.AudienceTeam, StageType: models.StageTypeSimple, StageConfig: validationCfg},
|
{WorkflowID: wf.ID, Ordinal: 0, Name: "review", StageMode: models.StageModeForm, Audience: models.AudienceTeam, StageType: models.StageTypeSimple, StageConfig: validationCfg},
|
||||||
{WorkflowID: wf.ID, Ordinal: 1, Name: "approved", StageMode: models.StageModeForm, Audience: models.AudienceTeam, StageType: models.StageTypeSimple},
|
{WorkflowID: wf.ID, Ordinal: 1, Name: "approved", StageMode: models.StageModeForm, Audience: models.AudienceTeam, StageType: models.StageTypeSimple},
|
||||||
}
|
}
|
||||||
for i := range stages {
|
for i := range stages {
|
||||||
s.Workflows.CreateStage(ctx, &stages[i])
|
s.Workflows.CreateStage(ctx, &stages[i])
|
||||||
}
|
}
|
||||||
snapshot, _ := json.Marshal(stages)
|
snapshot, _ := json.Marshal(map[string]any{"stages": stages})
|
||||||
s.Workflows.Publish(ctx, &models.WorkflowVersion{WorkflowID: wf.ID, VersionNumber: 1, Snapshot: snapshot})
|
s.Workflows.Publish(ctx, &models.WorkflowVersion{WorkflowID: wf.ID, VersionNumber: 1, Snapshot: snapshot})
|
||||||
|
|
||||||
inst, _ := eng.Start(ctx, wf.ID, json.RawMessage(`{}`), userID)
|
inst, _ := eng.Start(ctx, wf.ID, json.RawMessage(`{}`), userID)
|
||||||
@@ -495,7 +495,7 @@ func TestEngine_AutomatedStageContextIncludesStartedBy(t *testing.T) {
|
|||||||
for i := range stages {
|
for i := range stages {
|
||||||
s.Workflows.CreateStage(ctx, &stages[i])
|
s.Workflows.CreateStage(ctx, &stages[i])
|
||||||
}
|
}
|
||||||
snapshot, _ := json.Marshal(stages)
|
snapshot, _ := json.Marshal(map[string]any{"stages": stages})
|
||||||
s.Workflows.Publish(ctx, &models.WorkflowVersion{WorkflowID: wf.ID, VersionNumber: 1, Snapshot: snapshot})
|
s.Workflows.Publish(ctx, &models.WorkflowVersion{WorkflowID: wf.ID, VersionNumber: 1, Snapshot: snapshot})
|
||||||
|
|
||||||
// Engine with no runner — automated stage silently skips, instance stays at auto-stage
|
// Engine with no runner — automated stage silently skips, instance stays at auto-stage
|
||||||
@@ -590,7 +590,7 @@ func TestPackageInstall_ManifestRoundtrip(t *testing.T) {
|
|||||||
{WorkflowID: wf.ID, Ordinal: 1, Name: "classify", StageMode: models.StageModeForm, Audience: models.AudienceTeam, StageType: models.StageTypeSimple, BranchRules: branchRules},
|
{WorkflowID: wf.ID, Ordinal: 1, Name: "classify", StageMode: models.StageModeForm, Audience: models.AudienceTeam, StageType: models.StageTypeSimple, BranchRules: branchRules},
|
||||||
{WorkflowID: wf.ID, Ordinal: 2, Name: "fix-critical", StageMode: models.StageModeForm, Audience: models.AudienceTeam, StageType: models.StageTypeSimple, SLASeconds: &sla},
|
{WorkflowID: wf.ID, Ordinal: 2, Name: "fix-critical", StageMode: models.StageModeForm, Audience: models.AudienceTeam, StageType: models.StageTypeSimple, SLASeconds: &sla},
|
||||||
{WorkflowID: wf.ID, Ordinal: 3, Name: "fix-normal", StageMode: models.StageModeForm, Audience: models.AudienceTeam, StageType: models.StageTypeSimple},
|
{WorkflowID: wf.ID, Ordinal: 3, Name: "fix-normal", StageMode: models.StageModeForm, Audience: models.AudienceTeam, StageType: models.StageTypeSimple},
|
||||||
{WorkflowID: wf.ID, Ordinal: 4, Name: "verify", StageMode: models.StageModeReview, Audience: models.AudienceTeam, StageType: models.StageTypeSimple},
|
{WorkflowID: wf.ID, Ordinal: 4, Name: "verify", StageMode: models.StageModeForm, Audience: models.AudienceTeam, StageType: models.StageTypeSimple},
|
||||||
}
|
}
|
||||||
for i := range stages {
|
for i := range stages {
|
||||||
if err := s.Workflows.CreateStage(ctx, &stages[i]); err != nil {
|
if err := s.Workflows.CreateStage(ctx, &stages[i]); err != nil {
|
||||||
|
|||||||
@@ -78,7 +78,7 @@ func FireOnAdvanceHook(
|
|||||||
// Parse stage_data into Starlark dict
|
// Parse stage_data into Starlark dict
|
||||||
var dataMap map[string]interface{}
|
var dataMap map[string]interface{}
|
||||||
if json.Unmarshal(stageData, &dataMap) == nil {
|
if json.Unmarshal(stageData, &dataMap) == nil {
|
||||||
_ = ctxDict.SetKey(starlark.String("stage_data"), jsonToStarlark(dataMap))
|
_ = ctxDict.SetKey(starlark.String("stage_data"), sandbox.GoToStarlark(dataMap))
|
||||||
} else {
|
} else {
|
||||||
_ = ctxDict.SetKey(starlark.String("stage_data"), starlark.NewDict(0))
|
_ = ctxDict.SetKey(starlark.String("stage_data"), starlark.NewDict(0))
|
||||||
}
|
}
|
||||||
@@ -117,7 +117,7 @@ func parseOnAdvanceResult(val starlark.Value) *OnAdvanceResult {
|
|||||||
// Check for enriched stage_data
|
// Check for enriched stage_data
|
||||||
if sdVal, found, _ := d.Get(starlark.String("stage_data")); found {
|
if sdVal, found, _ := d.Get(starlark.String("stage_data")); found {
|
||||||
if sd, ok := sdVal.(*starlark.Dict); ok {
|
if sd, ok := sdVal.(*starlark.Dict); ok {
|
||||||
goMap := starlarkDictToMap(sd)
|
goMap := sandbox.DictToMap(sd)
|
||||||
if data, err := json.Marshal(goMap); err == nil {
|
if data, err := json.Marshal(goMap); err == nil {
|
||||||
result.EnrichedData = data
|
result.EnrichedData = data
|
||||||
return result
|
return result
|
||||||
@@ -128,44 +128,3 @@ func parseOnAdvanceResult(val starlark.Value) *OnAdvanceResult {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// starlarkDictToMap converts a Starlark dict to a Go map.
|
|
||||||
func starlarkDictToMap(d *starlark.Dict) map[string]any {
|
|
||||||
result := make(map[string]any, d.Len())
|
|
||||||
for _, item := range d.Items() {
|
|
||||||
k, ok := item[0].(starlark.String)
|
|
||||||
if !ok {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
result[string(k)] = starlarkToGo(item[1])
|
|
||||||
}
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
// starlarkToGo converts a Starlark value to a Go value.
|
|
||||||
func starlarkToGo(v starlark.Value) any {
|
|
||||||
switch val := v.(type) {
|
|
||||||
case starlark.NoneType:
|
|
||||||
return nil
|
|
||||||
case starlark.Bool:
|
|
||||||
return bool(val)
|
|
||||||
case starlark.Int:
|
|
||||||
if i, ok := val.Int64(); ok {
|
|
||||||
return i
|
|
||||||
}
|
|
||||||
return val.String()
|
|
||||||
case starlark.Float:
|
|
||||||
return float64(val)
|
|
||||||
case starlark.String:
|
|
||||||
return string(val)
|
|
||||||
case *starlark.List:
|
|
||||||
result := make([]any, val.Len())
|
|
||||||
for i := 0; i < val.Len(); i++ {
|
|
||||||
result[i] = starlarkToGo(val.Index(i))
|
|
||||||
}
|
|
||||||
return result
|
|
||||||
case *starlark.Dict:
|
|
||||||
return starlarkDictToMap(val)
|
|
||||||
default:
|
|
||||||
return v.String()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"go.starlark.net/starlark"
|
"go.starlark.net/starlark"
|
||||||
|
|
||||||
|
"armature/sandbox"
|
||||||
)
|
)
|
||||||
|
|
||||||
// ── parseOnAdvanceResult ────────────────────
|
// ── parseOnAdvanceResult ────────────────────
|
||||||
@@ -82,7 +84,7 @@ func TestParseOnAdvanceResult_EmptyDict(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── starlarkDictToMap ───────────────────────
|
// ── DictToMap ───────────────────────
|
||||||
|
|
||||||
func TestStarlarkDictToMap_NestedStructure(t *testing.T) {
|
func TestStarlarkDictToMap_NestedStructure(t *testing.T) {
|
||||||
inner := starlark.NewDict(1)
|
inner := starlark.NewDict(1)
|
||||||
@@ -98,7 +100,7 @@ func TestStarlarkDictToMap_NestedStructure(t *testing.T) {
|
|||||||
_ = outer.SetKey(starlark.String("nested"), inner)
|
_ = outer.SetKey(starlark.String("nested"), inner)
|
||||||
_ = outer.SetKey(starlark.String("members"), members)
|
_ = outer.SetKey(starlark.String("members"), members)
|
||||||
|
|
||||||
m := starlarkDictToMap(outer)
|
m := sandbox.DictToMap(outer)
|
||||||
|
|
||||||
if m["title"] != "Test" {
|
if m["title"] != "Test" {
|
||||||
t.Errorf("expected title='Test', got %v", m["title"])
|
t.Errorf("expected title='Test', got %v", m["title"])
|
||||||
|
|||||||
@@ -199,7 +199,7 @@ func (h *WorkflowInstanceHandler) enrichInstances(ctx context.Context, instances
|
|||||||
// SLA check from version snapshot
|
// SLA check from version snapshot
|
||||||
ver, _ := h.stores.Workflows.GetVersion(ctx, inst.WorkflowID, inst.WorkflowVersion)
|
ver, _ := h.stores.Workflows.GetVersion(ctx, inst.WorkflowID, inst.WorkflowVersion)
|
||||||
if ver != nil {
|
if ver != nil {
|
||||||
stages := parseSnapshotStagesForView(ver.Snapshot)
|
stages, _ := models.ParseSnapshotStages(ver.Snapshot)
|
||||||
for _, s := range stages {
|
for _, s := range stages {
|
||||||
if s.Name == inst.CurrentStage {
|
if s.Name == inst.CurrentStage {
|
||||||
v.StageName = s.Name
|
v.StageName = s.Name
|
||||||
@@ -217,18 +217,6 @@ func (h *WorkflowInstanceHandler) enrichInstances(ctx context.Context, instances
|
|||||||
return views
|
return views
|
||||||
}
|
}
|
||||||
|
|
||||||
func parseSnapshotStagesForView(snapshot json.RawMessage) []models.WorkflowStage {
|
|
||||||
var wrapped struct {
|
|
||||||
Stages []models.WorkflowStage `json:"stages"`
|
|
||||||
}
|
|
||||||
if err := json.Unmarshal(snapshot, &wrapped); err == nil && len(wrapped.Stages) > 0 {
|
|
||||||
return wrapped.Stages
|
|
||||||
}
|
|
||||||
var stages []models.WorkflowStage
|
|
||||||
json.Unmarshal(snapshot, &stages)
|
|
||||||
return stages
|
|
||||||
}
|
|
||||||
|
|
||||||
// CancelTeamInstance cancels an instance belonging to a team workflow.
|
// CancelTeamInstance cancels an instance belonging to a team workflow.
|
||||||
// POST /api/v1/teams/:teamId/workflow-instances/:iid/cancel
|
// POST /api/v1/teams/:teamId/workflow-instances/:iid/cancel
|
||||||
func (h *WorkflowInstanceHandler) CancelTeamInstance(c *gin.Context) {
|
func (h *WorkflowInstanceHandler) CancelTeamInstance(c *gin.Context) {
|
||||||
|
|||||||
@@ -55,7 +55,6 @@ func (h *WorkflowPackageHandler) ExportWorkflowPackage(c *gin.Context) {
|
|||||||
"ordinal": s.Ordinal,
|
"ordinal": s.Ordinal,
|
||||||
"stage_mode": s.StageMode,
|
"stage_mode": s.StageMode,
|
||||||
"audience": s.Audience,
|
"audience": s.Audience,
|
||||||
"stage_type": s.StageType,
|
|
||||||
"auto_transition": s.AutoTransition,
|
"auto_transition": s.AutoTransition,
|
||||||
}
|
}
|
||||||
if s.AssignmentTeamID != nil {
|
if s.AssignmentTeamID != nil {
|
||||||
@@ -230,6 +229,7 @@ func InstallWorkflowFromManifest(ctx *gin.Context, stores store.Stores, pkgID st
|
|||||||
StarlarkHook: s.StarlarkHook,
|
StarlarkHook: s.StarlarkHook,
|
||||||
SLASeconds: s.SLASeconds,
|
SLASeconds: s.SLASeconds,
|
||||||
}
|
}
|
||||||
|
st.StageMode = models.NormalizeStageModeInput(st.StageMode)
|
||||||
if st.StageMode == "" {
|
if st.StageMode == "" {
|
||||||
st.StageMode = models.StageModeDelegated
|
st.StageMode = models.StageModeDelegated
|
||||||
}
|
}
|
||||||
@@ -256,7 +256,7 @@ func InstallWorkflowFromManifest(ctx *gin.Context, stores store.Stores, pkgID st
|
|||||||
// Publish version 1 (snapshot of stages) and activate the workflow
|
// Publish version 1 (snapshot of stages) and activate the workflow
|
||||||
// so it's immediately usable after install.
|
// so it's immediately usable after install.
|
||||||
stages, _ := stores.Workflows.ListStages(reqCtx, workflowID)
|
stages, _ := stores.Workflows.ListStages(reqCtx, workflowID)
|
||||||
snapshot, _ := json.Marshal(stages)
|
snapshot, _ := json.Marshal(map[string]any{"stages": stages})
|
||||||
ver := &models.WorkflowVersion{
|
ver := &models.WorkflowVersion{
|
||||||
WorkflowID: workflowID,
|
WorkflowID: workflowID,
|
||||||
VersionNumber: 1,
|
VersionNumber: 1,
|
||||||
@@ -284,7 +284,7 @@ type workflowPkgStage struct {
|
|||||||
Ordinal int `json:"ordinal"`
|
Ordinal int `json:"ordinal"`
|
||||||
StageMode string `json:"stage_mode"`
|
StageMode string `json:"stage_mode"`
|
||||||
Audience string `json:"audience"`
|
Audience string `json:"audience"`
|
||||||
StageType string `json:"stage_type"`
|
StageType string `json:"stage_type"` // deprecated — kept for backward-compat deserialization
|
||||||
AutoTransition bool `json:"auto_transition"`
|
AutoTransition bool `json:"auto_transition"`
|
||||||
AssignmentTeamID *string `json:"assignment_team_id,omitempty"`
|
AssignmentTeamID *string `json:"assignment_team_id,omitempty"`
|
||||||
SurfacePkgID *string `json:"surface_pkg_id,omitempty"`
|
SurfacePkgID *string `json:"surface_pkg_id,omitempty"`
|
||||||
|
|||||||
@@ -69,7 +69,7 @@ func seedWorkflowFixture(t *testing.T, s store.Stores, userID, teamID string) (s
|
|||||||
WorkflowID: wf.ID,
|
WorkflowID: wf.ID,
|
||||||
Ordinal: 1,
|
Ordinal: 1,
|
||||||
Name: "review",
|
Name: "review",
|
||||||
StageMode: models.StageModeReview,
|
StageMode: models.StageModeForm,
|
||||||
Audience: models.AudienceTeam,
|
Audience: models.AudienceTeam,
|
||||||
StageType: models.StageTypeSimple,
|
StageType: models.StageTypeSimple,
|
||||||
AssignmentTeamID: &teamID,
|
AssignmentTeamID: &teamID,
|
||||||
@@ -82,7 +82,7 @@ func seedWorkflowFixture(t *testing.T, s store.Stores, userID, teamID string) (s
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Publish version 1
|
// Publish version 1
|
||||||
snapshot, _ := json.Marshal([]models.WorkflowStage{*s1, *s2})
|
snapshot, _ := json.Marshal(map[string]any{"stages": []models.WorkflowStage{*s1, *s2}})
|
||||||
ver := &models.WorkflowVersion{
|
ver := &models.WorkflowVersion{
|
||||||
WorkflowID: wf.ID,
|
WorkflowID: wf.ID,
|
||||||
VersionNumber: 1,
|
VersionNumber: 1,
|
||||||
|
|||||||
@@ -45,7 +45,13 @@ func (h *WorkflowHandler) requireTeamWorkflow(c *gin.Context) bool {
|
|||||||
// AdoptTeamWorkflow clones a global (team_id=NULL) workflow into this team.
|
// AdoptTeamWorkflow clones a global (team_id=NULL) workflow into this team.
|
||||||
// The global original is left untouched so other teams can also adopt it.
|
// The global original is left untouched so other teams can also adopt it.
|
||||||
// POST /api/v1/teams/:teamId/workflows/:id/adopt
|
// POST /api/v1/teams/:teamId/workflows/:id/adopt
|
||||||
|
//
|
||||||
|
// Deprecated: Use POST /api/v1/teams/:teamId/packages/:id/adopt instead.
|
||||||
|
// This endpoint will be removed in a future version.
|
||||||
func (h *WorkflowHandler) AdoptTeamWorkflow(c *gin.Context) {
|
func (h *WorkflowHandler) AdoptTeamWorkflow(c *gin.Context) {
|
||||||
|
c.Header("X-Deprecated", "Use POST /api/v1/teams/:teamId/packages/:id/adopt instead")
|
||||||
|
log.Printf("[workflows] DEPRECATED: AdoptTeamWorkflow called for team %s — use POST /teams/:teamId/packages/:id/adopt", c.Param("teamId"))
|
||||||
|
|
||||||
ctx := c.Request.Context()
|
ctx := c.Request.Context()
|
||||||
teamID := c.Param("teamId")
|
teamID := c.Param("teamId")
|
||||||
srcID := c.Param("id")
|
srcID := c.Param("id")
|
||||||
|
|||||||
@@ -202,11 +202,12 @@ func (h *WorkflowHandler) CreateStage(c *gin.Context) {
|
|||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "name is required"})
|
c.JSON(http.StatusBadRequest, gin.H{"error": "name is required"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
st.StageMode = models.NormalizeStageModeInput(st.StageMode)
|
||||||
if st.StageMode == "" {
|
if st.StageMode == "" {
|
||||||
st.StageMode = models.StageModeDelegated
|
st.StageMode = models.StageModeDelegated
|
||||||
}
|
}
|
||||||
if !models.ValidStageModes[st.StageMode] {
|
if !models.ValidStageModes[st.StageMode] {
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "stage_mode must be form, review, delegated, or automated"})
|
c.JSON(http.StatusBadRequest, gin.H{"error": "stage_mode must be form, delegated, or automated"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if st.Audience == "" {
|
if st.Audience == "" {
|
||||||
@@ -219,18 +220,10 @@ func (h *WorkflowHandler) CreateStage(c *gin.Context) {
|
|||||||
if st.StageType == "" {
|
if st.StageType == "" {
|
||||||
st.StageType = models.StageTypeSimple
|
st.StageType = models.StageTypeSimple
|
||||||
}
|
}
|
||||||
if !models.ValidStageTypes[st.StageType] {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "stage_type must be simple, dynamic, or automated"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if st.StageMode == models.StageModeDelegated && st.SurfacePkgID == nil {
|
if st.StageMode == models.StageModeDelegated && st.SurfacePkgID == nil {
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "delegated mode requires surface_pkg_id"})
|
c.JSON(http.StatusBadRequest, gin.H{"error": "delegated mode requires surface_pkg_id"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (st.StageType == models.StageTypeDynamic || st.StageType == models.StageTypeAutomated) && st.StarlarkHook == nil {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "dynamic/automated stage_type requires starlark_hook"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if st.Ordinal == 0 {
|
if st.Ordinal == 0 {
|
||||||
existing, _ := h.stores.Workflows.ListStages(c.Request.Context(), st.WorkflowID)
|
existing, _ := h.stores.Workflows.ListStages(c.Request.Context(), st.WorkflowID)
|
||||||
st.Ordinal = len(existing)
|
st.Ordinal = len(existing)
|
||||||
@@ -252,26 +245,19 @@ func (h *WorkflowHandler) UpdateStage(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
st.ID = c.Param("sid")
|
st.ID = c.Param("sid")
|
||||||
st.WorkflowID = c.Param("id")
|
st.WorkflowID = c.Param("id")
|
||||||
|
st.StageMode = models.NormalizeStageModeInput(st.StageMode)
|
||||||
if st.StageMode != "" && !models.ValidStageModes[st.StageMode] {
|
if st.StageMode != "" && !models.ValidStageModes[st.StageMode] {
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "stage_mode must be form, review, delegated, or automated"})
|
c.JSON(http.StatusBadRequest, gin.H{"error": "stage_mode must be form, delegated, or automated"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if st.Audience != "" && !models.ValidAudiences[st.Audience] {
|
if st.Audience != "" && !models.ValidAudiences[st.Audience] {
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "audience must be team, public, or system"})
|
c.JSON(http.StatusBadRequest, gin.H{"error": "audience must be team, public, or system"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if st.StageType != "" && !models.ValidStageTypes[st.StageType] {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "stage_type must be simple, dynamic, or automated"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if st.StageMode == models.StageModeDelegated && st.SurfacePkgID == nil {
|
if st.StageMode == models.StageModeDelegated && st.SurfacePkgID == nil {
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "delegated mode requires surface_pkg_id"})
|
c.JSON(http.StatusBadRequest, gin.H{"error": "delegated mode requires surface_pkg_id"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (st.StageType == models.StageTypeDynamic || st.StageType == models.StageTypeAutomated) && st.StarlarkHook == nil {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "dynamic/automated stage_type requires starlark_hook"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if err := h.stores.Workflows.UpdateStage(c.Request.Context(), &st); err != nil {
|
if err := h.stores.Workflows.UpdateStage(c.Request.Context(), &st); err != nil {
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update stage"})
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update stage"})
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -451,6 +451,7 @@ func main() {
|
|||||||
|
|
||||||
// ── Workflow Engine (shared by public + protected routes) ──
|
// ── Workflow Engine (shared by public + protected routes) ──
|
||||||
wfEngine := workflow.NewEngine(stores, bus, starlarkRunner)
|
wfEngine := workflow.NewEngine(stores, bus, starlarkRunner)
|
||||||
|
starlarkRunner.SetWorkflowEngine(wfEngine)
|
||||||
|
|
||||||
// ── Public Workflow Entry ─────
|
// ── Public Workflow Entry ─────
|
||||||
publicWfH := handlers.NewWorkflowPublicHandler(wfEngine, stores)
|
publicWfH := handlers.NewWorkflowPublicHandler(wfEngine, stores)
|
||||||
@@ -545,6 +546,10 @@ func main() {
|
|||||||
protected.POST("/instances/:iid/signoffs", wfSignoffH.Submit)
|
protected.POST("/instances/:iid/signoffs", wfSignoffH.Submit)
|
||||||
protected.GET("/instances/:iid/signoffs", wfSignoffH.List)
|
protected.GET("/instances/:iid/signoffs", wfSignoffH.List)
|
||||||
|
|
||||||
|
// Forms validation
|
||||||
|
formsH := &handlers.FormsHandler{}
|
||||||
|
protected.POST("/forms/validate", formsH.Validate)
|
||||||
|
|
||||||
// Surface discovery
|
// Surface discovery
|
||||||
pkgH := handlers.NewPackageHandler(stores)
|
pkgH := handlers.NewPackageHandler(stores)
|
||||||
protected.GET("/surfaces", pkgH.ListEnabledSurfaces)
|
protected.GET("/surfaces", pkgH.ListEnabledSurfaces)
|
||||||
@@ -640,6 +645,9 @@ func main() {
|
|||||||
teamScoped.POST("/members", teams.AddMember)
|
teamScoped.POST("/members", teams.AddMember)
|
||||||
teamScoped.PUT("/members/:memberId", teams.UpdateMember)
|
teamScoped.PUT("/members/:memberId", teams.UpdateMember)
|
||||||
teamScoped.DELETE("/members/:memberId", teams.RemoveMember)
|
teamScoped.DELETE("/members/:memberId", teams.RemoveMember)
|
||||||
|
teamScoped.GET("/members/:memberId/roles", teams.ListMemberRoles)
|
||||||
|
teamScoped.POST("/members/:memberId/roles", teams.AddMemberRole)
|
||||||
|
teamScoped.DELETE("/members/:memberId/roles/:role", teams.RemoveMemberRole)
|
||||||
teamScoped.GET("/roles", teams.ListRoles)
|
teamScoped.GET("/roles", teams.ListRoles)
|
||||||
teamScoped.PUT("/roles", teams.UpdateRoles)
|
teamScoped.PUT("/roles", teams.UpdateRoles)
|
||||||
|
|
||||||
@@ -657,6 +665,13 @@ func main() {
|
|||||||
teamScoped.POST("/packages/install", teamPkgH.InstallTeamPackage)
|
teamScoped.POST("/packages/install", teamPkgH.InstallTeamPackage)
|
||||||
teamScoped.DELETE("/packages/:id", teamPkgH.DeleteTeamPackage)
|
teamScoped.DELETE("/packages/:id", teamPkgH.DeleteTeamPackage)
|
||||||
|
|
||||||
|
// Team package adoption
|
||||||
|
adoptH := handlers.NewPackageAdoptHandler(stores)
|
||||||
|
teamScoped.POST("/packages/:id/adopt", adoptH.AdoptPackage)
|
||||||
|
teamScoped.GET("/packages/adoptable", adoptH.ListAdoptablePackages)
|
||||||
|
teamScoped.DELETE("/packages/:id/unadopt", adoptH.UnadoptPackage)
|
||||||
|
teamScoped.GET("/roles/catalog", adoptH.ListRoleCatalog)
|
||||||
|
|
||||||
// Team package settings — cascade overrides
|
// Team package settings — cascade overrides
|
||||||
teamPkgSettingsH := handlers.NewTeamPackageSettingsHandler(stores)
|
teamPkgSettingsH := handlers.NewTeamPackageSettingsHandler(stores)
|
||||||
teamScoped.GET("/packages/:id/settings", teamPkgSettingsH.GetTeamPackageSettings)
|
teamScoped.GET("/packages/:id/settings", teamPkgSettingsH.GetTeamPackageSettings)
|
||||||
@@ -958,12 +973,16 @@ func main() {
|
|||||||
Session: middleware.OptionalAuth(cfg, stores.Users, userCache),
|
Session: middleware.OptionalAuth(cfg, stores.Users, userCache),
|
||||||
})
|
})
|
||||||
|
|
||||||
// Mounted at /s/:slug/api/* with JWT auth (returns 401, not redirect).
|
// Extension surface + API routes: /s/:slug/* dispatches between
|
||||||
|
// surface rendering (GET) and ext API calls (all methods, /api/* prefix).
|
||||||
{
|
{
|
||||||
extAPIH := handlers.NewExtAPIHandler(stores, starlarkRunner)
|
extAPIH := handlers.NewExtAPIHandler(stores, starlarkRunner)
|
||||||
extAPI := base.Group("/s/:slug/api")
|
pageEngine.RegisterExtensionRoutes(
|
||||||
extAPI.Use(middleware.Auth(cfg, stores.Users, userCache, stores.APITokens))
|
base,
|
||||||
extAPI.Any("/*path", extAPIH.Handle)
|
middleware.OptionalAuth(cfg, stores.Users, userCache),
|
||||||
|
middleware.Auth(cfg, stores.Users, userCache, stores.APITokens),
|
||||||
|
extAPIH.Handle,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
bp := cfg.BasePath
|
bp := cfg.BasePath
|
||||||
|
|||||||
@@ -54,6 +54,51 @@ func RequireTeamAdmin(teams store.TeamStore, allStores ...store.Stores) gin.Hand
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// RequireRole returns middleware that restricts access to users holding at least
|
||||||
|
// one of the specified roles in the team identified by :teamId.
|
||||||
|
// Checks both the primary role (team_members.role) and additional roles
|
||||||
|
// (team_user_roles). System admins are always allowed through.
|
||||||
|
func RequireRole(teams store.TeamStore, roles []string, allStores ...store.Stores) gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
if len(allStores) > 0 && isSystemAdmin(c, allStores[0]) {
|
||||||
|
c.Next()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
userID := c.GetString("user_id")
|
||||||
|
teamID := c.Param("teamId")
|
||||||
|
if teamID == "" {
|
||||||
|
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{
|
||||||
|
"error": "team ID required",
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
memberRoles, err := teams.GetMemberRoles(c.Request.Context(), teamID, userID)
|
||||||
|
if err != nil || len(memberRoles) == 0 {
|
||||||
|
c.AbortWithStatusJSON(http.StatusForbidden, gin.H{
|
||||||
|
"error": "required role not held",
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
roleSet := make(map[string]bool, len(memberRoles))
|
||||||
|
for _, r := range memberRoles {
|
||||||
|
roleSet[r] = true
|
||||||
|
}
|
||||||
|
for _, required := range roles {
|
||||||
|
if roleSet[required] {
|
||||||
|
c.Next()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
c.AbortWithStatusJSON(http.StatusForbidden, gin.H{
|
||||||
|
"error": "required role not held",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// RequireTeamMember returns middleware that restricts access to users who
|
// RequireTeamMember returns middleware that restricts access to users who
|
||||||
// belong to the team identified by :teamId (any role).
|
// belong to the team identified by :teamId (any role).
|
||||||
// System admins are always allowed through.
|
// System admins are always allowed through.
|
||||||
|
|||||||
@@ -80,6 +80,16 @@ type TeamMember struct {
|
|||||||
Username string `json:"username,omitempty"`
|
Username string `json:"username,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TeamUserRole is a many-to-many join: one user can hold multiple roles in a team.
|
||||||
|
type TeamUserRole struct {
|
||||||
|
ID string `json:"id" db:"id"`
|
||||||
|
TeamID string `json:"team_id" db:"team_id"`
|
||||||
|
UserID string `json:"user_id" db:"user_id"`
|
||||||
|
Role string `json:"role" db:"role"`
|
||||||
|
AssignedBy string `json:"assigned_by,omitempty" db:"assigned_by"`
|
||||||
|
AssignedAt string `json:"assigned_at" db:"assigned_at"`
|
||||||
|
}
|
||||||
|
|
||||||
// PLATFORM POLICIES
|
// PLATFORM POLICIES
|
||||||
|
|
||||||
var PolicyDefaults = map[string]string{
|
var PolicyDefaults = map[string]string{
|
||||||
|
|||||||
25
server/models/snapshot.go
Normal file
25
server/models/snapshot.go
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
package models
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ParseSnapshotStages parses a version snapshot into a slice of
|
||||||
|
// WorkflowStage. It handles both the wrapped format
|
||||||
|
// {"stages": [...], "workflow": {...}} and the legacy flat array [...].
|
||||||
|
func ParseSnapshotStages(raw json.RawMessage) ([]WorkflowStage, error) {
|
||||||
|
// Try wrapped format first
|
||||||
|
var wrapped struct {
|
||||||
|
Stages []WorkflowStage `json:"stages"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(raw, &wrapped); err == nil && len(wrapped.Stages) > 0 {
|
||||||
|
return wrapped.Stages, nil
|
||||||
|
}
|
||||||
|
// Fallback to flat array
|
||||||
|
var stages []WorkflowStage
|
||||||
|
if err := json.Unmarshal(raw, &stages); err != nil {
|
||||||
|
return nil, fmt.Errorf("corrupt version snapshot: %w", err)
|
||||||
|
}
|
||||||
|
return stages, nil
|
||||||
|
}
|
||||||
@@ -2,9 +2,6 @@ package models
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
|
||||||
"regexp"
|
|
||||||
"strings"
|
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -59,9 +56,9 @@ type WorkflowStage struct {
|
|||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
AssignmentTeamID *string `json:"assignment_team_id,omitempty"`
|
AssignmentTeamID *string `json:"assignment_team_id,omitempty"`
|
||||||
FormTemplate json.RawMessage `json:"form_template"`
|
FormTemplate json.RawMessage `json:"form_template"`
|
||||||
StageMode string `json:"stage_mode"` // form | review | delegated | automated
|
StageMode string `json:"stage_mode"` // form | delegated | automated
|
||||||
Audience string `json:"audience"` // team | public | system
|
Audience string `json:"audience"` // team | public | system
|
||||||
StageType string `json:"stage_type"` // simple | dynamic | automated
|
StageType string `json:"stage_type"` // deprecated — retained for backward compatibility
|
||||||
AutoTransition bool `json:"auto_transition"`
|
AutoTransition bool `json:"auto_transition"`
|
||||||
StageConfig json.RawMessage `json:"stage_config"`
|
StageConfig json.RawMessage `json:"stage_config"`
|
||||||
BranchRules json.RawMessage `json:"branch_rules"`
|
BranchRules json.RawMessage `json:"branch_rules"`
|
||||||
@@ -75,7 +72,6 @@ type WorkflowStage struct {
|
|||||||
|
|
||||||
const (
|
const (
|
||||||
StageModeForm = "form"
|
StageModeForm = "form"
|
||||||
StageModeReview = "review"
|
|
||||||
StageModeDelegated = "delegated"
|
StageModeDelegated = "delegated"
|
||||||
StageModeAutomated = "automated"
|
StageModeAutomated = "automated"
|
||||||
)
|
)
|
||||||
@@ -83,12 +79,22 @@ const (
|
|||||||
// ValidStageModes is the set of valid stage_mode values.
|
// ValidStageModes is the set of valid stage_mode values.
|
||||||
var ValidStageModes = map[string]bool{
|
var ValidStageModes = map[string]bool{
|
||||||
StageModeForm: true,
|
StageModeForm: true,
|
||||||
StageModeReview: true,
|
|
||||||
StageModeDelegated: true,
|
StageModeDelegated: true,
|
||||||
StageModeAutomated: true,
|
StageModeAutomated: true,
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Stage Type Constants ────────────────────
|
// NormalizeStageModeInput maps deprecated stage_mode values to their
|
||||||
|
// replacements. "review" → "form"; all others pass through unchanged.
|
||||||
|
func NormalizeStageModeInput(mode string) string {
|
||||||
|
if mode == "review" {
|
||||||
|
return StageModeForm
|
||||||
|
}
|
||||||
|
return mode
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Stage Type Constants (deprecated) ───────
|
||||||
|
// stage_type is redundant with starlark_hook presence and is no longer
|
||||||
|
// validated. Constants are retained for backward-compatible references.
|
||||||
|
|
||||||
const (
|
const (
|
||||||
StageTypeSimple = "simple"
|
StageTypeSimple = "simple"
|
||||||
@@ -96,13 +102,6 @@ const (
|
|||||||
StageTypeAutomated = "automated"
|
StageTypeAutomated = "automated"
|
||||||
)
|
)
|
||||||
|
|
||||||
// ValidStageTypes is the set of valid stage_type values.
|
|
||||||
var ValidStageTypes = map[string]bool{
|
|
||||||
StageTypeSimple: true,
|
|
||||||
StageTypeDynamic: true,
|
|
||||||
StageTypeAutomated: true,
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Audience Constants ──────────────────────
|
// ── Audience Constants ──────────────────────
|
||||||
|
|
||||||
const (
|
const (
|
||||||
@@ -118,320 +117,6 @@ var ValidAudiences = map[string]bool{
|
|||||||
AudienceSystem: true,
|
AudienceSystem: true,
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Typed Form Template ─────────────────────
|
|
||||||
|
|
||||||
// TypedFormTemplate is the structured form_template schema.
|
|
||||||
type TypedFormTemplate struct {
|
|
||||||
Fields []FormField `json:"fields"`
|
|
||||||
Fieldsets []FormFieldset `json:"fieldsets,omitempty"`
|
|
||||||
Hooks *FormHooks `json:"hooks,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// FormFieldset groups fields into a labelled step for progressive forms.
|
|
||||||
// When fieldsets is present, top-level fields is ignored.
|
|
||||||
type FormFieldset struct {
|
|
||||||
Label string `json:"label"`
|
|
||||||
Fields []FormField `json:"fields"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// FieldCondition controls conditional visibility of a form field.
|
|
||||||
// When set, the field is shown/validated only if the condition is met.
|
|
||||||
type FieldCondition struct {
|
|
||||||
When string `json:"when"` // key of the field to check
|
|
||||||
Op string `json:"op,omitempty"` // eq (default) | neq | in | exists
|
|
||||||
Value any `json:"value,omitempty"` // value to compare against
|
|
||||||
}
|
|
||||||
|
|
||||||
// FormField is a single field in a typed form template.
|
|
||||||
type FormField struct {
|
|
||||||
Key string `json:"key"`
|
|
||||||
Type string `json:"type"` // text | email | select | number | date | textarea | checkbox | file
|
|
||||||
Label string `json:"label"`
|
|
||||||
Placeholder string `json:"placeholder,omitempty"`
|
|
||||||
Required bool `json:"required,omitempty"`
|
|
||||||
Default interface{} `json:"default,omitempty"`
|
|
||||||
Options []FormOption `json:"options,omitempty"`
|
|
||||||
Validation *FormValidation `json:"validation,omitempty"`
|
|
||||||
Condition *FieldCondition `json:"condition,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// FormOption is a choice for select fields.
|
|
||||||
type FormOption struct {
|
|
||||||
Value string `json:"value"`
|
|
||||||
Label string `json:"label"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// FormValidation holds type-specific validation rules.
|
|
||||||
type FormValidation struct {
|
|
||||||
MinLength *int `json:"min_length,omitempty"` // text, textarea, email
|
|
||||||
MaxLength *int `json:"max_length,omitempty"` // text, textarea, email
|
|
||||||
Pattern string `json:"pattern,omitempty"` // text, email (regex)
|
|
||||||
Min *float64 `json:"min,omitempty"` // number
|
|
||||||
Max *float64 `json:"max,omitempty"` // number
|
|
||||||
Step *float64 `json:"step,omitempty"` // number
|
|
||||||
MinDate string `json:"min_date,omitempty"` // date (YYYY-MM-DD)
|
|
||||||
MaxDate string `json:"max_date,omitempty"` // date (YYYY-MM-DD)
|
|
||||||
Accept string `json:"accept,omitempty"` // file (MIME types)
|
|
||||||
MaxSize *int64 `json:"max_size_bytes,omitempty"` // file
|
|
||||||
}
|
|
||||||
|
|
||||||
// FormHooks wires Starlark validation/submission hooks for a stage's form.
|
|
||||||
type FormHooks struct {
|
|
||||||
PackageID string `json:"package_id"`
|
|
||||||
Validate string `json:"validate,omitempty"` // entry point name
|
|
||||||
OnSubmit string `json:"on_submit,omitempty"` // entry point name
|
|
||||||
}
|
|
||||||
|
|
||||||
// ValidFormFieldTypes is the set of recognized field types.
|
|
||||||
var ValidFormFieldTypes = map[string]bool{
|
|
||||||
"text": true, "email": true, "select": true, "number": true,
|
|
||||||
"date": true, "textarea": true, "checkbox": true, "file": true,
|
|
||||||
}
|
|
||||||
|
|
||||||
// ParseTypedFormTemplate parses a raw JSON form_template into the typed schema.
|
|
||||||
// Returns nil if the template is empty, legacy format, or not a typed template.
|
|
||||||
func ParseTypedFormTemplate(raw json.RawMessage) *TypedFormTemplate {
|
|
||||||
if len(raw) == 0 || string(raw) == "{}" || string(raw) == "null" {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
var tpl TypedFormTemplate
|
|
||||||
if err := json.Unmarshal(raw, &tpl); err != nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(tpl.Fieldsets) > 0 {
|
|
||||||
var allFields []FormField
|
|
||||||
for _, fs := range tpl.Fieldsets {
|
|
||||||
allFields = append(allFields, fs.Fields...)
|
|
||||||
}
|
|
||||||
if len(allFields) > 0 {
|
|
||||||
tpl.Fields = allFields
|
|
||||||
return &tpl
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(tpl.Fields) == 0 {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
// Verify this is actually the typed format (first field must have key+type)
|
|
||||||
if tpl.Fields[0].Key == "" || tpl.Fields[0].Type == "" {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return &tpl
|
|
||||||
}
|
|
||||||
|
|
||||||
// AllFields returns all fields from the template, whether from top-level fields or fieldsets.
|
|
||||||
func (t *TypedFormTemplate) AllFields() []FormField {
|
|
||||||
return t.Fields // ParseTypedFormTemplate already flattens fieldsets into Fields
|
|
||||||
}
|
|
||||||
|
|
||||||
// FieldError is a validation error for a specific form field.
|
|
||||||
type FieldError struct {
|
|
||||||
Key string `json:"key"`
|
|
||||||
Message string `json:"message"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// ValidateFormData validates submitted data against a typed form template.
|
|
||||||
func ValidateFormData(tpl *TypedFormTemplate, data map[string]interface{}) []FieldError {
|
|
||||||
var errs []FieldError
|
|
||||||
for _, f := range tpl.Fields {
|
|
||||||
if f.Condition != nil && !evaluateFieldCondition(f.Condition, data) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
val, present := data[f.Key]
|
|
||||||
|
|
||||||
// Required check
|
|
||||||
if f.Required && (!present || val == nil || val == "") {
|
|
||||||
errs = append(errs, FieldError{Key: f.Key, Message: f.Label + " is required"})
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if !present || val == nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
switch f.Type {
|
|
||||||
case "text", "textarea":
|
|
||||||
errs = append(errs, validateString(f, val)...)
|
|
||||||
case "email":
|
|
||||||
errs = append(errs, validateEmail(f, val)...)
|
|
||||||
case "number":
|
|
||||||
errs = append(errs, validateNumber(f, val)...)
|
|
||||||
case "date":
|
|
||||||
errs = append(errs, validateDate(f, val)...)
|
|
||||||
case "select":
|
|
||||||
errs = append(errs, validateSelect(f, val)...)
|
|
||||||
case "checkbox":
|
|
||||||
if _, ok := val.(bool); !ok {
|
|
||||||
errs = append(errs, FieldError{Key: f.Key, Message: f.Label + " must be true or false"})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return errs
|
|
||||||
}
|
|
||||||
|
|
||||||
func validateString(f FormField, val interface{}) []FieldError {
|
|
||||||
s, ok := val.(string)
|
|
||||||
if !ok {
|
|
||||||
return []FieldError{{Key: f.Key, Message: f.Label + " must be text"}}
|
|
||||||
}
|
|
||||||
var errs []FieldError
|
|
||||||
v := f.Validation
|
|
||||||
if v == nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
if v.MinLength != nil && len(s) < *v.MinLength {
|
|
||||||
errs = append(errs, FieldError{Key: f.Key, Message: fmt.Sprintf("%s must be at least %d characters", f.Label, *v.MinLength)})
|
|
||||||
}
|
|
||||||
if v.MaxLength != nil && len(s) > *v.MaxLength {
|
|
||||||
errs = append(errs, FieldError{Key: f.Key, Message: fmt.Sprintf("%s must be at most %d characters", f.Label, *v.MaxLength)})
|
|
||||||
}
|
|
||||||
if v.Pattern != "" {
|
|
||||||
if re, err := regexp.Compile(v.Pattern); err == nil && !re.MatchString(s) {
|
|
||||||
errs = append(errs, FieldError{Key: f.Key, Message: f.Label + " does not match the required pattern"})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return errs
|
|
||||||
}
|
|
||||||
|
|
||||||
var emailRe = regexp.MustCompile(`^[^@\s]+@[^@\s]+\.[^@\s]+$`)
|
|
||||||
|
|
||||||
func validateEmail(f FormField, val interface{}) []FieldError {
|
|
||||||
s, ok := val.(string)
|
|
||||||
if !ok {
|
|
||||||
return []FieldError{{Key: f.Key, Message: f.Label + " must be text"}}
|
|
||||||
}
|
|
||||||
if !emailRe.MatchString(s) {
|
|
||||||
return []FieldError{{Key: f.Key, Message: f.Label + " must be a valid email address"}}
|
|
||||||
}
|
|
||||||
var errs []FieldError
|
|
||||||
if f.Validation != nil {
|
|
||||||
if f.Validation.Pattern != "" {
|
|
||||||
if re, err := regexp.Compile(f.Validation.Pattern); err == nil && !re.MatchString(s) {
|
|
||||||
errs = append(errs, FieldError{Key: f.Key, Message: f.Label + " does not match the required pattern"})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return errs
|
|
||||||
}
|
|
||||||
|
|
||||||
func validateNumber(f FormField, val interface{}) []FieldError {
|
|
||||||
var n float64
|
|
||||||
switch v := val.(type) {
|
|
||||||
case float64:
|
|
||||||
n = v
|
|
||||||
case int:
|
|
||||||
n = float64(v)
|
|
||||||
case json.Number:
|
|
||||||
var err error
|
|
||||||
n, err = v.Float64()
|
|
||||||
if err != nil {
|
|
||||||
return []FieldError{{Key: f.Key, Message: f.Label + " must be a number"}}
|
|
||||||
}
|
|
||||||
case string:
|
|
||||||
return []FieldError{{Key: f.Key, Message: f.Label + " must be a number"}}
|
|
||||||
default:
|
|
||||||
return []FieldError{{Key: f.Key, Message: f.Label + " must be a number"}}
|
|
||||||
}
|
|
||||||
var errs []FieldError
|
|
||||||
if f.Validation != nil {
|
|
||||||
if f.Validation.Min != nil && n < *f.Validation.Min {
|
|
||||||
errs = append(errs, FieldError{Key: f.Key, Message: fmt.Sprintf("%s must be at least %g", f.Label, *f.Validation.Min)})
|
|
||||||
}
|
|
||||||
if f.Validation.Max != nil && n > *f.Validation.Max {
|
|
||||||
errs = append(errs, FieldError{Key: f.Key, Message: fmt.Sprintf("%s must be at most %g", f.Label, *f.Validation.Max)})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return errs
|
|
||||||
}
|
|
||||||
|
|
||||||
func validateDate(f FormField, val interface{}) []FieldError {
|
|
||||||
s, ok := val.(string)
|
|
||||||
if !ok {
|
|
||||||
return []FieldError{{Key: f.Key, Message: f.Label + " must be a date string (YYYY-MM-DD)"}}
|
|
||||||
}
|
|
||||||
_, err := time.Parse("2006-01-02", s)
|
|
||||||
if err != nil {
|
|
||||||
return []FieldError{{Key: f.Key, Message: f.Label + " must be a valid date (YYYY-MM-DD)"}}
|
|
||||||
}
|
|
||||||
if f.Validation != nil {
|
|
||||||
if f.Validation.MinDate != "" {
|
|
||||||
if minD, e := time.Parse("2006-01-02", f.Validation.MinDate); e == nil {
|
|
||||||
if d, _ := time.Parse("2006-01-02", s); d.Before(minD) {
|
|
||||||
return []FieldError{{Key: f.Key, Message: fmt.Sprintf("%s must be on or after %s", f.Label, f.Validation.MinDate)}}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if f.Validation.MaxDate != "" {
|
|
||||||
if maxD, e := time.Parse("2006-01-02", f.Validation.MaxDate); e == nil {
|
|
||||||
if d, _ := time.Parse("2006-01-02", s); d.After(maxD) {
|
|
||||||
return []FieldError{{Key: f.Key, Message: fmt.Sprintf("%s must be on or before %s", f.Label, f.Validation.MaxDate)}}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func validateSelect(f FormField, val interface{}) []FieldError {
|
|
||||||
s, ok := val.(string)
|
|
||||||
if !ok {
|
|
||||||
return []FieldError{{Key: f.Key, Message: f.Label + " must be a string"}}
|
|
||||||
}
|
|
||||||
if len(f.Options) == 0 {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
for _, o := range f.Options {
|
|
||||||
if strings.EqualFold(o.Value, s) {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return []FieldError{{Key: f.Key, Message: f.Label + " is not a valid option"}}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Field Condition Evaluator ─────
|
|
||||||
|
|
||||||
// evaluateFieldCondition checks if a conditional field should be visible/validated.
|
|
||||||
func evaluateFieldCondition(cond *FieldCondition, data map[string]interface{}) bool {
|
|
||||||
val, exists := data[cond.When]
|
|
||||||
op := cond.Op
|
|
||||||
if op == "" {
|
|
||||||
op = "eq"
|
|
||||||
}
|
|
||||||
|
|
||||||
switch op {
|
|
||||||
case "exists":
|
|
||||||
return exists
|
|
||||||
case "eq":
|
|
||||||
if !exists {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
return fmt.Sprintf("%v", val) == fmt.Sprintf("%v", cond.Value)
|
|
||||||
case "neq":
|
|
||||||
if !exists {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
return fmt.Sprintf("%v", val) != fmt.Sprintf("%v", cond.Value)
|
|
||||||
case "in":
|
|
||||||
if !exists {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
arr, ok := cond.Value.([]interface{})
|
|
||||||
if !ok {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
vs := fmt.Sprintf("%v", val)
|
|
||||||
for _, item := range arr {
|
|
||||||
if fmt.Sprintf("%v", item) == vs {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
default:
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Instance Status Constants ───────────────
|
// ── Instance Status Constants ───────────────
|
||||||
|
|
||||||
const (
|
const (
|
||||||
|
|||||||
@@ -99,7 +99,9 @@ type PageData struct {
|
|||||||
Theme string // "dark", "light", or "" (= use default)
|
Theme string // "dark", "light", or "" (= use default)
|
||||||
SurfaceSettings any // JSON-serialized to window.__SETTINGS__
|
SurfaceSettings any // JSON-serialized to window.__SETTINGS__
|
||||||
|
|
||||||
Manifest *SurfaceManifest `json:"manifest,omitempty"`
|
Manifest *SurfaceManifest `json:"manifest,omitempty"`
|
||||||
|
SurfacePath string `json:"-"` // matched surface path pattern, e.g. "/monitor"
|
||||||
|
SurfaceParams map[string]string `json:"-"` // extracted params, e.g. {"id": "abc"}
|
||||||
|
|
||||||
EnabledSurfaces []string `json:"-"`
|
EnabledSurfaces []string `json:"-"`
|
||||||
|
|
||||||
@@ -158,29 +160,60 @@ func (e *Engine) extensionNavItems() []ExtensionNavItem {
|
|||||||
|
|
||||||
var items []ExtensionNavItem
|
var items []ExtensionNavItem
|
||||||
for _, sr := range surfaces {
|
for _, sr := range surfaces {
|
||||||
if sr.Source == "core" || !sr.Enabled {
|
if sr.Source == "core" || !sr.Enabled || sr.Status != "active" {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
// Only surface and full types have routes — extensions are headless
|
// Only surface and full types have routes — extensions are headless
|
||||||
if sr.Type != "surface" && sr.Type != "full" {
|
if sr.Type != "surface" && sr.Type != "full" {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
route := ""
|
|
||||||
|
basePath := "/s/" + sr.ID
|
||||||
|
title := sr.Title
|
||||||
|
|
||||||
|
// Multi-surface: find the nav entry from surfaces array
|
||||||
if sr.Manifest != nil {
|
if sr.Manifest != nil {
|
||||||
route, _ = sr.Manifest["route"].(string)
|
if rawSurfaces, ok := sr.Manifest["surfaces"].([]any); ok {
|
||||||
}
|
navEntry := findNavSurface(rawSurfaces)
|
||||||
if route == "" {
|
if navEntry != nil {
|
||||||
route = "/s/" + sr.ID
|
if p, ok := navEntry["path"].(string); ok && p != "/" {
|
||||||
|
basePath = "/s/" + sr.ID + p
|
||||||
|
}
|
||||||
|
if t, ok := navEntry["title"].(string); ok && t != "" {
|
||||||
|
title = t
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
items = append(items, ExtensionNavItem{
|
items = append(items, ExtensionNavItem{
|
||||||
ID: sr.ID,
|
ID: sr.ID,
|
||||||
Title: sr.Title,
|
Title: title,
|
||||||
Route: route,
|
Route: basePath,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
return items
|
return items
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// findNavSurface returns the surface entry to use for navigation.
|
||||||
|
// Returns the first entry with nav:true, falling back to the entry with path "/".
|
||||||
|
func findNavSurface(surfaces []any) map[string]any {
|
||||||
|
var root map[string]any
|
||||||
|
for _, raw := range surfaces {
|
||||||
|
s, ok := raw.(map[string]any)
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if nav, ok := s["nav"].(bool); ok && nav {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
if p, ok := s["path"].(string); ok && p == "/" {
|
||||||
|
root = s
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return root
|
||||||
|
}
|
||||||
|
|
||||||
// browserExtensionIDs returns IDs of enabled browser-tier extensions.
|
// browserExtensionIDs returns IDs of enabled browser-tier extensions.
|
||||||
// These extensions have JS scripts that should be injected into every page
|
// These extensions have JS scripts that should be injected into every page
|
||||||
// so they can register renderers with the SDK.
|
// so they can register renderers with the SDK.
|
||||||
@@ -401,8 +434,10 @@ func (e *Engine) RenderSurface(surfaceID string) gin.HandlerFunc {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// RenderExtensionSurface handles /s/:slug — dynamic DB lookup for extension surfaces.
|
// RenderExtensionSurface handles extension surface rendering with multi-surface
|
||||||
// No restart required after installing a surface via admin API.
|
// support. Each surface entry in the manifest can have its own path, access
|
||||||
|
// level, title, and layout. Called by RegisterExtensionRoutes for both root
|
||||||
|
// requests (/s/:slug) and sub-path requests (/s/:slug/monitor).
|
||||||
func (e *Engine) RenderExtensionSurface() gin.HandlerFunc {
|
func (e *Engine) RenderExtensionSurface() gin.HandlerFunc {
|
||||||
return func(c *gin.Context) {
|
return func(c *gin.Context) {
|
||||||
slug := c.Param("slug")
|
slug := c.Param("slug")
|
||||||
@@ -410,6 +445,10 @@ func (e *Engine) RenderExtensionSurface() gin.HandlerFunc {
|
|||||||
c.String(http.StatusNotFound, "Surface not found")
|
c.String(http.StatusNotFound, "Surface not found")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
reqPath := c.Param("path")
|
||||||
|
if reqPath == "" {
|
||||||
|
reqPath = "/"
|
||||||
|
}
|
||||||
|
|
||||||
if e.stores.Packages == nil {
|
if e.stores.Packages == nil {
|
||||||
c.String(http.StatusNotFound, "Surface registry not available")
|
c.String(http.StatusNotFound, "Surface registry not available")
|
||||||
@@ -427,28 +466,80 @@ func (e *Engine) RenderExtensionSurface() gin.HandlerFunc {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
if sr.Source == "core" {
|
if sr.Source == "core" {
|
||||||
// Core surfaces have their own routes — don't serve them here
|
|
||||||
c.String(http.StatusNotFound, "Surface not found: "+slug)
|
c.String(http.StatusNotFound, "Surface not found: "+slug)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Early auth short-circuit ─────────────────────────────
|
||||||
|
// For all-authenticated packages, redirect before surface matching.
|
||||||
|
if rawSurfaces, ok := sr.Manifest["surfaces"].([]any); ok && len(rawSurfaces) > 0 {
|
||||||
|
profile := aggregateAccess(rawSurfaces)
|
||||||
|
if profile == "authenticated" && c.GetString("user_id") == "" {
|
||||||
|
c.Redirect(http.StatusTemporaryRedirect, e.cfg.BasePath+"/login")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Multi-surface resolution ─────────────────────────────
|
||||||
|
var surfacePath string
|
||||||
|
var surfaceParams map[string]string
|
||||||
|
surfaceTitle := sr.Title
|
||||||
|
surfaceAccess := "authenticated"
|
||||||
|
surfaceLayout := "single"
|
||||||
|
|
||||||
|
// Read package-level defaults
|
||||||
|
if pkgAuth, ok := sr.Manifest["auth"].(string); ok && pkgAuth != "" {
|
||||||
|
surfaceAccess = pkgAuth
|
||||||
|
}
|
||||||
|
if pkgLayout, ok := sr.Manifest["layout"].(string); ok && pkgLayout != "" {
|
||||||
|
surfaceLayout = pkgLayout
|
||||||
|
}
|
||||||
|
|
||||||
|
if rawSurfaces, ok := sr.Manifest["surfaces"].([]any); ok && len(rawSurfaces) > 0 {
|
||||||
|
matched, params := matchSurface(rawSurfaces, reqPath)
|
||||||
|
if matched == nil {
|
||||||
|
c.String(http.StatusNotFound, "Page not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
surfacePath, _ = matched["path"].(string)
|
||||||
|
surfaceParams = params
|
||||||
|
if t, ok := matched["title"].(string); ok && t != "" {
|
||||||
|
surfaceTitle = t
|
||||||
|
}
|
||||||
|
if a, ok := matched["access"].(string); ok && a != "" {
|
||||||
|
surfaceAccess = a
|
||||||
|
}
|
||||||
|
if l, ok := matched["layout"].(string); ok && l != "" {
|
||||||
|
surfaceLayout = l
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Legacy package without surfaces — only serve root path
|
||||||
|
if reqPath != "/" {
|
||||||
|
c.String(http.StatusNotFound, "Page not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
surfacePath = "/"
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Access check ─────────────────────────────────────────
|
||||||
|
if !e.evaluateAccess(c, surfaceAccess) {
|
||||||
|
// Redirect unauthenticated users to login; deny others with 403
|
||||||
|
if c.GetString("user_id") == "" {
|
||||||
|
c.Redirect(http.StatusTemporaryRedirect, e.cfg.BasePath+"/login")
|
||||||
|
} else {
|
||||||
|
c.String(http.StatusForbidden, "Access denied")
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
user := e.getUserContext(c)
|
user := e.getUserContext(c)
|
||||||
|
|
||||||
// Build manifest from DB record
|
|
||||||
route, _ := sr.Manifest["route"].(string)
|
|
||||||
if route == "" {
|
|
||||||
route = "/s/" + sr.ID
|
|
||||||
}
|
|
||||||
layout, _ := sr.Manifest["layout"].(string)
|
|
||||||
if layout == "" {
|
|
||||||
layout = "single"
|
|
||||||
}
|
|
||||||
manifest := &SurfaceManifest{
|
manifest := &SurfaceManifest{
|
||||||
ID: sr.ID,
|
ID: sr.ID,
|
||||||
Route: route,
|
Route: "/s/" + sr.ID,
|
||||||
Title: sr.Title,
|
Title: surfaceTitle,
|
||||||
Template: "surface-extension",
|
Template: "surface-extension",
|
||||||
Layout: layout,
|
Layout: surfaceLayout,
|
||||||
Source: "extension",
|
Source: "extension",
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -456,6 +547,8 @@ func (e *Engine) RenderExtensionSurface() gin.HandlerFunc {
|
|||||||
Surface: sr.ID,
|
Surface: sr.ID,
|
||||||
User: user,
|
User: user,
|
||||||
Manifest: manifest,
|
Manifest: manifest,
|
||||||
|
SurfacePath: surfacePath,
|
||||||
|
SurfaceParams: surfaceParams,
|
||||||
EnabledSurfaces: e.EnabledSurfaceIDs(),
|
EnabledSurfaces: e.EnabledSurfaceIDs(),
|
||||||
ExtensionSurfaces: e.extensionNavItems(),
|
ExtensionSurfaces: e.extensionNavItems(),
|
||||||
BrowserExtensions: e.browserExtensionIDs(),
|
BrowserExtensions: e.browserExtensionIDs(),
|
||||||
@@ -463,6 +556,141 @@ func (e *Engine) RenderExtensionSurface() gin.HandlerFunc {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// matchSurface finds the best-matching surface entry for a request path.
|
||||||
|
// Supports Gin-style param segments (/:id matches /abc). Static segments
|
||||||
|
// are preferred over param segments. Returns nil if no surface matches.
|
||||||
|
func matchSurface(surfaces []any, reqPath string) (map[string]any, map[string]string) {
|
||||||
|
reqParts := splitPath(reqPath)
|
||||||
|
|
||||||
|
type candidate struct {
|
||||||
|
surface map[string]any
|
||||||
|
params map[string]string
|
||||||
|
score int // higher = more static segments = better match
|
||||||
|
}
|
||||||
|
|
||||||
|
var best *candidate
|
||||||
|
for _, raw := range surfaces {
|
||||||
|
s, ok := raw.(map[string]any)
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
pattern, _ := s["path"].(string)
|
||||||
|
if pattern == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
patParts := splitPath(pattern)
|
||||||
|
if len(patParts) != len(reqParts) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
params := map[string]string{}
|
||||||
|
score := 0
|
||||||
|
matched := true
|
||||||
|
for i, pp := range patParts {
|
||||||
|
if strings.HasPrefix(pp, ":") {
|
||||||
|
params[pp[1:]] = reqParts[i]
|
||||||
|
} else if pp == reqParts[i] {
|
||||||
|
score++
|
||||||
|
} else {
|
||||||
|
matched = false
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !matched {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if best == nil || score > best.score {
|
||||||
|
best = &candidate{surface: s, params: params, score: score}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if best == nil {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
return best.surface, best.params
|
||||||
|
}
|
||||||
|
|
||||||
|
// splitPath splits a URL path into non-empty segments.
|
||||||
|
// "/" → [], "/monitor" → ["monitor"], "/monitor/:id" → ["monitor", ":id"]
|
||||||
|
func splitPath(p string) []string {
|
||||||
|
var parts []string
|
||||||
|
for _, s := range strings.Split(p, "/") {
|
||||||
|
if s != "" {
|
||||||
|
parts = append(parts, s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return parts
|
||||||
|
}
|
||||||
|
|
||||||
|
// aggregateAccess scans surfaces to determine the package's access profile.
|
||||||
|
// Returns "public" (all public), "authenticated" (all require auth), or "mixed".
|
||||||
|
func aggregateAccess(surfaces []any) string {
|
||||||
|
hasPublic := false
|
||||||
|
hasAuth := false
|
||||||
|
for _, raw := range surfaces {
|
||||||
|
s, ok := raw.(map[string]any)
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
access, _ := s["access"].(string)
|
||||||
|
if access == "" {
|
||||||
|
access = "authenticated" // default
|
||||||
|
}
|
||||||
|
if access == "public" {
|
||||||
|
hasPublic = true
|
||||||
|
} else {
|
||||||
|
hasAuth = true
|
||||||
|
}
|
||||||
|
if hasPublic && hasAuth {
|
||||||
|
return "mixed"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if hasPublic {
|
||||||
|
return "public"
|
||||||
|
}
|
||||||
|
return "authenticated"
|
||||||
|
}
|
||||||
|
|
||||||
|
// evaluateAccess checks whether the current request meets an access requirement.
|
||||||
|
func (e *Engine) evaluateAccess(c *gin.Context, access string) bool {
|
||||||
|
switch {
|
||||||
|
case access == "public":
|
||||||
|
return true
|
||||||
|
case access == "authenticated":
|
||||||
|
return c.GetString("user_id") != ""
|
||||||
|
case access == "admin":
|
||||||
|
return c.GetBool("is_admin")
|
||||||
|
case strings.HasPrefix(access, "group:"):
|
||||||
|
// Group membership check — look for groups set by auth middleware
|
||||||
|
group := strings.TrimPrefix(access, "group:")
|
||||||
|
if groups, exists := c.Get("user_groups"); exists {
|
||||||
|
if gs, ok := groups.([]string); ok {
|
||||||
|
for _, g := range gs {
|
||||||
|
if g == group {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
case strings.HasPrefix(access, "role:"):
|
||||||
|
role := strings.TrimPrefix(access, "role:")
|
||||||
|
userID := c.GetString("user_id")
|
||||||
|
if userID == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if c.GetBool("is_admin") {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if e.stores.Teams == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
has, err := e.stores.Teams.HasRoleInAnyTeam(c.Request.Context(), userID, role)
|
||||||
|
return err == nil && has
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// PageRouteMiddleware holds middleware handlers for each auth level.
|
// PageRouteMiddleware holds middleware handlers for each auth level.
|
||||||
// Passed to RegisterPageRoutes by main.go.
|
// Passed to RegisterPageRoutes by main.go.
|
||||||
type PageRouteMiddleware struct {
|
type PageRouteMiddleware struct {
|
||||||
@@ -502,8 +730,9 @@ func (e *Engine) RegisterPageRoutes(base *gin.RouterGroup, mw PageRouteMiddlewar
|
|||||||
registerRoutes(group, s, handler)
|
registerRoutes(group, s, handler)
|
||||||
}
|
}
|
||||||
|
|
||||||
// No restart needed after installing new surfaces via admin API.
|
// Extension surfaces are registered separately via
|
||||||
group.GET("/s/:slug", e.RenderExtensionSurface())
|
// RegisterExtensionRoutes (called from main.go) to unify
|
||||||
|
// the /s/:slug route tree with the ext API dispatcher.
|
||||||
}
|
}
|
||||||
|
|
||||||
// Admin surfaces — auth + admin role middleware
|
// Admin surfaces — auth + admin role middleware
|
||||||
@@ -553,6 +782,75 @@ func (e *Engine) RegisterPageRoutes(base *gin.RouterGroup, mw PageRouteMiddlewar
|
|||||||
log.Printf("[pages] Registered routes for %d surfaces", len(e.surfaces))
|
log.Printf("[pages] Registered routes for %d surfaces", len(e.surfaces))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// RegisterExtensionRoutes registers the combined extension surface and ext API
|
||||||
|
// routes. A single /s/:slug/*path catch-all dispatches between:
|
||||||
|
// - /s/:slug/api/* → apiAuth middleware + extAPIHandler (JSON API)
|
||||||
|
// - /s/:slug/* → surface handler with per-surface access checks (HTML)
|
||||||
|
//
|
||||||
|
// This avoids Gin route conflicts between the catch-all and the API sub-path.
|
||||||
|
// Must be called AFTER RegisterPageRoutes (which handles core surfaces).
|
||||||
|
func (e *Engine) RegisterExtensionRoutes(
|
||||||
|
base *gin.RouterGroup,
|
||||||
|
optAuth gin.HandlerFunc,
|
||||||
|
apiAuth gin.HandlerFunc,
|
||||||
|
extAPIHandler gin.HandlerFunc,
|
||||||
|
) {
|
||||||
|
surfaceHandler := e.RenderExtensionSurface()
|
||||||
|
|
||||||
|
sGroup := base.Group("/s/:slug")
|
||||||
|
sGroup.Use(optAuth) // populate user context if token present; don't require it
|
||||||
|
|
||||||
|
// Dispatcher handles both root and sub-path requests.
|
||||||
|
// API requests (/s/:slug/api/*) go to extAPIHandler with JWT auth.
|
||||||
|
// Everything else goes to the surface handler with per-surface access checks.
|
||||||
|
dispatch := func(c *gin.Context) {
|
||||||
|
subPath := c.Param("path")
|
||||||
|
if subPath == "" {
|
||||||
|
subPath = "/"
|
||||||
|
}
|
||||||
|
|
||||||
|
// API requests: /s/:slug/api/*
|
||||||
|
if strings.HasPrefix(subPath, "/api/") || subPath == "/api" {
|
||||||
|
apiAuth(c)
|
||||||
|
if c.IsAborted() {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Rewrite the path param so the ext API handler sees the
|
||||||
|
// API-relative path, e.g. "/api/items" → "/items"
|
||||||
|
apiPath := strings.TrimPrefix(subPath, "/api")
|
||||||
|
if apiPath == "" {
|
||||||
|
apiPath = "/"
|
||||||
|
}
|
||||||
|
for i := range c.Params {
|
||||||
|
if c.Params[i].Key == "path" {
|
||||||
|
c.Params[i].Value = apiPath
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
extAPIHandler(c)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Surface requests: GET only
|
||||||
|
if c.Request.Method != http.MethodGet {
|
||||||
|
c.String(http.StatusMethodNotAllowed, "Method not allowed")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
surfaceHandler(c)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Root path: /s/:slug (Gin requires a separate registration since
|
||||||
|
// /*path doesn't match the bare group path)
|
||||||
|
sGroup.GET("", func(c *gin.Context) {
|
||||||
|
c.Params = append(c.Params, gin.Param{Key: "path", Value: "/"})
|
||||||
|
dispatch(c)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Sub-paths: /s/:slug/*path — API and surface dispatch
|
||||||
|
sGroup.Any("/*path", dispatch)
|
||||||
|
}
|
||||||
|
|
||||||
// surfaceHandler returns the appropriate Gin handler for a surface.
|
// surfaceHandler returns the appropriate Gin handler for a surface.
|
||||||
func (e *Engine) surfaceHandler(s SurfaceManifest) gin.HandlerFunc {
|
func (e *Engine) surfaceHandler(s SurfaceManifest) gin.HandlerFunc {
|
||||||
if s.ID == "workflow" {
|
if s.ID == "workflow" {
|
||||||
@@ -637,7 +935,7 @@ type WorkflowPageData struct {
|
|||||||
WorkflowDescription string
|
WorkflowDescription string
|
||||||
SessionID string
|
SessionID string
|
||||||
SessionName string
|
SessionName string
|
||||||
StageMode string // form | review | delegated | automated
|
StageMode string // form | delegated | automated
|
||||||
StageName string
|
StageName string
|
||||||
FormTemplateJSON string // typed form template JSON (empty if delegated)
|
FormTemplateJSON string // typed form template JSON (empty if delegated)
|
||||||
TotalStages int
|
TotalStages int
|
||||||
@@ -664,7 +962,7 @@ type WorkflowLandingPageData struct {
|
|||||||
PersonaName string
|
PersonaName string
|
||||||
PersonaIcon string
|
PersonaIcon string
|
||||||
StageCount int
|
StageCount int
|
||||||
FirstStageMode string // form | review | delegated | automated
|
FirstStageMode string // form | delegated | automated
|
||||||
ResumeURL string // non-empty if visitor has an active session
|
ResumeURL string // non-empty if visitor has an active session
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
488
server/pages/pages_handler_test.go
Normal file
488
server/pages/pages_handler_test.go
Normal file
@@ -0,0 +1,488 @@
|
|||||||
|
package pages
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
|
||||||
|
"armature/config"
|
||||||
|
"armature/models"
|
||||||
|
"armature/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ── mock TeamStore ───────────────────────────────────────────
|
||||||
|
|
||||||
|
// mockTeamStore implements store.TeamStore with a simple role lookup map.
|
||||||
|
// Only HasRoleInAnyTeam is functional; everything else is a no-op stub.
|
||||||
|
type mockTeamStore struct {
|
||||||
|
// userRoles maps userID → set of roles they hold in any team
|
||||||
|
userRoles map[string]map[string]bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func newMockTeamStore() *mockTeamStore {
|
||||||
|
return &mockTeamStore{userRoles: make(map[string]map[string]bool)}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *mockTeamStore) addRole(userID, role string) {
|
||||||
|
if m.userRoles[userID] == nil {
|
||||||
|
m.userRoles[userID] = make(map[string]bool)
|
||||||
|
}
|
||||||
|
m.userRoles[userID][role] = true
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *mockTeamStore) HasRoleInAnyTeam(_ context.Context, userID, role string) (bool, error) {
|
||||||
|
if roles, ok := m.userRoles[userID]; ok {
|
||||||
|
return roles[role], nil
|
||||||
|
}
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stubs — not exercised by surface access tests.
|
||||||
|
func (m *mockTeamStore) Create(context.Context, *models.Team) error { return nil }
|
||||||
|
func (m *mockTeamStore) GetByID(context.Context, string) (*models.Team, error) { return nil, nil }
|
||||||
|
func (m *mockTeamStore) Update(context.Context, string, map[string]interface{}) error { return nil }
|
||||||
|
func (m *mockTeamStore) Delete(context.Context, string) error { return nil }
|
||||||
|
func (m *mockTeamStore) List(context.Context) ([]models.Team, error) { return nil, nil }
|
||||||
|
func (m *mockTeamStore) ListForUser(context.Context, string) ([]models.Team, error) { return nil, nil }
|
||||||
|
func (m *mockTeamStore) AddMember(context.Context, string, string, string) error { return nil }
|
||||||
|
func (m *mockTeamStore) RemoveMember(context.Context, string, string) error { return nil }
|
||||||
|
func (m *mockTeamStore) UpdateMemberRole(context.Context, string, string, string) error { return nil }
|
||||||
|
func (m *mockTeamStore) ListMembers(context.Context, string) ([]models.TeamMember, error) { return nil, nil }
|
||||||
|
func (m *mockTeamStore) GetMember(context.Context, string, string) (*models.TeamMember, error) { return nil, nil }
|
||||||
|
func (m *mockTeamStore) GetUserTeamIDs(context.Context, string) ([]string, error) { return nil, nil }
|
||||||
|
func (m *mockTeamStore) IsTeamAdmin(context.Context, string, string) (bool, error) { return false, nil }
|
||||||
|
func (m *mockTeamStore) IsMember(context.Context, string, string) (bool, error) { return false, nil }
|
||||||
|
func (m *mockTeamStore) Exists(context.Context, string) (bool, error) { return false, nil }
|
||||||
|
func (m *mockTeamStore) UpdateMemberRoleByID(context.Context, string, string, string) (int64, error) { return 0, nil }
|
||||||
|
func (m *mockTeamStore) DeleteMemberByID(context.Context, string, string) (int64, error) { return 0, nil }
|
||||||
|
func (m *mockTeamStore) ListTeamAuditActions(context.Context, string) ([]string, error) { return nil, nil }
|
||||||
|
func (m *mockTeamStore) GetFirstTeamIDForUser(context.Context, string) (string, error) { return "", nil }
|
||||||
|
func (m *mockTeamStore) AddMemberReturningID(context.Context, string, string, string) (string, error) { return "", nil }
|
||||||
|
func (m *mockTeamStore) MergeSettings(context.Context, string, string) error { return nil }
|
||||||
|
func (m *mockTeamStore) AddUserRole(context.Context, string, string, string, string) error { return nil }
|
||||||
|
func (m *mockTeamStore) RemoveUserRole(context.Context, string, string, string) error { return nil }
|
||||||
|
func (m *mockTeamStore) ListUserRoles(context.Context, string, string) ([]string, error) { return nil, nil }
|
||||||
|
func (m *mockTeamStore) GetMemberRoles(context.Context, string, string) ([]string, error) { return nil, nil }
|
||||||
|
func (m *mockTeamStore) HasRole(context.Context, string, string, string) (bool, error) { return false, nil }
|
||||||
|
func (m *mockTeamStore) RemoveAllUserRoles(context.Context, string, string) error { return nil }
|
||||||
|
func (m *mockTeamStore) AddRoleToCatalog(context.Context, string, string, string) error { return nil }
|
||||||
|
func (m *mockTeamStore) ListRoleCatalog(context.Context, string) ([]store.TeamRoleCatalogEntry, error) { return nil, nil }
|
||||||
|
func (m *mockTeamStore) RemoveRoleCatalogBySource(context.Context, string, string) error { return nil }
|
||||||
|
|
||||||
|
// ── mock PackageStore ────────────────────────────────────────
|
||||||
|
|
||||||
|
// mockPackageStore implements store.PackageStore with in-memory data.
|
||||||
|
// Only Get and List are wired; everything else is a no-op stub.
|
||||||
|
type mockPackageStore struct {
|
||||||
|
pkgs map[string]*store.PackageRegistration
|
||||||
|
}
|
||||||
|
|
||||||
|
func newMockPackageStore(pkgs ...*store.PackageRegistration) *mockPackageStore {
|
||||||
|
m := &mockPackageStore{pkgs: make(map[string]*store.PackageRegistration)}
|
||||||
|
for _, p := range pkgs {
|
||||||
|
m.pkgs[p.ID] = p
|
||||||
|
}
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *mockPackageStore) Get(_ context.Context, id string) (*store.PackageRegistration, error) {
|
||||||
|
if p, ok := m.pkgs[id]; ok {
|
||||||
|
return p, nil
|
||||||
|
}
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *mockPackageStore) List(_ context.Context) ([]store.PackageRegistration, error) {
|
||||||
|
var out []store.PackageRegistration
|
||||||
|
for _, p := range m.pkgs {
|
||||||
|
out = append(out, *p)
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *mockPackageStore) ListEnabled(_ context.Context) ([]string, error) {
|
||||||
|
var ids []string
|
||||||
|
for id, p := range m.pkgs {
|
||||||
|
if p.Enabled {
|
||||||
|
ids = append(ids, id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ids, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stubs — not exercised by the pages handler tests.
|
||||||
|
func (m *mockPackageStore) Seed(context.Context, string, string, string, map[string]any) error { return nil }
|
||||||
|
func (m *mockPackageStore) SetEnabled(context.Context, string, bool) error { return nil }
|
||||||
|
func (m *mockPackageStore) SetStatus(context.Context, string, string) error { return nil }
|
||||||
|
func (m *mockPackageStore) Delete(context.Context, string) error { return nil }
|
||||||
|
func (m *mockPackageStore) Create(context.Context, *store.PackageRegistration) error { return nil }
|
||||||
|
func (m *mockPackageStore) Update(context.Context, string, *store.PackageRegistration) error { return nil }
|
||||||
|
func (m *mockPackageStore) ListByType(context.Context, string) ([]store.PackageRegistration, error) { return nil, nil }
|
||||||
|
func (m *mockPackageStore) ListEnabledByType(context.Context, string) ([]store.PackageRegistration, error) { return nil, nil }
|
||||||
|
func (m *mockPackageStore) ListForUser(context.Context, string) ([]store.UserPackage, error) { return nil, nil }
|
||||||
|
func (m *mockPackageStore) GetUserSettings(context.Context, string, string) (*store.PackageUserSettings, error) { return nil, nil }
|
||||||
|
func (m *mockPackageStore) SetUserSettings(context.Context, *store.PackageUserSettings) error { return nil }
|
||||||
|
func (m *mockPackageStore) DeleteUserSettings(context.Context, string, string) error { return nil }
|
||||||
|
func (m *mockPackageStore) ListVisiblePackages(context.Context, string) ([]store.PackageRegistration, error) { return nil, nil }
|
||||||
|
func (m *mockPackageStore) SetSchemaVersion(context.Context, string, int) error { return nil }
|
||||||
|
func (m *mockPackageStore) GetPackageSettings(context.Context, string) (json.RawMessage, error) { return nil, nil }
|
||||||
|
func (m *mockPackageStore) SetPackageSettings(context.Context, string, json.RawMessage) error { return nil }
|
||||||
|
func (m *mockPackageStore) GetTeamSettings(context.Context, string, string) (json.RawMessage, error) { return nil, nil }
|
||||||
|
func (m *mockPackageStore) SetTeamSettings(context.Context, string, string, json.RawMessage) error { return nil }
|
||||||
|
func (m *mockPackageStore) DeleteTeamSettings(context.Context, string, string) error { return nil }
|
||||||
|
func (m *mockPackageStore) ListAdoptable(context.Context) ([]store.PackageRegistration, error) { return nil, nil }
|
||||||
|
func (m *mockPackageStore) GetByAdoptedFrom(context.Context, string, string) (*store.PackageRegistration, error) { return nil, nil }
|
||||||
|
|
||||||
|
// ── test helpers ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
// testEngine creates a minimal Engine with parsed templates and mock stores.
|
||||||
|
func testEngine(t *testing.T, pkgs ...*store.PackageRegistration) *Engine {
|
||||||
|
t.Helper()
|
||||||
|
gin.SetMode(gin.TestMode)
|
||||||
|
e := &Engine{
|
||||||
|
cfg: &config.Config{BasePath: ""},
|
||||||
|
stores: store.Stores{Packages: newMockPackageStore(pkgs...)},
|
||||||
|
loaders: make(map[string]DataLoaderFunc),
|
||||||
|
}
|
||||||
|
e.parseTemplates()
|
||||||
|
e.registerCoreSurfaces()
|
||||||
|
return e
|
||||||
|
}
|
||||||
|
|
||||||
|
// testRouter builds a Gin router with extension routes registered.
|
||||||
|
func testRouter(t *testing.T, pkgs ...*store.PackageRegistration) *gin.Engine {
|
||||||
|
t.Helper()
|
||||||
|
engine := testEngine(t, pkgs...)
|
||||||
|
r := gin.New()
|
||||||
|
|
||||||
|
// OptionalAuth — set user_id if "X-User-ID" header is present (test shortcut)
|
||||||
|
optAuth := func(c *gin.Context) {
|
||||||
|
if uid := c.GetHeader("X-User-ID"); uid != "" {
|
||||||
|
c.Set("user_id", uid)
|
||||||
|
c.Set("role", "user")
|
||||||
|
}
|
||||||
|
c.Next()
|
||||||
|
}
|
||||||
|
|
||||||
|
// API auth — for ext API sub-paths (not exercised in surface tests)
|
||||||
|
apiAuth := func(c *gin.Context) { c.Next() }
|
||||||
|
|
||||||
|
// Ext API handler stub
|
||||||
|
apiHandler := func(c *gin.Context) { c.JSON(200, gin.H{"ok": true}) }
|
||||||
|
|
||||||
|
engine.RegisterExtensionRoutes(r.Group(""), optAuth, apiAuth, apiHandler)
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
|
||||||
|
func doGet(r *gin.Engine, path string, headers ...string) *httptest.ResponseRecorder {
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
req, _ := http.NewRequest("GET", path, nil)
|
||||||
|
for i := 0; i+1 < len(headers); i += 2 {
|
||||||
|
req.Header.Set(headers[i], headers[i+1])
|
||||||
|
}
|
||||||
|
r.ServeHTTP(w, req)
|
||||||
|
return w
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── handler integration tests ───────────────────────────────
|
||||||
|
|
||||||
|
func TestHandler_RootPathRefresh(t *testing.T) {
|
||||||
|
pkg := &store.PackageRegistration{
|
||||||
|
ID: "test-pkg", Title: "Test", Type: "surface", Source: "extension",
|
||||||
|
Enabled: true, Status: "active",
|
||||||
|
Manifest: map[string]any{
|
||||||
|
"surfaces": []any{
|
||||||
|
map[string]any{"path": "/", "access": "authenticated", "title": "Dashboard"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
r := testRouter(t, pkg)
|
||||||
|
w := doGet(r, "/s/test-pkg", "X-User-ID", "user-1")
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Errorf("expected 200, got %d", w.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandler_SubPathStaticRefresh(t *testing.T) {
|
||||||
|
pkg := &store.PackageRegistration{
|
||||||
|
ID: "test-pkg", Title: "Test", Type: "surface", Source: "extension",
|
||||||
|
Enabled: true, Status: "active",
|
||||||
|
Manifest: map[string]any{
|
||||||
|
"surfaces": []any{
|
||||||
|
map[string]any{"path": "/", "access": "authenticated", "title": "Dashboard"},
|
||||||
|
map[string]any{"path": "/monitor", "access": "authenticated", "title": "Monitor"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
r := testRouter(t, pkg)
|
||||||
|
w := doGet(r, "/s/test-pkg/monitor", "X-User-ID", "user-1")
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Errorf("expected 200, got %d", w.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandler_SubPathParamRefresh(t *testing.T) {
|
||||||
|
pkg := &store.PackageRegistration{
|
||||||
|
ID: "test-pkg", Title: "Test", Type: "surface", Source: "extension",
|
||||||
|
Enabled: true, Status: "active",
|
||||||
|
Manifest: map[string]any{
|
||||||
|
"surfaces": []any{
|
||||||
|
map[string]any{"path": "/", "access": "authenticated", "title": "Dashboard"},
|
||||||
|
map[string]any{"path": "/:id", "access": "authenticated", "title": "Detail"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
r := testRouter(t, pkg)
|
||||||
|
w := doGet(r, "/s/test-pkg/abc123", "X-User-ID", "user-1")
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Errorf("expected 200, got %d", w.Code)
|
||||||
|
}
|
||||||
|
body := w.Body.String()
|
||||||
|
if !strings.Contains(body, `"id":"abc123"`) {
|
||||||
|
t.Errorf("expected __SURFACE_PARAMS__ to contain id=abc123")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandler_UnauthRedirect(t *testing.T) {
|
||||||
|
pkg := &store.PackageRegistration{
|
||||||
|
ID: "test-pkg", Title: "Test", Type: "surface", Source: "extension",
|
||||||
|
Enabled: true, Status: "active",
|
||||||
|
Manifest: map[string]any{
|
||||||
|
"surfaces": []any{
|
||||||
|
map[string]any{"path": "/dashboard", "access": "authenticated", "title": "Dashboard"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
r := testRouter(t, pkg)
|
||||||
|
w := doGet(r, "/s/test-pkg/dashboard") // no auth header
|
||||||
|
if w.Code != http.StatusTemporaryRedirect {
|
||||||
|
t.Errorf("expected 307, got %d", w.Code)
|
||||||
|
}
|
||||||
|
loc := w.Header().Get("Location")
|
||||||
|
if !strings.Contains(loc, "/login") {
|
||||||
|
t.Errorf("expected redirect to /login, got %s", loc)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandler_PublicSurfaceAnonymous(t *testing.T) {
|
||||||
|
pkg := &store.PackageRegistration{
|
||||||
|
ID: "test-pkg", Title: "Test", Type: "surface", Source: "extension",
|
||||||
|
Enabled: true, Status: "active",
|
||||||
|
Manifest: map[string]any{
|
||||||
|
"surfaces": []any{
|
||||||
|
map[string]any{"path": "/submit", "access": "public", "title": "Submit"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
r := testRouter(t, pkg)
|
||||||
|
w := doGet(r, "/s/test-pkg/submit") // no auth
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Errorf("expected 200 for public surface, got %d", w.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandler_MixedAccessPackage(t *testing.T) {
|
||||||
|
pkg := &store.PackageRegistration{
|
||||||
|
ID: "test-pkg", Title: "Test", Type: "surface", Source: "extension",
|
||||||
|
Enabled: true, Status: "active",
|
||||||
|
Manifest: map[string]any{
|
||||||
|
"surfaces": []any{
|
||||||
|
map[string]any{"path": "/submit", "access": "public", "title": "Submit"},
|
||||||
|
map[string]any{"path": "/dashboard", "access": "authenticated", "title": "Dashboard"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
r := testRouter(t, pkg)
|
||||||
|
|
||||||
|
// Public surface — anonymous OK
|
||||||
|
w1 := doGet(r, "/s/test-pkg/submit")
|
||||||
|
if w1.Code != http.StatusOK {
|
||||||
|
t.Errorf("public surface: expected 200, got %d", w1.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Authenticated surface — anonymous gets redirect
|
||||||
|
w2 := doGet(r, "/s/test-pkg/dashboard")
|
||||||
|
if w2.Code != http.StatusTemporaryRedirect {
|
||||||
|
t.Errorf("auth surface: expected 307, got %d", w2.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandler_NonMatchingSubPath(t *testing.T) {
|
||||||
|
pkg := &store.PackageRegistration{
|
||||||
|
ID: "test-pkg", Title: "Test", Type: "surface", Source: "extension",
|
||||||
|
Enabled: true, Status: "active",
|
||||||
|
Manifest: map[string]any{
|
||||||
|
"surfaces": []any{
|
||||||
|
map[string]any{"path": "/", "access": "authenticated", "title": "Dashboard"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
r := testRouter(t, pkg)
|
||||||
|
w := doGet(r, "/s/test-pkg/nonexistent", "X-User-ID", "user-1")
|
||||||
|
if w.Code != http.StatusNotFound {
|
||||||
|
t.Errorf("expected 404, got %d", w.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandler_EarlyAuthShortCircuit(t *testing.T) {
|
||||||
|
// All surfaces require auth → early short-circuit before surface matching
|
||||||
|
pkg := &store.PackageRegistration{
|
||||||
|
ID: "test-pkg", Title: "Test", Type: "surface", Source: "extension",
|
||||||
|
Enabled: true, Status: "active",
|
||||||
|
Manifest: map[string]any{
|
||||||
|
"surfaces": []any{
|
||||||
|
map[string]any{"path": "/", "access": "authenticated", "title": "Dashboard"},
|
||||||
|
map[string]any{"path": "/settings", "access": "admin", "title": "Settings"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
r := testRouter(t, pkg)
|
||||||
|
|
||||||
|
// Any path — should redirect before even matching surfaces
|
||||||
|
w := doGet(r, "/s/test-pkg/anything") // no auth
|
||||||
|
if w.Code != http.StatusTemporaryRedirect {
|
||||||
|
t.Errorf("expected 307 (early short-circuit), got %d", w.Code)
|
||||||
|
}
|
||||||
|
loc := w.Header().Get("Location")
|
||||||
|
if !strings.Contains(loc, "/login") {
|
||||||
|
t.Errorf("expected redirect to /login, got %s", loc)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── v0.9.9 — role-based surface access ──────────────────────
|
||||||
|
|
||||||
|
// testRouterWithTeams builds a router whose Engine has a mock TeamStore.
|
||||||
|
// The optAuth middleware also checks X-Admin header to set is_admin.
|
||||||
|
func testRouterWithTeams(t *testing.T, teams *mockTeamStore, pkgs ...*store.PackageRegistration) *gin.Engine {
|
||||||
|
t.Helper()
|
||||||
|
gin.SetMode(gin.TestMode)
|
||||||
|
engine := &Engine{
|
||||||
|
cfg: &config.Config{BasePath: ""},
|
||||||
|
stores: store.Stores{Packages: newMockPackageStore(pkgs...), Teams: teams},
|
||||||
|
loaders: make(map[string]DataLoaderFunc),
|
||||||
|
}
|
||||||
|
engine.parseTemplates()
|
||||||
|
engine.registerCoreSurfaces()
|
||||||
|
|
||||||
|
r := gin.New()
|
||||||
|
optAuth := func(c *gin.Context) {
|
||||||
|
if uid := c.GetHeader("X-User-ID"); uid != "" {
|
||||||
|
c.Set("user_id", uid)
|
||||||
|
c.Set("role", "user")
|
||||||
|
}
|
||||||
|
if c.GetHeader("X-Admin") == "true" {
|
||||||
|
c.Set("is_admin", true)
|
||||||
|
}
|
||||||
|
c.Next()
|
||||||
|
}
|
||||||
|
apiAuth := func(c *gin.Context) { c.Next() }
|
||||||
|
apiHandler := func(c *gin.Context) { c.JSON(200, gin.H{"ok": true}) }
|
||||||
|
engine.RegisterExtensionRoutes(r.Group(""), optAuth, apiAuth, apiHandler)
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandler_RoleAccess_Granted(t *testing.T) {
|
||||||
|
teams := newMockTeamStore()
|
||||||
|
teams.addRole("user-1", "approver")
|
||||||
|
|
||||||
|
pkg := &store.PackageRegistration{
|
||||||
|
ID: "role-pkg", Title: "Role Pkg", Type: "surface", Source: "extension",
|
||||||
|
Enabled: true, Status: "active",
|
||||||
|
Manifest: map[string]any{
|
||||||
|
"surfaces": []any{
|
||||||
|
map[string]any{"path": "/", "access": "role:approver", "title": "Approvals"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
r := testRouterWithTeams(t, teams, pkg)
|
||||||
|
w := doGet(r, "/s/role-pkg", "X-User-ID", "user-1")
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Errorf("expected 200 for user with role, got %d", w.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandler_RoleAccess_Denied(t *testing.T) {
|
||||||
|
teams := newMockTeamStore()
|
||||||
|
// user-2 has no roles
|
||||||
|
|
||||||
|
pkg := &store.PackageRegistration{
|
||||||
|
ID: "role-pkg", Title: "Role Pkg", Type: "surface", Source: "extension",
|
||||||
|
Enabled: true, Status: "active",
|
||||||
|
Manifest: map[string]any{
|
||||||
|
"surfaces": []any{
|
||||||
|
map[string]any{"path": "/", "access": "role:approver", "title": "Approvals"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
r := testRouterWithTeams(t, teams, pkg)
|
||||||
|
w := doGet(r, "/s/role-pkg", "X-User-ID", "user-2")
|
||||||
|
if w.Code != http.StatusForbidden {
|
||||||
|
t.Errorf("expected 403 for user without role, got %d", w.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandler_RoleAccess_Unauthenticated(t *testing.T) {
|
||||||
|
teams := newMockTeamStore()
|
||||||
|
|
||||||
|
pkg := &store.PackageRegistration{
|
||||||
|
ID: "role-pkg", Title: "Role Pkg", Type: "surface", Source: "extension",
|
||||||
|
Enabled: true, Status: "active",
|
||||||
|
Manifest: map[string]any{
|
||||||
|
"surfaces": []any{
|
||||||
|
map[string]any{"path": "/", "access": "role:approver", "title": "Approvals"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
r := testRouterWithTeams(t, teams, pkg)
|
||||||
|
w := doGet(r, "/s/role-pkg") // no auth
|
||||||
|
if w.Code != http.StatusTemporaryRedirect {
|
||||||
|
t.Errorf("expected 307 redirect, got %d", w.Code)
|
||||||
|
}
|
||||||
|
loc := w.Header().Get("Location")
|
||||||
|
if !strings.Contains(loc, "/login") {
|
||||||
|
t.Errorf("expected redirect to /login, got %s", loc)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandler_RoleAccess_AdminBypass(t *testing.T) {
|
||||||
|
teams := newMockTeamStore()
|
||||||
|
// admin-user has no "approver" role but is_admin=true
|
||||||
|
|
||||||
|
pkg := &store.PackageRegistration{
|
||||||
|
ID: "role-pkg", Title: "Role Pkg", Type: "surface", Source: "extension",
|
||||||
|
Enabled: true, Status: "active",
|
||||||
|
Manifest: map[string]any{
|
||||||
|
"surfaces": []any{
|
||||||
|
map[string]any{"path": "/", "access": "role:approver", "title": "Approvals"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
r := testRouterWithTeams(t, teams, pkg)
|
||||||
|
w := doGet(r, "/s/role-pkg", "X-User-ID", "admin-user", "X-Admin", "true")
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Errorf("expected 200 for admin bypass, got %d", w.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandler_RoleAccess_NilTeamStore(t *testing.T) {
|
||||||
|
// Engine with no team store — role access should deny gracefully
|
||||||
|
pkg := &store.PackageRegistration{
|
||||||
|
ID: "role-pkg", Title: "Role Pkg", Type: "surface", Source: "extension",
|
||||||
|
Enabled: true, Status: "active",
|
||||||
|
Manifest: map[string]any{
|
||||||
|
"surfaces": []any{
|
||||||
|
map[string]any{"path": "/", "access": "role:approver", "title": "Approvals"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
r := testRouter(t, pkg) // testRouter uses nil Teams store
|
||||||
|
w := doGet(r, "/s/role-pkg", "X-User-ID", "user-1")
|
||||||
|
if w.Code != http.StatusForbidden {
|
||||||
|
t.Errorf("expected 403 when Teams store is nil, got %d", w.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
226
server/pages/pages_surface_match_test.go
Normal file
226
server/pages/pages_surface_match_test.go
Normal file
@@ -0,0 +1,226 @@
|
|||||||
|
package pages
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ── matchSurface tests ──────────────────────────────────────
|
||||||
|
|
||||||
|
func TestMatchSurface_StaticRoot(t *testing.T) {
|
||||||
|
surfaces := []any{
|
||||||
|
map[string]any{"path": "/", "title": "Dashboard"},
|
||||||
|
map[string]any{"path": "/submit", "title": "Submit"},
|
||||||
|
}
|
||||||
|
s, params := matchSurface(surfaces, "/")
|
||||||
|
if s == nil {
|
||||||
|
t.Fatal("expected match for /")
|
||||||
|
}
|
||||||
|
if s["title"] != "Dashboard" {
|
||||||
|
t.Errorf("expected Dashboard, got %v", s["title"])
|
||||||
|
}
|
||||||
|
if len(params) != 0 {
|
||||||
|
t.Errorf("expected no params, got %v", params)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMatchSurface_StaticPath(t *testing.T) {
|
||||||
|
surfaces := []any{
|
||||||
|
map[string]any{"path": "/", "title": "Dashboard"},
|
||||||
|
map[string]any{"path": "/submit", "title": "Submit"},
|
||||||
|
map[string]any{"path": "/monitor", "title": "Monitor"},
|
||||||
|
}
|
||||||
|
s, _ := matchSurface(surfaces, "/submit")
|
||||||
|
if s == nil {
|
||||||
|
t.Fatal("expected match for /submit")
|
||||||
|
}
|
||||||
|
if s["title"] != "Submit" {
|
||||||
|
t.Errorf("expected Submit, got %v", s["title"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMatchSurface_ParamPath(t *testing.T) {
|
||||||
|
surfaces := []any{
|
||||||
|
map[string]any{"path": "/", "title": "Dashboard"},
|
||||||
|
map[string]any{"path": "/:id", "title": "Detail"},
|
||||||
|
}
|
||||||
|
s, params := matchSurface(surfaces, "/abc123")
|
||||||
|
if s == nil {
|
||||||
|
t.Fatal("expected match for /:id")
|
||||||
|
}
|
||||||
|
if s["title"] != "Detail" {
|
||||||
|
t.Errorf("expected Detail, got %v", s["title"])
|
||||||
|
}
|
||||||
|
if params["id"] != "abc123" {
|
||||||
|
t.Errorf("expected id=abc123, got %v", params["id"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMatchSurface_MultiSegmentParam(t *testing.T) {
|
||||||
|
surfaces := []any{
|
||||||
|
map[string]any{"path": "/monitor", "title": "Monitor"},
|
||||||
|
map[string]any{"path": "/monitor/:id", "title": "Instance Detail"},
|
||||||
|
}
|
||||||
|
s, params := matchSurface(surfaces, "/monitor/inst-42")
|
||||||
|
if s == nil {
|
||||||
|
t.Fatal("expected match for /monitor/:id")
|
||||||
|
}
|
||||||
|
if s["title"] != "Instance Detail" {
|
||||||
|
t.Errorf("expected Instance Detail, got %v", s["title"])
|
||||||
|
}
|
||||||
|
if params["id"] != "inst-42" {
|
||||||
|
t.Errorf("expected id=inst-42, got %v", params["id"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMatchSurface_NoMatch(t *testing.T) {
|
||||||
|
surfaces := []any{
|
||||||
|
map[string]any{"path": "/", "title": "Dashboard"},
|
||||||
|
map[string]any{"path": "/submit", "title": "Submit"},
|
||||||
|
}
|
||||||
|
s, _ := matchSurface(surfaces, "/nonexistent/deep")
|
||||||
|
if s != nil {
|
||||||
|
t.Errorf("expected no match, got %v", s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMatchSurface_StaticPreferredOverParam(t *testing.T) {
|
||||||
|
surfaces := []any{
|
||||||
|
map[string]any{"path": "/:id", "title": "Detail"},
|
||||||
|
map[string]any{"path": "/new", "title": "New"},
|
||||||
|
}
|
||||||
|
s, params := matchSurface(surfaces, "/new")
|
||||||
|
if s == nil {
|
||||||
|
t.Fatal("expected match for /new")
|
||||||
|
}
|
||||||
|
if s["title"] != "New" {
|
||||||
|
t.Errorf("expected static match 'New', got %v", s["title"])
|
||||||
|
}
|
||||||
|
if len(params) != 0 {
|
||||||
|
t.Errorf("expected no params for static match, got %v", params)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMatchSurface_EmptySurfaces(t *testing.T) {
|
||||||
|
s, _ := matchSurface([]any{}, "/")
|
||||||
|
if s != nil {
|
||||||
|
t.Errorf("expected no match for empty surfaces, got %v", s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── splitPath tests ─────────────────────────────────────────
|
||||||
|
|
||||||
|
func TestSplitPath(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
input string
|
||||||
|
want int
|
||||||
|
}{
|
||||||
|
{"/", 0},
|
||||||
|
{"/submit", 1},
|
||||||
|
{"/monitor/:id", 2},
|
||||||
|
{"/:id/edit", 2},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
parts := splitPath(tt.input)
|
||||||
|
if len(parts) != tt.want {
|
||||||
|
t.Errorf("splitPath(%q) = %d parts, want %d", tt.input, len(parts), tt.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── findNavSurface tests ────────────────────────────────────
|
||||||
|
|
||||||
|
func TestFindNavSurface_ExplicitNav(t *testing.T) {
|
||||||
|
surfaces := []any{
|
||||||
|
map[string]any{"path": "/", "title": "Dashboard"},
|
||||||
|
map[string]any{"path": "/monitor", "title": "Monitor", "nav": true},
|
||||||
|
}
|
||||||
|
s := findNavSurface(surfaces)
|
||||||
|
if s == nil {
|
||||||
|
t.Fatal("expected nav surface")
|
||||||
|
}
|
||||||
|
if s["title"] != "Monitor" {
|
||||||
|
t.Errorf("expected Monitor, got %v", s["title"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFindNavSurface_FallbackRoot(t *testing.T) {
|
||||||
|
surfaces := []any{
|
||||||
|
map[string]any{"path": "/submit", "title": "Submit"},
|
||||||
|
map[string]any{"path": "/", "title": "Dashboard"},
|
||||||
|
}
|
||||||
|
s := findNavSurface(surfaces)
|
||||||
|
if s == nil {
|
||||||
|
t.Fatal("expected nav surface")
|
||||||
|
}
|
||||||
|
if s["title"] != "Dashboard" {
|
||||||
|
t.Errorf("expected Dashboard, got %v", s["title"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFindNavSurface_NoMatch(t *testing.T) {
|
||||||
|
surfaces := []any{
|
||||||
|
map[string]any{"path": "/submit", "title": "Submit"},
|
||||||
|
map[string]any{"path": "/admin", "title": "Admin"},
|
||||||
|
}
|
||||||
|
s := findNavSurface(surfaces)
|
||||||
|
if s != nil {
|
||||||
|
t.Errorf("expected nil, got %v", s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── aggregateAccess tests ──────────────────────────────────
|
||||||
|
|
||||||
|
func TestAggregateAccess_AllPublic(t *testing.T) {
|
||||||
|
surfaces := []any{
|
||||||
|
map[string]any{"path": "/", "access": "public"},
|
||||||
|
map[string]any{"path": "/submit", "access": "public"},
|
||||||
|
}
|
||||||
|
if got := aggregateAccess(surfaces); got != "public" {
|
||||||
|
t.Errorf("expected public, got %s", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAggregateAccess_AllAuthenticated(t *testing.T) {
|
||||||
|
surfaces := []any{
|
||||||
|
map[string]any{"path": "/", "access": "authenticated"},
|
||||||
|
map[string]any{"path": "/admin", "access": "admin"},
|
||||||
|
}
|
||||||
|
if got := aggregateAccess(surfaces); got != "authenticated" {
|
||||||
|
t.Errorf("expected authenticated, got %s", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAggregateAccess_Mixed(t *testing.T) {
|
||||||
|
surfaces := []any{
|
||||||
|
map[string]any{"path": "/submit", "access": "public"},
|
||||||
|
map[string]any{"path": "/dashboard", "access": "authenticated"},
|
||||||
|
}
|
||||||
|
if got := aggregateAccess(surfaces); got != "mixed" {
|
||||||
|
t.Errorf("expected mixed, got %s", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAggregateAccess_DefaultAccess(t *testing.T) {
|
||||||
|
// No access field → defaults to "authenticated"
|
||||||
|
surfaces := []any{
|
||||||
|
map[string]any{"path": "/"},
|
||||||
|
}
|
||||||
|
if got := aggregateAccess(surfaces); got != "authenticated" {
|
||||||
|
t.Errorf("expected authenticated (default), got %s", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAggregateAccess_SinglePublic(t *testing.T) {
|
||||||
|
surfaces := []any{
|
||||||
|
map[string]any{"path": "/", "access": "public"},
|
||||||
|
}
|
||||||
|
if got := aggregateAccess(surfaces); got != "public" {
|
||||||
|
t.Errorf("expected public, got %s", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── evaluateAccess tests ────────────────────────────────────
|
||||||
|
// Note: evaluateAccess depends on gin.Context which is hard to unit test
|
||||||
|
// without a full Gin setup. These are covered via handler integration tests.
|
||||||
|
// The matchSurface + splitPath + findNavSurface functions above are the
|
||||||
|
// pure-logic components that benefit most from unit testing.
|
||||||
@@ -122,6 +122,8 @@
|
|||||||
{{/* __PAGE_DATA__ and __USER__ removed in v0.37.19 — SDK boot
|
{{/* __PAGE_DATA__ and __USER__ removed in v0.37.19 — SDK boot
|
||||||
populates sw.auth.user and sw.auth.policies from API. */}}
|
populates sw.auth.user and sw.auth.policies from API. */}}
|
||||||
{{if .Manifest}}window.__MANIFEST__ = {{.Manifest | toJSON}};{{end}}
|
{{if .Manifest}}window.__MANIFEST__ = {{.Manifest | toJSON}};{{end}}
|
||||||
|
{{if .SurfacePath}}window.__SURFACE_PATH__ = '{{.SurfacePath}}';{{end}}
|
||||||
|
{{if .SurfaceParams}}window.__SURFACE_PARAMS__ = {{.SurfaceParams | toJSON}};{{end}}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{{/* All surfaces use Preact SDK boot(). Legacy script includes removed.
|
{{/* All surfaces use Preact SDK boot(). Legacy script includes removed.
|
||||||
|
|||||||
@@ -154,7 +154,7 @@
|
|||||||
{{end}}
|
{{end}}
|
||||||
|
|
||||||
<button class="wf-start-btn" id="startBtn" onclick="startWorkflow()">
|
<button class="wf-start-btn" id="startBtn" onclick="startWorkflow()">
|
||||||
{{if eq .Data.FirstStageMode "form"}}Fill Out Form{{else if eq .Data.FirstStageMode "review"}}Start Review{{else}}Start{{end}}
|
{{if eq .Data.FirstStageMode "form"}}Fill Out Form{{else}}Start{{end}}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{{if .Data.ResumeURL}}
|
{{if .Data.ResumeURL}}
|
||||||
|
|||||||
@@ -178,7 +178,7 @@
|
|||||||
</div>
|
</div>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|
||||||
<!-- Stage surface: form, review, delegated (custom surface), or automated -->
|
<!-- Stage surface: form, delegated (custom surface), or automated -->
|
||||||
|
|
||||||
{{if .Data.AudienceMismatch}}
|
{{if .Data.AudienceMismatch}}
|
||||||
<div class="wf-submitted">
|
<div class="wf-submitted">
|
||||||
@@ -195,11 +195,6 @@
|
|||||||
<div id="customSurfaceMount" style="flex:1;overflow-y:auto"></div>
|
<div id="customSurfaceMount" style="flex:1;overflow-y:auto"></div>
|
||||||
{{else if eq .Data.StageMode "form"}}
|
{{else if eq .Data.StageMode "form"}}
|
||||||
<div class="wf-form" id="formArea"></div>
|
<div class="wf-form" id="formArea"></div>
|
||||||
{{else if eq .Data.StageMode "review"}}
|
|
||||||
<div id="reviewArea" style="flex:1;overflow-y:auto;display:flex">
|
|
||||||
<div id="reviewDataPanel" style="flex:1;overflow-y:auto;border-right:1px solid var(--border)"></div>
|
|
||||||
<div id="reviewActionPanel" style="flex:1;overflow-y:auto;display:flex;flex-direction:column"></div>
|
|
||||||
</div>
|
|
||||||
{{else if eq .Data.Status "completed"}}
|
{{else if eq .Data.Status "completed"}}
|
||||||
<div class="wf-form-success" style="flex:1;display:flex;flex-direction:column;justify-content:center">
|
<div class="wf-form-success" style="flex:1;display:flex;flex-direction:column;justify-content:center">
|
||||||
<h3>Completed</h3>
|
<h3>Completed</h3>
|
||||||
@@ -212,7 +207,7 @@
|
|||||||
{{end}}
|
{{end}}
|
||||||
|
|
||||||
<div class="wf-session-info">
|
<div class="wf-session-info">
|
||||||
{{if .Data.SurfacePkgID}}Session:{{else if eq .Data.StageMode "form"}}Submitting as{{else if eq .Data.StageMode "review"}}Reviewing as{{else}}Session:{{end}} <strong>{{.Data.SessionName}}</strong>
|
{{if .Data.SurfacePkgID}}Session:{{else if eq .Data.StageMode "form"}}Submitting as{{else}}Session:{{end}} <strong>{{.Data.SessionName}}</strong>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -542,116 +537,6 @@
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Review surface (structured review with side-by-side + comments) ──
|
|
||||||
if (STAGE_MODE === 'review') {
|
|
||||||
var dataPanel = document.getElementById('reviewDataPanel');
|
|
||||||
var actionPanel = document.getElementById('reviewActionPanel');
|
|
||||||
if (dataPanel && actionPanel) {
|
|
||||||
loadReviewSurface(dataPanel, actionPanel);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadReviewSurface(dataPanel, actionPanel) {
|
|
||||||
dataPanel.innerHTML = '<div style="padding:24px;text-align:center;color:var(--text-3)">Loading\u2026</div>';
|
|
||||||
|
|
||||||
// Left panel: structured data card
|
|
||||||
var html = '<div style="padding:24px">';
|
|
||||||
html += '<h3 style="margin-bottom:16px">Collected Data</h3>';
|
|
||||||
|
|
||||||
try {
|
|
||||||
var resp = await fetch(BASE + '/api/v1/public/workflows/resume/' + API_ID, {
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
});
|
|
||||||
if (resp.ok) {
|
|
||||||
var status = await resp.json();
|
|
||||||
if (status.stage_data && typeof status.stage_data === 'object') {
|
|
||||||
html += '<table style="width:100%;border-collapse:collapse">';
|
|
||||||
for (var key in status.stage_data) {
|
|
||||||
if (!status.stage_data.hasOwnProperty(key) || key.startsWith('_')) continue;
|
|
||||||
html += '<tr style="border-bottom:1px solid var(--border)">';
|
|
||||||
html += '<td style="padding:8px;font-weight:600;font-size:13px;width:30%;vertical-align:top">' + escHtml(key) + '</td>';
|
|
||||||
var val = status.stage_data[key];
|
|
||||||
var display = typeof val === 'object' ? JSON.stringify(val, null, 2) : String(val);
|
|
||||||
html += '<td style="padding:8px;font-size:13px;white-space:pre-wrap">' + escHtml(display) + '</td>';
|
|
||||||
html += '</tr>';
|
|
||||||
}
|
|
||||||
html += '</table>';
|
|
||||||
} else {
|
|
||||||
html += '<p style="color:var(--text-3)">No data collected yet.</p>';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch(e) {
|
|
||||||
html += '<p style="color:var(--danger)">Failed to load review data.</p>';
|
|
||||||
}
|
|
||||||
html += '</div>';
|
|
||||||
dataPanel.innerHTML = html;
|
|
||||||
|
|
||||||
// Right panel: comment input + approve/reject buttons
|
|
||||||
var actHtml = '<div style="padding:24px;flex:1;display:flex;flex-direction:column">';
|
|
||||||
actHtml += '<h3 style="margin-bottom:16px">Review Actions</h3>';
|
|
||||||
actHtml += '<div style="margin-bottom:16px">';
|
|
||||||
actHtml += '<label style="font-size:13px;font-weight:600;display:block;margin-bottom:4px">Add Comment</label>';
|
|
||||||
actHtml += '<textarea id="reviewComment" rows="3" style="width:100%;padding:8px;font-size:14px;border:1px solid var(--border);border-radius:6px;background:var(--input-bg);color:var(--text);font-family:var(--font);resize:vertical" placeholder="Optional comment\u2026"></textarea>';
|
|
||||||
actHtml += '</div>';
|
|
||||||
actHtml += '<div style="display:flex;gap:8px">';
|
|
||||||
actHtml += '<button id="reviewAdvanceBtn" style="background:var(--accent);color:#fff;border:none;border-radius:8px;padding:10px 24px;font-weight:600;cursor:pointer;font-size:14px">Approve & Advance</button>';
|
|
||||||
actHtml += '<button id="reviewRejectBtn" style="background:var(--danger,#e74c3c);color:#fff;border:none;border-radius:8px;padding:10px 24px;font-weight:600;cursor:pointer;font-size:14px">Reject</button>';
|
|
||||||
actHtml += '</div>';
|
|
||||||
actHtml += '<div style="font-size:12px;color:var(--text-3);margin-top:8px">Ctrl+Enter: Approve · Ctrl+Shift+Enter: Reject</div>';
|
|
||||||
actHtml += '</div>';
|
|
||||||
actionPanel.innerHTML = actHtml;
|
|
||||||
|
|
||||||
// Keyboard shortcuts
|
|
||||||
document.addEventListener('keydown', function(e) {
|
|
||||||
if (e.ctrlKey && e.key === 'Enter') {
|
|
||||||
e.preventDefault();
|
|
||||||
if (e.shiftKey) {
|
|
||||||
document.getElementById('reviewRejectBtn').click();
|
|
||||||
} else {
|
|
||||||
document.getElementById('reviewAdvanceBtn').click();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
document.getElementById('reviewAdvanceBtn').addEventListener('click', async function() {
|
|
||||||
this.disabled = true;
|
|
||||||
try {
|
|
||||||
var r = await fetch(BASE + '/api/v1/public/workflows/advance/' + API_ID, {
|
|
||||||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ data: {} }),
|
|
||||||
});
|
|
||||||
if (r.ok) {
|
|
||||||
actionPanel.innerHTML = '<div style="padding:40px;text-align:center"><h3>Approved</h3><p style="color:var(--text-2)">Stage advanced.</p></div>';
|
|
||||||
setTimeout(function() { window.location.reload(); }, 1500);
|
|
||||||
} else {
|
|
||||||
var err = await r.json().catch(function() { return {}; });
|
|
||||||
alert('Failed: ' + (err.error || 'unknown'));
|
|
||||||
document.getElementById('reviewAdvanceBtn').disabled = false;
|
|
||||||
}
|
|
||||||
} catch(e) { alert('Error: ' + e.message); }
|
|
||||||
});
|
|
||||||
|
|
||||||
document.getElementById('reviewRejectBtn').addEventListener('click', async function() {
|
|
||||||
var comment = document.getElementById('reviewComment').value;
|
|
||||||
var reason = comment || prompt('Rejection reason:');
|
|
||||||
if (!reason) return;
|
|
||||||
this.disabled = true;
|
|
||||||
try {
|
|
||||||
var r = await fetch(BASE + '/api/v1/public/workflows/advance/' + API_ID, {
|
|
||||||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ data: { _action: 'reject', reason: reason } }),
|
|
||||||
});
|
|
||||||
if (r.ok) {
|
|
||||||
actionPanel.innerHTML = '<div style="padding:40px;text-align:center"><h3>Rejected</h3><p style="color:var(--text-2)">Sent back for revision.</p></div>';
|
|
||||||
setTimeout(function() { window.location.reload(); }, 1500);
|
|
||||||
} else {
|
|
||||||
var err = await r.json().catch(function() { return {}; });
|
|
||||||
alert('Failed: ' + (err.error || 'unknown'));
|
|
||||||
document.getElementById('reviewRejectBtn').disabled = false;
|
|
||||||
}
|
|
||||||
} catch(e) { alert('Error: ' + e.message); }
|
|
||||||
});
|
|
||||||
}
|
|
||||||
})();
|
})();
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
101
server/sandbox/convert.go
Normal file
101
server/sandbox/convert.go
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
package sandbox
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"go.starlark.net/starlark"
|
||||||
|
)
|
||||||
|
|
||||||
|
// GoToStarlark converts a Go value (typically from JSON unmarshal) to a
|
||||||
|
// starlark.Value. Handles nil, bool, int, int64, float64, string,
|
||||||
|
// map[string]any, []any, and their interface{} equivalents. Unknown
|
||||||
|
// types are stringified.
|
||||||
|
func GoToStarlark(v any) starlark.Value {
|
||||||
|
switch val := v.(type) {
|
||||||
|
case nil:
|
||||||
|
return starlark.None
|
||||||
|
case bool:
|
||||||
|
return starlark.Bool(val)
|
||||||
|
case int:
|
||||||
|
return starlark.MakeInt(val)
|
||||||
|
case int64:
|
||||||
|
return starlark.MakeInt64(val)
|
||||||
|
case float64:
|
||||||
|
if val == float64(int64(val)) {
|
||||||
|
return starlark.MakeInt64(int64(val))
|
||||||
|
}
|
||||||
|
return starlark.Float(val)
|
||||||
|
case string:
|
||||||
|
return starlark.String(val)
|
||||||
|
case map[string]any:
|
||||||
|
return MapToDict(val)
|
||||||
|
case []any:
|
||||||
|
elems := make([]starlark.Value, len(val))
|
||||||
|
for i, e := range val {
|
||||||
|
elems[i] = GoToStarlark(e)
|
||||||
|
}
|
||||||
|
return starlark.NewList(elems)
|
||||||
|
default:
|
||||||
|
return starlark.String(fmt.Sprintf("%v", val))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// StarlarkToGo converts a starlark.Value to a Go value suitable for
|
||||||
|
// JSON serialization. Handles None, Bool, Int, Float, String, List,
|
||||||
|
// Dict, and Tuple.
|
||||||
|
func StarlarkToGo(v starlark.Value) any {
|
||||||
|
switch val := v.(type) {
|
||||||
|
case starlark.NoneType:
|
||||||
|
return nil
|
||||||
|
case starlark.Bool:
|
||||||
|
return bool(val)
|
||||||
|
case starlark.Int:
|
||||||
|
if i, ok := val.Int64(); ok {
|
||||||
|
return i
|
||||||
|
}
|
||||||
|
return val.String()
|
||||||
|
case starlark.Float:
|
||||||
|
return float64(val)
|
||||||
|
case starlark.String:
|
||||||
|
return string(val)
|
||||||
|
case *starlark.List:
|
||||||
|
result := make([]any, val.Len())
|
||||||
|
for i := 0; i < val.Len(); i++ {
|
||||||
|
result[i] = StarlarkToGo(val.Index(i))
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
case *starlark.Dict:
|
||||||
|
return DictToMap(val)
|
||||||
|
case starlark.Tuple:
|
||||||
|
result := make([]any, len(val))
|
||||||
|
for i, e := range val {
|
||||||
|
result[i] = StarlarkToGo(e)
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
default:
|
||||||
|
return v.String()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// DictToMap converts a *starlark.Dict to map[string]any.
|
||||||
|
// Non-string keys are silently skipped.
|
||||||
|
func DictToMap(d *starlark.Dict) map[string]any {
|
||||||
|
result := make(map[string]any, d.Len())
|
||||||
|
for _, item := range d.Items() {
|
||||||
|
k, ok := starlark.AsString(item[0])
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
result[k] = StarlarkToGo(item[1])
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
// MapToDict converts a map[string]any to *starlark.Dict.
|
||||||
|
func MapToDict(m map[string]any) *starlark.Dict {
|
||||||
|
d := starlark.NewDict(len(m))
|
||||||
|
for k, v := range m {
|
||||||
|
_ = d.SetKey(starlark.String(k), GoToStarlark(v))
|
||||||
|
}
|
||||||
|
return d
|
||||||
|
}
|
||||||
150
server/sandbox/convert_test.go
Normal file
150
server/sandbox/convert_test.go
Normal file
@@ -0,0 +1,150 @@
|
|||||||
|
package sandbox
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"go.starlark.net/starlark"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestGoToStarlark_Primitives(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
in any
|
||||||
|
want string // starlark.Value.String()
|
||||||
|
}{
|
||||||
|
{"nil", nil, "None"},
|
||||||
|
{"bool true", true, "True"},
|
||||||
|
{"bool false", false, "False"},
|
||||||
|
{"int", 42, "42"},
|
||||||
|
{"int64", int64(99), "99"},
|
||||||
|
{"float64 integer", float64(5), "5"},
|
||||||
|
{"float64 fractional", 3.14, "3.14"},
|
||||||
|
{"string", "hello", `"hello"`},
|
||||||
|
{"unknown type", struct{}{}, `"{}"`},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
got := GoToStarlark(tt.in)
|
||||||
|
if got.String() != tt.want {
|
||||||
|
t.Errorf("GoToStarlark(%v) = %s, want %s", tt.in, got.String(), tt.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGoToStarlark_Containers(t *testing.T) {
|
||||||
|
// map[string]any
|
||||||
|
m := map[string]any{"a": 1.0, "b": "two"}
|
||||||
|
d := GoToStarlark(m)
|
||||||
|
if _, ok := d.(*starlark.Dict); !ok {
|
||||||
|
t.Fatalf("expected *starlark.Dict, got %T", d)
|
||||||
|
}
|
||||||
|
|
||||||
|
// []any
|
||||||
|
s := []any{1.0, "x", true}
|
||||||
|
l := GoToStarlark(s)
|
||||||
|
if list, ok := l.(*starlark.List); !ok || list.Len() != 3 {
|
||||||
|
t.Fatalf("expected *starlark.List len 3, got %T", l)
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStarlarkToGo_Primitives(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
in starlark.Value
|
||||||
|
want any
|
||||||
|
}{
|
||||||
|
{"None", starlark.None, nil},
|
||||||
|
{"True", starlark.True, true},
|
||||||
|
{"Int", starlark.MakeInt(7), int64(7)},
|
||||||
|
{"Float", starlark.Float(2.5), 2.5},
|
||||||
|
{"String", starlark.String("hi"), "hi"},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
got := StarlarkToGo(tt.in)
|
||||||
|
if got != tt.want {
|
||||||
|
t.Errorf("StarlarkToGo(%s) = %v (%T), want %v (%T)", tt.in, got, got, tt.want, tt.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStarlarkToGo_Tuple(t *testing.T) {
|
||||||
|
tup := starlark.Tuple{starlark.MakeInt(1), starlark.String("a")}
|
||||||
|
got := StarlarkToGo(tup)
|
||||||
|
arr, ok := got.([]any)
|
||||||
|
if !ok || len(arr) != 2 {
|
||||||
|
t.Fatalf("expected []any len 2, got %T", got)
|
||||||
|
}
|
||||||
|
if arr[0] != int64(1) {
|
||||||
|
t.Errorf("arr[0] = %v, want 1", arr[0])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRoundTrip(t *testing.T) {
|
||||||
|
original := map[string]any{
|
||||||
|
"name": "test",
|
||||||
|
"count": float64(42),
|
||||||
|
"active": true,
|
||||||
|
"tags": []any{"a", "b"},
|
||||||
|
"nested": map[string]any{
|
||||||
|
"x": float64(1),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
sv := GoToStarlark(original)
|
||||||
|
back := StarlarkToGo(sv)
|
||||||
|
m, ok := back.(map[string]any)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("round-trip produced %T, want map[string]any", back)
|
||||||
|
}
|
||||||
|
|
||||||
|
if m["name"] != "test" {
|
||||||
|
t.Errorf("name = %v", m["name"])
|
||||||
|
}
|
||||||
|
if m["count"] != int64(42) {
|
||||||
|
t.Errorf("count = %v (%T)", m["count"], m["count"])
|
||||||
|
}
|
||||||
|
if m["active"] != true {
|
||||||
|
t.Errorf("active = %v", m["active"])
|
||||||
|
}
|
||||||
|
tags, ok := m["tags"].([]any)
|
||||||
|
if !ok || len(tags) != 2 {
|
||||||
|
t.Fatalf("tags = %v (%T)", m["tags"], m["tags"])
|
||||||
|
}
|
||||||
|
nested, ok := m["nested"].(map[string]any)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("nested = %T", m["nested"])
|
||||||
|
}
|
||||||
|
if nested["x"] != int64(1) {
|
||||||
|
t.Errorf("nested.x = %v", nested["x"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDictToMap(t *testing.T) {
|
||||||
|
d := starlark.NewDict(2)
|
||||||
|
_ = d.SetKey(starlark.String("k"), starlark.String("v"))
|
||||||
|
_ = d.SetKey(starlark.MakeInt(1), starlark.String("skip")) // non-string key
|
||||||
|
|
||||||
|
m := DictToMap(d)
|
||||||
|
if len(m) != 1 {
|
||||||
|
t.Errorf("expected 1 entry (non-string key skipped), got %d", len(m))
|
||||||
|
}
|
||||||
|
if m["k"] != "v" {
|
||||||
|
t.Errorf("m[k] = %v", m["k"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMapToDict(t *testing.T) {
|
||||||
|
m := map[string]any{"a": "b", "n": float64(3)}
|
||||||
|
d := MapToDict(m)
|
||||||
|
if d.Len() != 2 {
|
||||||
|
t.Errorf("expected 2 entries, got %d", d.Len())
|
||||||
|
}
|
||||||
|
v, found, _ := d.Get(starlark.String("a"))
|
||||||
|
if !found || v.(starlark.String) != "b" {
|
||||||
|
t.Errorf("dict[a] = %v", v)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -164,7 +164,7 @@ func filesGet(ctx context.Context, cfg FilesModuleConfig) func(*starlark.Thread,
|
|||||||
metaRC.Close()
|
metaRC.Close()
|
||||||
var m map[string]any
|
var m map[string]any
|
||||||
if json.Unmarshal(metaBytes, &m) == nil {
|
if json.Unmarshal(metaBytes, &m) == nil {
|
||||||
metaDict = mapToStarlarkDict(m)
|
metaDict = MapToDict(m)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -207,7 +207,7 @@ func filesMeta(ctx context.Context, cfg FilesModuleConfig) func(*starlark.Thread
|
|||||||
metaRC.Close()
|
metaRC.Close()
|
||||||
var m map[string]any
|
var m map[string]any
|
||||||
if json.Unmarshal(metaBytes, &m) == nil {
|
if json.Unmarshal(metaBytes, &m) == nil {
|
||||||
metaDict = mapToStarlarkDict(m)
|
metaDict = MapToDict(m)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -396,77 +396,13 @@ func filesMaxSize() int64 {
|
|||||||
return filesDefaultMaxSize
|
return filesDefaultMaxSize
|
||||||
}
|
}
|
||||||
|
|
||||||
// starlarkDictToMap converts a *starlark.Dict to map[string]any.
|
// starlarkDictToMap converts a *starlark.Dict to map[string]any, validating
|
||||||
|
// that all keys are strings. Returns an error if a non-string key is found.
|
||||||
func starlarkDictToMap(d *starlark.Dict) (map[string]any, error) {
|
func starlarkDictToMap(d *starlark.Dict) (map[string]any, error) {
|
||||||
m := make(map[string]any, d.Len())
|
|
||||||
for _, item := range d.Items() {
|
for _, item := range d.Items() {
|
||||||
k, ok := starlark.AsString(item[0])
|
if _, ok := starlark.AsString(item[0]); !ok {
|
||||||
if !ok {
|
|
||||||
return nil, fmt.Errorf("metadata keys must be strings, got %s", item[0].Type())
|
return nil, fmt.Errorf("metadata keys must be strings, got %s", item[0].Type())
|
||||||
}
|
}
|
||||||
m[k] = starlarkValueToGo(item[1])
|
|
||||||
}
|
|
||||||
return m, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// starlarkValueToGo converts a starlark.Value to a Go value for JSON serialization.
|
|
||||||
func starlarkValueToGo(v starlark.Value) any {
|
|
||||||
switch x := v.(type) {
|
|
||||||
case starlark.String:
|
|
||||||
return string(x)
|
|
||||||
case starlark.Int:
|
|
||||||
if i, ok := x.Int64(); ok {
|
|
||||||
return i
|
|
||||||
}
|
|
||||||
return x.String()
|
|
||||||
case starlark.Float:
|
|
||||||
return float64(x)
|
|
||||||
case starlark.Bool:
|
|
||||||
return bool(x)
|
|
||||||
case *starlark.List:
|
|
||||||
out := make([]any, x.Len())
|
|
||||||
for i := 0; i < x.Len(); i++ {
|
|
||||||
out[i] = starlarkValueToGo(x.Index(i))
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
case *starlark.Dict:
|
|
||||||
m, _ := starlarkDictToMap(x)
|
|
||||||
return m
|
|
||||||
default:
|
|
||||||
return v.String()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// mapToStarlarkDict converts a map[string]any to *starlark.Dict.
|
|
||||||
func mapToStarlarkDict(m map[string]any) *starlark.Dict {
|
|
||||||
d := starlark.NewDict(len(m))
|
|
||||||
for k, v := range m {
|
|
||||||
d.SetKey(starlark.String(k), goValueToStarlark(v))
|
|
||||||
}
|
|
||||||
return d
|
|
||||||
}
|
|
||||||
|
|
||||||
// goValueToStarlark converts a Go value (from JSON) to starlark.Value.
|
|
||||||
func goValueToStarlark(v any) starlark.Value {
|
|
||||||
switch x := v.(type) {
|
|
||||||
case string:
|
|
||||||
return starlark.String(x)
|
|
||||||
case float64:
|
|
||||||
if x == float64(int64(x)) {
|
|
||||||
return starlark.MakeInt64(int64(x))
|
|
||||||
}
|
|
||||||
return starlark.Float(x)
|
|
||||||
case bool:
|
|
||||||
return starlark.Bool(x)
|
|
||||||
case []any:
|
|
||||||
elems := make([]starlark.Value, len(x))
|
|
||||||
for i, e := range x {
|
|
||||||
elems[i] = goValueToStarlark(e)
|
|
||||||
}
|
|
||||||
return starlark.NewList(elems)
|
|
||||||
case map[string]any:
|
|
||||||
return mapToStarlarkDict(x)
|
|
||||||
default:
|
|
||||||
return starlark.None
|
|
||||||
}
|
}
|
||||||
|
return DictToMap(d), nil
|
||||||
}
|
}
|
||||||
|
|||||||
86
server/sandbox/forms_module.go
Normal file
86
server/sandbox/forms_module.go
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
package sandbox
|
||||||
|
|
||||||
|
// forms_module.go
|
||||||
|
//
|
||||||
|
// Starlark forms module — validates form data against typed form templates.
|
||||||
|
// Gated by the forms.validate extension permission.
|
||||||
|
//
|
||||||
|
// Starlark API:
|
||||||
|
// result = forms.validate(template, data)
|
||||||
|
// # result["valid"] → True/False
|
||||||
|
// # result["errors"] → [{"key": "...", "message": "..."}, ...]
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"go.starlark.net/starlark"
|
||||||
|
"go.starlark.net/starlarkstruct"
|
||||||
|
|
||||||
|
"armature/forms"
|
||||||
|
)
|
||||||
|
|
||||||
|
// BuildFormsModule creates the "forms" Starlark module.
|
||||||
|
// Requires the forms.validate permission.
|
||||||
|
func BuildFormsModule(_ context.Context) *starlarkstruct.Module {
|
||||||
|
return MakeModule("forms", starlark.StringDict{
|
||||||
|
"validate": starlark.NewBuiltin("forms.validate", formsValidateBuiltin()),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func formsValidateBuiltin() func(*starlark.Thread, *starlark.Builtin, starlark.Tuple, []starlark.Tuple) (starlark.Value, error) {
|
||||||
|
return func(_ *starlark.Thread, b *starlark.Builtin, args starlark.Tuple, kwargs []starlark.Tuple) (starlark.Value, error) {
|
||||||
|
var templateVal, dataVal starlark.Value
|
||||||
|
if err := starlark.UnpackPositionalArgs(b.Name(), args, kwargs, 2, &templateVal, &dataVal); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert template dict → Go map → JSON → TypedFormTemplate
|
||||||
|
templateDict, ok := templateVal.(*starlark.Dict)
|
||||||
|
if !ok {
|
||||||
|
return nil, fmt.Errorf("forms.validate: template must be a dict, got %s", templateVal.Type())
|
||||||
|
}
|
||||||
|
templateMap := DictToMap(templateDict)
|
||||||
|
templateJSON, err := json.Marshal(templateMap)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("forms.validate: failed to marshal template: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
tpl := forms.ParseTypedFormTemplate(json.RawMessage(templateJSON))
|
||||||
|
if tpl == nil {
|
||||||
|
return nil, fmt.Errorf("forms.validate: template is invalid or empty")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert data dict → Go map
|
||||||
|
dataDict, ok := dataVal.(*starlark.Dict)
|
||||||
|
if !ok {
|
||||||
|
return nil, fmt.Errorf("forms.validate: data must be a dict, got %s", dataVal.Type())
|
||||||
|
}
|
||||||
|
dataMap := DictToMap(dataDict)
|
||||||
|
|
||||||
|
// Coerce to map[string]interface{}
|
||||||
|
data := make(map[string]interface{}, len(dataMap))
|
||||||
|
for k, v := range dataMap {
|
||||||
|
data[k] = v
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate
|
||||||
|
errs := forms.ValidateFormData(tpl, data)
|
||||||
|
|
||||||
|
// Build result dict
|
||||||
|
errList := make([]starlark.Value, len(errs))
|
||||||
|
for i, e := range errs {
|
||||||
|
d := starlark.NewDict(2)
|
||||||
|
_ = d.SetKey(starlark.String("key"), starlark.String(e.Key))
|
||||||
|
_ = d.SetKey(starlark.String("message"), starlark.String(e.Message))
|
||||||
|
errList[i] = d
|
||||||
|
}
|
||||||
|
|
||||||
|
result := starlark.NewDict(2)
|
||||||
|
_ = result.SetKey(starlark.String("valid"), starlark.Bool(len(errs) == 0))
|
||||||
|
_ = result.SetKey(starlark.String("errors"), starlark.NewList(errList))
|
||||||
|
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
116
server/sandbox/forms_module_test.go
Normal file
116
server/sandbox/forms_module_test.go
Normal file
@@ -0,0 +1,116 @@
|
|||||||
|
package sandbox
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"go.starlark.net/starlark"
|
||||||
|
)
|
||||||
|
|
||||||
|
func runFormsScript(t *testing.T, script string) starlark.StringDict {
|
||||||
|
t.Helper()
|
||||||
|
mod := BuildFormsModule(context.Background())
|
||||||
|
predeclared := starlark.StringDict{"forms": mod}
|
||||||
|
globals, err := starlark.ExecFile(&starlark.Thread{Name: "test"}, "test.star", script, predeclared)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return globals
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFormsModule_ValidateValid(t *testing.T) {
|
||||||
|
globals := runFormsScript(t, `
|
||||||
|
tpl = {
|
||||||
|
"fields": [
|
||||||
|
{"key": "name", "type": "text", "label": "Name", "required": True},
|
||||||
|
{"key": "email", "type": "email", "label": "Email"},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
result = forms.validate(tpl, {"name": "Alice", "email": "alice@example.com"})
|
||||||
|
`)
|
||||||
|
result := globals["result"].(*starlark.Dict)
|
||||||
|
valid, _, _ := result.Get(starlark.String("valid"))
|
||||||
|
if valid != starlark.True {
|
||||||
|
t.Fatalf("expected valid=True, got %v", valid)
|
||||||
|
}
|
||||||
|
errors, _, _ := result.Get(starlark.String("errors"))
|
||||||
|
errList := errors.(*starlark.List)
|
||||||
|
if errList.Len() != 0 {
|
||||||
|
t.Fatalf("expected 0 errors, got %d", errList.Len())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFormsModule_ValidateRequired(t *testing.T) {
|
||||||
|
globals := runFormsScript(t, `
|
||||||
|
tpl = {
|
||||||
|
"fields": [
|
||||||
|
{"key": "name", "type": "text", "label": "Name", "required": True},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
result = forms.validate(tpl, {})
|
||||||
|
`)
|
||||||
|
result := globals["result"].(*starlark.Dict)
|
||||||
|
valid, _, _ := result.Get(starlark.String("valid"))
|
||||||
|
if valid != starlark.False {
|
||||||
|
t.Fatalf("expected valid=False, got %v", valid)
|
||||||
|
}
|
||||||
|
errors, _, _ := result.Get(starlark.String("errors"))
|
||||||
|
errList := errors.(*starlark.List)
|
||||||
|
if errList.Len() != 1 {
|
||||||
|
t.Fatalf("expected 1 error, got %d", errList.Len())
|
||||||
|
}
|
||||||
|
errDict := errList.Index(0).(*starlark.Dict)
|
||||||
|
key, _, _ := errDict.Get(starlark.String("key"))
|
||||||
|
if key.(starlark.String).GoString() != "name" {
|
||||||
|
t.Fatalf("expected error key 'name', got %v", key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFormsModule_ValidateTypeErrors(t *testing.T) {
|
||||||
|
globals := runFormsScript(t, `
|
||||||
|
tpl = {
|
||||||
|
"fields": [
|
||||||
|
{"key": "age", "type": "number", "label": "Age", "validation": {"min": 0, "max": 150}},
|
||||||
|
{"key": "email", "type": "email", "label": "Email"},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
result = forms.validate(tpl, {"age": -5, "email": "bad"})
|
||||||
|
`)
|
||||||
|
result := globals["result"].(*starlark.Dict)
|
||||||
|
valid, _, _ := result.Get(starlark.String("valid"))
|
||||||
|
if valid != starlark.False {
|
||||||
|
t.Fatalf("expected valid=False, got %v", valid)
|
||||||
|
}
|
||||||
|
errors, _, _ := result.Get(starlark.String("errors"))
|
||||||
|
errList := errors.(*starlark.List)
|
||||||
|
if errList.Len() != 2 {
|
||||||
|
t.Fatalf("expected 2 errors, got %d", errList.Len())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFormsModule_ValidateCondition(t *testing.T) {
|
||||||
|
globals := runFormsScript(t, `
|
||||||
|
tpl = {
|
||||||
|
"fields": [
|
||||||
|
{"key": "type", "type": "text", "label": "Type"},
|
||||||
|
{"key": "details", "type": "text", "label": "Details", "required": True,
|
||||||
|
"condition": {"when": "type", "op": "eq", "value": "other"}},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
# Condition not met — details not required
|
||||||
|
result1 = forms.validate(tpl, {"type": "normal"})
|
||||||
|
# Condition met — details required
|
||||||
|
result2 = forms.validate(tpl, {"type": "other"})
|
||||||
|
`)
|
||||||
|
result1 := globals["result1"].(*starlark.Dict)
|
||||||
|
valid1, _, _ := result1.Get(starlark.String("valid"))
|
||||||
|
if valid1 != starlark.True {
|
||||||
|
t.Fatalf("result1: expected valid=True, got %v", valid1)
|
||||||
|
}
|
||||||
|
|
||||||
|
result2 := globals["result2"].(*starlark.Dict)
|
||||||
|
valid2, _, _ := result2.Get(starlark.String("valid"))
|
||||||
|
if valid2 != starlark.False {
|
||||||
|
t.Fatalf("result2: expected valid=False, got %v", valid2)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -83,7 +83,7 @@ func realtimePublish(ctx context.Context, bus *events.Bus, packageID string) fun
|
|||||||
if k == "_pkg" {
|
if k == "_pkg" {
|
||||||
continue // reserved field
|
continue // reserved field
|
||||||
}
|
}
|
||||||
payload[k] = starlarkToGoVal(item[1])
|
payload[k] = StarlarkToGo(item[1])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -108,46 +108,3 @@ func realtimePublish(ctx context.Context, bus *events.Bus, packageID string) fun
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// starlarkToGoVal converts a Starlark value to a Go value for JSON marshaling.
|
|
||||||
func starlarkToGoVal(v starlark.Value) any {
|
|
||||||
switch val := v.(type) {
|
|
||||||
case starlark.NoneType:
|
|
||||||
return nil
|
|
||||||
case starlark.Bool:
|
|
||||||
return bool(val)
|
|
||||||
case starlark.Int:
|
|
||||||
if i, ok := val.Int64(); ok {
|
|
||||||
return i
|
|
||||||
}
|
|
||||||
return val.String()
|
|
||||||
case starlark.Float:
|
|
||||||
return float64(val)
|
|
||||||
case starlark.String:
|
|
||||||
return string(val)
|
|
||||||
case *starlark.Dict:
|
|
||||||
m := make(map[string]any, val.Len())
|
|
||||||
for _, item := range val.Items() {
|
|
||||||
k, ok := starlark.AsString(item[0])
|
|
||||||
if !ok {
|
|
||||||
k = item[0].String()
|
|
||||||
}
|
|
||||||
m[k] = starlarkToGoVal(item[1])
|
|
||||||
}
|
|
||||||
return m
|
|
||||||
case *starlark.List:
|
|
||||||
n := val.Len()
|
|
||||||
s := make([]any, n)
|
|
||||||
for i := 0; i < n; i++ {
|
|
||||||
s[i] = starlarkToGoVal(val.Index(i))
|
|
||||||
}
|
|
||||||
return s
|
|
||||||
case starlark.Tuple:
|
|
||||||
s := make([]any, len(val))
|
|
||||||
for i, v := range val {
|
|
||||||
s[i] = starlarkToGoVal(v)
|
|
||||||
}
|
|
||||||
return s
|
|
||||||
default:
|
|
||||||
return v.String()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -133,7 +133,7 @@ realtime.publish("ch:1", "big.event", {"data": big})
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestStarlarkToGoVal(t *testing.T) {
|
func TestStarlarkToGo_Realtime(t *testing.T) {
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
val starlark.Value
|
val starlark.Value
|
||||||
@@ -149,9 +149,9 @@ func TestStarlarkToGoVal(t *testing.T) {
|
|||||||
|
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
got := starlarkToGoVal(tt.val)
|
got := StarlarkToGo(tt.val)
|
||||||
if got != tt.want {
|
if got != tt.want {
|
||||||
t.Errorf("starlarkToGoVal(%s) = %v (%T), want %v (%T)", tt.val, got, got, tt.want, tt.want)
|
t.Errorf("StarlarkToGo(%s) = %v (%T), want %v (%T)", tt.val, got, got, tt.want, tt.want)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
190
server/sandbox/routing_module.go
Normal file
190
server/sandbox/routing_module.go
Normal file
@@ -0,0 +1,190 @@
|
|||||||
|
package sandbox
|
||||||
|
|
||||||
|
// routing_module.go
|
||||||
|
//
|
||||||
|
// Starlark routing module — a generic rule-based decision engine.
|
||||||
|
// Always available (pure computation, no I/O).
|
||||||
|
//
|
||||||
|
// Starlark API:
|
||||||
|
// result = routing.evaluate(rules, data)
|
||||||
|
// # result → "target_string" or None
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"go.starlark.net/starlark"
|
||||||
|
"go.starlark.net/starlarkstruct"
|
||||||
|
)
|
||||||
|
|
||||||
|
// BuildRoutingModule creates the "routing" Starlark module.
|
||||||
|
// No permission required — pure computation.
|
||||||
|
func BuildRoutingModule() *starlarkstruct.Module {
|
||||||
|
return MakeModule("routing", starlark.StringDict{
|
||||||
|
"evaluate": starlark.NewBuiltin("routing.evaluate", routingEvaluateBuiltin()),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// routingRule is the generic equivalent of workflow.Condition.
|
||||||
|
// Uses "target" instead of "target_stage" to be domain-agnostic.
|
||||||
|
type routingRule struct {
|
||||||
|
Field string
|
||||||
|
Op string
|
||||||
|
Value any
|
||||||
|
Target string
|
||||||
|
}
|
||||||
|
|
||||||
|
func routingEvaluateBuiltin() func(*starlark.Thread, *starlark.Builtin, starlark.Tuple, []starlark.Tuple) (starlark.Value, error) {
|
||||||
|
return func(_ *starlark.Thread, b *starlark.Builtin, args starlark.Tuple, kwargs []starlark.Tuple) (starlark.Value, error) {
|
||||||
|
var rulesVal, dataVal starlark.Value
|
||||||
|
if err := starlark.UnpackPositionalArgs(b.Name(), args, kwargs, 2, &rulesVal, &dataVal); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert rules list → []routingRule
|
||||||
|
rulesList, ok := rulesVal.(*starlark.List)
|
||||||
|
if !ok {
|
||||||
|
return nil, fmt.Errorf("routing.evaluate: rules must be a list, got %s", rulesVal.Type())
|
||||||
|
}
|
||||||
|
|
||||||
|
rules := make([]routingRule, rulesList.Len())
|
||||||
|
for i := 0; i < rulesList.Len(); i++ {
|
||||||
|
ruleDict, ok := rulesList.Index(i).(*starlark.Dict)
|
||||||
|
if !ok {
|
||||||
|
return nil, fmt.Errorf("routing.evaluate: rules[%d] must be a dict, got %s", i, rulesList.Index(i).Type())
|
||||||
|
}
|
||||||
|
m := DictToMap(ruleDict)
|
||||||
|
|
||||||
|
field, _ := m["field"].(string)
|
||||||
|
op, _ := m["op"].(string)
|
||||||
|
target, _ := m["target"].(string)
|
||||||
|
if field == "" || op == "" || target == "" {
|
||||||
|
return nil, fmt.Errorf("routing.evaluate: rules[%d] must have field, op, and target", i)
|
||||||
|
}
|
||||||
|
rules[i] = routingRule{
|
||||||
|
Field: field,
|
||||||
|
Op: op,
|
||||||
|
Value: m["value"],
|
||||||
|
Target: target,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert data dict → Go map
|
||||||
|
dataDict, ok := dataVal.(*starlark.Dict)
|
||||||
|
if !ok {
|
||||||
|
return nil, fmt.Errorf("routing.evaluate: data must be a dict, got %s", dataVal.Type())
|
||||||
|
}
|
||||||
|
data := DictToMap(dataDict)
|
||||||
|
|
||||||
|
// Evaluate rules — first match wins
|
||||||
|
for _, rule := range rules {
|
||||||
|
if evaluateRoutingCondition(rule, data) {
|
||||||
|
return starlark.String(rule.Target), nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return starlark.None, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Evaluation helpers (mirrored from workflow/routing.go) ─────────
|
||||||
|
//
|
||||||
|
// These are pure functions copied from the workflow package to avoid
|
||||||
|
// a sandbox → workflow import cycle. The workflow package continues
|
||||||
|
// using its own copy for ResolveNextStage.
|
||||||
|
|
||||||
|
// evaluateRoutingCondition checks if a single rule matches against data.
|
||||||
|
func evaluateRoutingCondition(rule routingRule, data map[string]any) bool {
|
||||||
|
val, exists := data[rule.Field]
|
||||||
|
|
||||||
|
switch rule.Op {
|
||||||
|
case "exists":
|
||||||
|
return exists
|
||||||
|
case "not_exists":
|
||||||
|
return !exists
|
||||||
|
}
|
||||||
|
|
||||||
|
if !exists {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
switch rule.Op {
|
||||||
|
case "eq":
|
||||||
|
return routingCompareEq(val, rule.Value)
|
||||||
|
case "neq":
|
||||||
|
return !routingCompareEq(val, rule.Value)
|
||||||
|
case "gt":
|
||||||
|
return routingCompareNum(val, rule.Value) > 0
|
||||||
|
case "lt":
|
||||||
|
return routingCompareNum(val, rule.Value) < 0
|
||||||
|
case "gte":
|
||||||
|
return routingCompareNum(val, rule.Value) >= 0
|
||||||
|
case "lte":
|
||||||
|
return routingCompareNum(val, rule.Value) <= 0
|
||||||
|
case "in":
|
||||||
|
return routingCompareIn(val, rule.Value)
|
||||||
|
case "contains":
|
||||||
|
return routingCompareContains(val, rule.Value)
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func routingCompareEq(a, b any) bool {
|
||||||
|
return fmt.Sprintf("%v", a) == fmt.Sprintf("%v", b)
|
||||||
|
}
|
||||||
|
|
||||||
|
func routingCompareNum(a, b any) int {
|
||||||
|
af := routingToFloat(a)
|
||||||
|
bf := routingToFloat(b)
|
||||||
|
if af == nil || bf == nil {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
switch {
|
||||||
|
case *af < *bf:
|
||||||
|
return -1
|
||||||
|
case *af > *bf:
|
||||||
|
return 1
|
||||||
|
default:
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func routingToFloat(v any) *float64 {
|
||||||
|
switch n := v.(type) {
|
||||||
|
case float64:
|
||||||
|
return &n
|
||||||
|
case int:
|
||||||
|
f := float64(n)
|
||||||
|
return &f
|
||||||
|
case int64:
|
||||||
|
f := float64(n)
|
||||||
|
return &f
|
||||||
|
case string:
|
||||||
|
var f float64
|
||||||
|
if _, err := fmt.Sscanf(n, "%f", &f); err == nil {
|
||||||
|
return &f
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func routingCompareIn(val, list any) bool {
|
||||||
|
arr, ok := list.([]any)
|
||||||
|
if !ok {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
vs := fmt.Sprintf("%v", val)
|
||||||
|
for _, item := range arr {
|
||||||
|
if fmt.Sprintf("%v", item) == vs {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func routingCompareContains(val, target any) bool {
|
||||||
|
s := fmt.Sprintf("%v", val)
|
||||||
|
t := fmt.Sprintf("%v", target)
|
||||||
|
return strings.Contains(s, t)
|
||||||
|
}
|
||||||
162
server/sandbox/routing_module_test.go
Normal file
162
server/sandbox/routing_module_test.go
Normal file
@@ -0,0 +1,162 @@
|
|||||||
|
package sandbox
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"go.starlark.net/starlark"
|
||||||
|
)
|
||||||
|
|
||||||
|
func runRoutingScript(t *testing.T, script string) starlark.StringDict {
|
||||||
|
t.Helper()
|
||||||
|
mod := BuildRoutingModule()
|
||||||
|
predeclared := starlark.StringDict{"routing": mod}
|
||||||
|
globals, err := starlark.ExecFile(&starlark.Thread{Name: "test"}, "test.star", script, predeclared)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return globals
|
||||||
|
}
|
||||||
|
|
||||||
|
func runRoutingScriptErr(t *testing.T, script string) error {
|
||||||
|
t.Helper()
|
||||||
|
mod := BuildRoutingModule()
|
||||||
|
predeclared := starlark.StringDict{"routing": mod}
|
||||||
|
_, err := starlark.ExecFile(&starlark.Thread{Name: "test"}, "test.star", script, predeclared)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRoutingEvaluate_SingleMatch(t *testing.T) {
|
||||||
|
globals := runRoutingScript(t, `
|
||||||
|
result = routing.evaluate([
|
||||||
|
{"field": "priority", "op": "eq", "value": "high", "target": "escalation"},
|
||||||
|
], {"priority": "high"})
|
||||||
|
`)
|
||||||
|
result := globals["result"]
|
||||||
|
if s, ok := result.(starlark.String); !ok || string(s) != "escalation" {
|
||||||
|
t.Fatalf("expected 'escalation', got %v", result)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRoutingEvaluate_FirstMatchWins(t *testing.T) {
|
||||||
|
globals := runRoutingScript(t, `
|
||||||
|
result = routing.evaluate([
|
||||||
|
{"field": "amount", "op": "gt", "value": 100, "target": "big"},
|
||||||
|
{"field": "amount", "op": "gt", "value": 50, "target": "medium"},
|
||||||
|
], {"amount": 200})
|
||||||
|
`)
|
||||||
|
result := globals["result"]
|
||||||
|
if s, ok := result.(starlark.String); !ok || string(s) != "big" {
|
||||||
|
t.Fatalf("expected 'big', got %v", result)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRoutingEvaluate_NoMatch(t *testing.T) {
|
||||||
|
globals := runRoutingScript(t, `
|
||||||
|
result = routing.evaluate([
|
||||||
|
{"field": "status", "op": "eq", "value": "done", "target": "finish"},
|
||||||
|
], {"status": "pending"})
|
||||||
|
`)
|
||||||
|
if globals["result"] != starlark.None {
|
||||||
|
t.Fatalf("expected None, got %v", globals["result"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRoutingEvaluate_EmptyRules(t *testing.T) {
|
||||||
|
globals := runRoutingScript(t, `
|
||||||
|
result = routing.evaluate([], {"x": 1})
|
||||||
|
`)
|
||||||
|
if globals["result"] != starlark.None {
|
||||||
|
t.Fatalf("expected None, got %v", globals["result"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRoutingEvaluate_AllOperators(t *testing.T) {
|
||||||
|
globals := runRoutingScript(t, `
|
||||||
|
# exists
|
||||||
|
r1 = routing.evaluate([{"field": "x", "op": "exists", "value": "", "target": "yes"}], {"x": 1})
|
||||||
|
# not_exists
|
||||||
|
r2 = routing.evaluate([{"field": "x", "op": "not_exists", "value": "", "target": "yes"}], {"y": 1})
|
||||||
|
# eq
|
||||||
|
r3 = routing.evaluate([{"field": "s", "op": "eq", "value": "abc", "target": "yes"}], {"s": "abc"})
|
||||||
|
# neq
|
||||||
|
r4 = routing.evaluate([{"field": "s", "op": "neq", "value": "abc", "target": "yes"}], {"s": "xyz"})
|
||||||
|
# gt
|
||||||
|
r5 = routing.evaluate([{"field": "n", "op": "gt", "value": 10, "target": "yes"}], {"n": 20})
|
||||||
|
# lt
|
||||||
|
r6 = routing.evaluate([{"field": "n", "op": "lt", "value": 10, "target": "yes"}], {"n": 5})
|
||||||
|
# gte
|
||||||
|
r7 = routing.evaluate([{"field": "n", "op": "gte", "value": 10, "target": "yes"}], {"n": 10})
|
||||||
|
# lte
|
||||||
|
r8 = routing.evaluate([{"field": "n", "op": "lte", "value": 10, "target": "yes"}], {"n": 10})
|
||||||
|
# in
|
||||||
|
r9 = routing.evaluate([{"field": "s", "op": "in", "value": ["a", "b", "c"], "target": "yes"}], {"s": "b"})
|
||||||
|
# contains
|
||||||
|
r10 = routing.evaluate([{"field": "s", "op": "contains", "value": "ell", "target": "yes"}], {"s": "hello"})
|
||||||
|
`)
|
||||||
|
for _, name := range []string{"r1", "r2", "r3", "r4", "r5", "r6", "r7", "r8", "r9", "r10"} {
|
||||||
|
v := globals[name]
|
||||||
|
if s, ok := v.(starlark.String); !ok || string(s) != "yes" {
|
||||||
|
t.Errorf("%s: expected 'yes', got %v", name, v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRoutingEvaluate_TypeCoercion(t *testing.T) {
|
||||||
|
globals := runRoutingScript(t, `
|
||||||
|
# Numeric string vs int
|
||||||
|
r1 = routing.evaluate([
|
||||||
|
{"field": "n", "op": "gt", "value": 10, "target": "yes"},
|
||||||
|
], {"n": "20"})
|
||||||
|
|
||||||
|
# Int equality via string normalization
|
||||||
|
r2 = routing.evaluate([
|
||||||
|
{"field": "n", "op": "eq", "value": 42, "target": "yes"},
|
||||||
|
], {"n": 42})
|
||||||
|
`)
|
||||||
|
for _, name := range []string{"r1", "r2"} {
|
||||||
|
v := globals[name]
|
||||||
|
if s, ok := v.(starlark.String); !ok || string(s) != "yes" {
|
||||||
|
t.Errorf("%s: expected 'yes', got %v", name, v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRoutingEvaluate_BadInput(t *testing.T) {
|
||||||
|
// rules must be a list
|
||||||
|
err1 := runRoutingScriptErr(t, `routing.evaluate("bad", {})`)
|
||||||
|
if err1 == nil {
|
||||||
|
t.Fatal("expected error for non-list rules")
|
||||||
|
}
|
||||||
|
|
||||||
|
// data must be a dict
|
||||||
|
err2 := runRoutingScriptErr(t, `routing.evaluate([], "bad")`)
|
||||||
|
if err2 == nil {
|
||||||
|
t.Fatal("expected error for non-dict data")
|
||||||
|
}
|
||||||
|
|
||||||
|
// rule missing required fields
|
||||||
|
err3 := runRoutingScriptErr(t, `routing.evaluate([{"field": "x"}], {})`)
|
||||||
|
if err3 == nil {
|
||||||
|
t.Fatal("expected error for incomplete rule")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRoutingEvaluate_MissingFields(t *testing.T) {
|
||||||
|
globals := runRoutingScript(t, `
|
||||||
|
# Field not in data — eq should not match
|
||||||
|
r1 = routing.evaluate([
|
||||||
|
{"field": "absent", "op": "eq", "value": "x", "target": "bad"},
|
||||||
|
], {"other": "y"})
|
||||||
|
|
||||||
|
# not_exists matches when field is absent
|
||||||
|
r2 = routing.evaluate([
|
||||||
|
{"field": "absent", "op": "not_exists", "value": "", "target": "good"},
|
||||||
|
], {"other": "y"})
|
||||||
|
`)
|
||||||
|
if globals["r1"] != starlark.None {
|
||||||
|
t.Fatalf("r1: expected None for missing field, got %v", globals["r1"])
|
||||||
|
}
|
||||||
|
if s, ok := globals["r2"].(starlark.String); !ok || string(s) != "good" {
|
||||||
|
t.Fatalf("r2: expected 'good', got %v", globals["r2"])
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -82,6 +82,7 @@ type Runner struct {
|
|||||||
workspaceRoot string // empty = workspace module unavailable
|
workspaceRoot string // empty = workspace module unavailable
|
||||||
workspaceQuota int // MB, 0 = unlimited
|
workspaceQuota int // MB, 0 = unlimited
|
||||||
capabilities map[string]bool // detected environment capabilities
|
capabilities map[string]bool // detected environment capabilities
|
||||||
|
wfEngine WorkflowEngine // nil = workflow write ops unavailable
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewRunner creates a runner with the given sandbox and dependencies.
|
// NewRunner creates a runner with the given sandbox and dependencies.
|
||||||
@@ -139,6 +140,12 @@ func (r *Runner) SetCapabilities(caps map[string]bool) {
|
|||||||
r.capabilities = caps
|
r.capabilities = caps
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SetWorkflowEngine attaches the workflow engine for write operations
|
||||||
|
// (start/advance/cancel/submit_signoff) in the workflow Starlark module.
|
||||||
|
func (r *Runner) SetWorkflowEngine(e WorkflowEngine) {
|
||||||
|
r.wfEngine = e
|
||||||
|
}
|
||||||
|
|
||||||
// SetAllowPrivateIPs disables the SSRF check that blocks connections to
|
// SetAllowPrivateIPs disables the SSRF check that blocks connections to
|
||||||
// private/loopback IPs. For self-hosted environments where extensions
|
// private/loopback IPs. For self-hosted environments where extensions
|
||||||
// reach internal services. Controlled by EXT_ALLOW_PRIVATE_IPS env var.
|
// reach internal services. Controlled by EXT_ALLOW_PRIVATE_IPS env var.
|
||||||
@@ -396,7 +403,7 @@ func (r *Runner) buildModulesWithLibCtx(ctx context.Context, packageID string, m
|
|||||||
filesLevel = 2
|
filesLevel = 2
|
||||||
|
|
||||||
case models.ExtPermWorkflowAccess:
|
case models.ExtPermWorkflowAccess:
|
||||||
modules["workflow"] = BuildWorkflowModule(ctx, r.stores)
|
modules["workflow"] = BuildWorkflowModule(ctx, r.stores, r.wfEngine, rc)
|
||||||
|
|
||||||
case models.ExtPermConnectionsRead:
|
case models.ExtPermConnectionsRead:
|
||||||
if r.connResolver != nil && rc != nil && rc.UserID != "" {
|
if r.connResolver != nil && rc != nil && rc.UserID != "" {
|
||||||
@@ -417,6 +424,9 @@ func (r *Runner) buildModulesWithLibCtx(ctx context.Context, packageID string, m
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
case models.ExtPermFormValidate:
|
||||||
|
modules["forms"] = BuildFormsModule(ctx)
|
||||||
|
|
||||||
case models.ExtPermBatchExec:
|
case models.ExtPermBatchExec:
|
||||||
hasBatchExec = true
|
hasBatchExec = true
|
||||||
}
|
}
|
||||||
@@ -454,6 +464,12 @@ func (r *Runner) buildModulesWithLibCtx(ctx context.Context, packageID string, m
|
|||||||
// Always available — read-only check against kernel permission data
|
// Always available — read-only check against kernel permission data
|
||||||
modules["permissions"] = BuildPermissionsModule(ctx, r.stores)
|
modules["permissions"] = BuildPermissionsModule(ctx, r.stores)
|
||||||
|
|
||||||
|
// Always available — read-only team role queries
|
||||||
|
modules["teams"] = BuildTeamsModule(ctx, r.stores)
|
||||||
|
|
||||||
|
// Always available — pure-computation routing decision engine
|
||||||
|
modules["routing"] = BuildRoutingModule()
|
||||||
|
|
||||||
// Allows any starlark package to load declared library dependencies.
|
// Allows any starlark package to load declared library dependencies.
|
||||||
if lc != nil {
|
if lc != nil {
|
||||||
modules["lib"] = BuildLibModule(ctx, r, packageID, rc, lc)
|
modules["lib"] = BuildLibModule(ctx, r, packageID, rc, lc)
|
||||||
|
|||||||
62
server/sandbox/teams_module.go
Normal file
62
server/sandbox/teams_module.go
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
// Package sandbox — teams_module.go
|
||||||
|
//
|
||||||
|
// Read-only module for querying team role membership from Starlark scripts.
|
||||||
|
// No sandbox permission required — extensions can check roles without special grants.
|
||||||
|
//
|
||||||
|
// Starlark API:
|
||||||
|
// teams.get_member_roles(team_id, user_id) → ["admin", "reviewer", ...]
|
||||||
|
// teams.has_role(team_id, user_id, role) → True/False
|
||||||
|
package sandbox
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
"go.starlark.net/starlark"
|
||||||
|
"go.starlark.net/starlarkstruct"
|
||||||
|
|
||||||
|
"armature/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
// BuildTeamsModule creates the "teams" module.
|
||||||
|
// Always available to all extensions (no permission gate).
|
||||||
|
func BuildTeamsModule(ctx context.Context, stores store.Stores) *starlarkstruct.Module {
|
||||||
|
return MakeModule("teams", starlark.StringDict{
|
||||||
|
|
||||||
|
"get_member_roles": starlark.NewBuiltin("teams.get_member_roles", func(
|
||||||
|
thread *starlark.Thread, b *starlark.Builtin,
|
||||||
|
args starlark.Tuple, kwargs []starlark.Tuple,
|
||||||
|
) (starlark.Value, error) {
|
||||||
|
var teamID, userID string
|
||||||
|
if err := starlark.UnpackPositionalArgs(b.Name(), args, kwargs, 2, &teamID, &userID); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
roles, err := stores.Teams.GetMemberRoles(ctx, teamID, userID)
|
||||||
|
if err != nil {
|
||||||
|
return starlark.NewList(nil), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
elems := make([]starlark.Value, len(roles))
|
||||||
|
for i, r := range roles {
|
||||||
|
elems[i] = starlark.String(r)
|
||||||
|
}
|
||||||
|
return starlark.NewList(elems), nil
|
||||||
|
}),
|
||||||
|
|
||||||
|
"has_role": starlark.NewBuiltin("teams.has_role", func(
|
||||||
|
thread *starlark.Thread, b *starlark.Builtin,
|
||||||
|
args starlark.Tuple, kwargs []starlark.Tuple,
|
||||||
|
) (starlark.Value, error) {
|
||||||
|
var teamID, userID, role string
|
||||||
|
if err := starlark.UnpackPositionalArgs(b.Name(), args, kwargs, 3, &teamID, &userID, &role); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
has, err := stores.Teams.HasRole(ctx, teamID, userID, role)
|
||||||
|
if err != nil || !has {
|
||||||
|
return starlark.False, nil
|
||||||
|
}
|
||||||
|
return starlark.True, nil
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -6,12 +6,13 @@ package sandbox
|
|||||||
// manage instances, and programmatically advance workflow stages.
|
// manage instances, and programmatically advance workflow stages.
|
||||||
//
|
//
|
||||||
// Starlark API:
|
// Starlark API:
|
||||||
// wf = workflow.get_definition(workflow_id) # returns dict
|
// wf = workflow.get_definition(workflow_id) # returns dict
|
||||||
// inst = workflow.get_instance(instance_id) # returns dict
|
// inst = workflow.get_instance(instance_id) # returns dict
|
||||||
// inst = workflow.start(workflow_id, data={}) # start new instance
|
// instances = workflow.list_instances(workflow_id) # list instances
|
||||||
// inst = workflow.advance(instance_id, data={}) # advance to next stage
|
// inst = workflow.start(workflow_id, data={}) # start new instance
|
||||||
// workflow.cancel(instance_id) # cancel instance
|
// inst = workflow.advance(instance_id, data={}) # advance to next stage
|
||||||
// instances = workflow.list_instances(workflow_id) # list instances
|
// workflow.cancel(instance_id) # cancel instance
|
||||||
|
// signoff = workflow.submit_signoff(instance_id, decision, comment="") # submit signoff
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
@@ -21,21 +22,37 @@ import (
|
|||||||
"go.starlark.net/starlark"
|
"go.starlark.net/starlark"
|
||||||
"go.starlark.net/starlarkstruct"
|
"go.starlark.net/starlarkstruct"
|
||||||
|
|
||||||
|
"armature/models"
|
||||||
"armature/store"
|
"armature/store"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// WorkflowEngine is the subset of the workflow engine needed by Starlark
|
||||||
|
// write operations. Defined here (not in the workflow package) to avoid
|
||||||
|
// circular imports — workflow imports sandbox for the Runner.
|
||||||
|
type WorkflowEngine interface {
|
||||||
|
Start(ctx context.Context, workflowID string, initialData json.RawMessage, userID string) (*models.WorkflowInstance, error)
|
||||||
|
Advance(ctx context.Context, instanceID string, stageData json.RawMessage, userID string) (*models.WorkflowInstance, error)
|
||||||
|
Cancel(ctx context.Context, instanceID string, userID string) error
|
||||||
|
SubmitSignoff(ctx context.Context, instanceID, userID, decision, comment string) (*models.WorkflowSignoff, error)
|
||||||
|
}
|
||||||
|
|
||||||
// BuildWorkflowModule creates the "workflow" Starlark module for a package.
|
// BuildWorkflowModule creates the "workflow" Starlark module for a package.
|
||||||
// Requires the workflow.access permission.
|
// Requires the workflow.access permission.
|
||||||
//
|
//
|
||||||
// Mutating operations (start/advance/cancel) are available via HTTP API;
|
// Read operations use stores directly. Write operations delegate to the
|
||||||
// Starlark builtins for them will be added when the engine interface is
|
// WorkflowEngine interface (engine may be nil if not wired — write builtins
|
||||||
// extracted to a shared package to avoid circular imports.
|
// return an error in that case).
|
||||||
func BuildWorkflowModule(ctx context.Context, stores store.Stores) *starlarkstruct.Module {
|
func BuildWorkflowModule(ctx context.Context, stores store.Stores, engine WorkflowEngine, rc *RunContext) *starlarkstruct.Module {
|
||||||
return MakeModule("workflow", starlark.StringDict{
|
members := starlark.StringDict{
|
||||||
"get_definition": starlark.NewBuiltin("workflow.get_definition", workflowGetDef(ctx, stores)),
|
"get_definition": starlark.NewBuiltin("workflow.get_definition", workflowGetDef(ctx, stores)),
|
||||||
"get_instance": starlark.NewBuiltin("workflow.get_instance", workflowGetInstance(ctx, stores)),
|
"get_instance": starlark.NewBuiltin("workflow.get_instance", workflowGetInstance(ctx, stores)),
|
||||||
"list_instances": starlark.NewBuiltin("workflow.list_instances", workflowListInstances(ctx, stores)),
|
"list_instances": starlark.NewBuiltin("workflow.list_instances", workflowListInstances(ctx, stores)),
|
||||||
})
|
"start": starlark.NewBuiltin("workflow.start", workflowStart(ctx, engine, rc)),
|
||||||
|
"advance": starlark.NewBuiltin("workflow.advance", workflowAdvance(ctx, engine, rc)),
|
||||||
|
"cancel": starlark.NewBuiltin("workflow.cancel", workflowCancel(ctx, engine, rc)),
|
||||||
|
"submit_signoff": starlark.NewBuiltin("workflow.submit_signoff", workflowSubmitSignoff(ctx, engine, rc)),
|
||||||
|
}
|
||||||
|
return MakeModule("workflow", members)
|
||||||
}
|
}
|
||||||
|
|
||||||
func workflowGetDef(ctx context.Context, stores store.Stores) func(*starlark.Thread, *starlark.Builtin, starlark.Tuple, []starlark.Tuple) (starlark.Value, error) {
|
func workflowGetDef(ctx context.Context, stores store.Stores) func(*starlark.Thread, *starlark.Builtin, starlark.Tuple, []starlark.Tuple) (starlark.Value, error) {
|
||||||
@@ -60,7 +77,7 @@ func workflowGetDef(ctx context.Context, stores store.Stores) func(*starlark.Thr
|
|||||||
d.SetKey(starlark.String("ordinal"), starlark.MakeInt(s.Ordinal))
|
d.SetKey(starlark.String("ordinal"), starlark.MakeInt(s.Ordinal))
|
||||||
d.SetKey(starlark.String("stage_mode"), starlark.String(s.StageMode))
|
d.SetKey(starlark.String("stage_mode"), starlark.String(s.StageMode))
|
||||||
d.SetKey(starlark.String("audience"), starlark.String(s.Audience))
|
d.SetKey(starlark.String("audience"), starlark.String(s.Audience))
|
||||||
d.SetKey(starlark.String("stage_type"), starlark.String(s.StageType))
|
d.SetKey(starlark.String("stage_type"), starlark.String(s.StageType)) // deprecated — kept for backward compat
|
||||||
d.SetKey(starlark.String("auto_transition"), starlark.Bool(s.AutoTransition))
|
d.SetKey(starlark.String("auto_transition"), starlark.Bool(s.AutoTransition))
|
||||||
if s.StarlarkHook != nil {
|
if s.StarlarkHook != nil {
|
||||||
d.SetKey(starlark.String("starlark_hook"), starlark.String(*s.StarlarkHook))
|
d.SetKey(starlark.String("starlark_hook"), starlark.String(*s.StarlarkHook))
|
||||||
@@ -98,35 +115,51 @@ func workflowGetInstance(ctx context.Context, stores store.Stores) func(*starlar
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return starlark.None, fmt.Errorf("workflow.get_instance: %w", err)
|
return starlark.None, fmt.Errorf("workflow.get_instance: %w", err)
|
||||||
}
|
}
|
||||||
|
return instanceToDict(inst), nil
|
||||||
d := starlark.NewDict(8)
|
|
||||||
d.SetKey(starlark.String("id"), starlark.String(inst.ID))
|
|
||||||
d.SetKey(starlark.String("workflow_id"), starlark.String(inst.WorkflowID))
|
|
||||||
d.SetKey(starlark.String("workflow_version"), starlark.MakeInt(inst.WorkflowVersion))
|
|
||||||
d.SetKey(starlark.String("current_stage"), starlark.String(inst.CurrentStage))
|
|
||||||
d.SetKey(starlark.String("status"), starlark.String(inst.Status))
|
|
||||||
d.SetKey(starlark.String("started_by"), starlark.String(inst.StartedBy))
|
|
||||||
|
|
||||||
// Parse stage_data into Starlark dict
|
|
||||||
var dataMap map[string]interface{}
|
|
||||||
if json.Unmarshal(inst.StageData, &dataMap) == nil {
|
|
||||||
starlarkData := starlark.NewDict(len(dataMap))
|
|
||||||
for k, v := range dataMap {
|
|
||||||
starlarkData.SetKey(starlark.String(k), goValToStarlark(v))
|
|
||||||
}
|
|
||||||
d.SetKey(starlark.String("stage_data"), starlarkData)
|
|
||||||
} else {
|
|
||||||
d.SetKey(starlark.String("stage_data"), starlark.NewDict(0))
|
|
||||||
}
|
|
||||||
|
|
||||||
if inst.EntryToken != nil {
|
|
||||||
d.SetKey(starlark.String("entry_token"), starlark.String(*inst.EntryToken))
|
|
||||||
}
|
|
||||||
|
|
||||||
return d, nil
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// instanceToDict converts a WorkflowInstance to a Starlark dict.
|
||||||
|
func instanceToDict(inst *models.WorkflowInstance) *starlark.Dict {
|
||||||
|
d := starlark.NewDict(8)
|
||||||
|
d.SetKey(starlark.String("id"), starlark.String(inst.ID))
|
||||||
|
d.SetKey(starlark.String("workflow_id"), starlark.String(inst.WorkflowID))
|
||||||
|
d.SetKey(starlark.String("workflow_version"), starlark.MakeInt(inst.WorkflowVersion))
|
||||||
|
d.SetKey(starlark.String("current_stage"), starlark.String(inst.CurrentStage))
|
||||||
|
d.SetKey(starlark.String("status"), starlark.String(inst.Status))
|
||||||
|
d.SetKey(starlark.String("started_by"), starlark.String(inst.StartedBy))
|
||||||
|
|
||||||
|
// Parse stage_data into Starlark dict
|
||||||
|
var dataMap map[string]interface{}
|
||||||
|
if json.Unmarshal(inst.StageData, &dataMap) == nil {
|
||||||
|
starlarkData := starlark.NewDict(len(dataMap))
|
||||||
|
for k, v := range dataMap {
|
||||||
|
starlarkData.SetKey(starlark.String(k), GoToStarlark(v))
|
||||||
|
}
|
||||||
|
d.SetKey(starlark.String("stage_data"), starlarkData)
|
||||||
|
} else {
|
||||||
|
d.SetKey(starlark.String("stage_data"), starlark.NewDict(0))
|
||||||
|
}
|
||||||
|
|
||||||
|
if inst.EntryToken != nil {
|
||||||
|
d.SetKey(starlark.String("entry_token"), starlark.String(*inst.EntryToken))
|
||||||
|
}
|
||||||
|
return d
|
||||||
|
}
|
||||||
|
|
||||||
|
// signoffToDict converts a WorkflowSignoff to a Starlark dict.
|
||||||
|
func signoffToDict(s *models.WorkflowSignoff) *starlark.Dict {
|
||||||
|
d := starlark.NewDict(7)
|
||||||
|
d.SetKey(starlark.String("id"), starlark.String(s.ID))
|
||||||
|
d.SetKey(starlark.String("instance_id"), starlark.String(s.InstanceID))
|
||||||
|
d.SetKey(starlark.String("stage"), starlark.String(s.Stage))
|
||||||
|
d.SetKey(starlark.String("user_id"), starlark.String(s.UserID))
|
||||||
|
d.SetKey(starlark.String("decision"), starlark.String(s.Decision))
|
||||||
|
d.SetKey(starlark.String("comment"), starlark.String(s.Comment))
|
||||||
|
d.SetKey(starlark.String("created_at"), starlark.String(s.CreatedAt.Format("2006-01-02T15:04:05Z07:00")))
|
||||||
|
return d
|
||||||
|
}
|
||||||
|
|
||||||
func workflowListInstances(ctx context.Context, stores store.Stores) func(*starlark.Thread, *starlark.Builtin, starlark.Tuple, []starlark.Tuple) (starlark.Value, error) {
|
func workflowListInstances(ctx context.Context, stores store.Stores) func(*starlark.Thread, *starlark.Builtin, starlark.Tuple, []starlark.Tuple) (starlark.Value, error) {
|
||||||
return func(_ *starlark.Thread, _ *starlark.Builtin, args starlark.Tuple, kwargs []starlark.Tuple) (starlark.Value, error) {
|
return func(_ *starlark.Thread, _ *starlark.Builtin, args starlark.Tuple, kwargs []starlark.Tuple) (starlark.Value, error) {
|
||||||
var workflowID string
|
var workflowID string
|
||||||
@@ -155,36 +188,113 @@ func workflowListInstances(ctx context.Context, stores store.Stores) func(*starl
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// goValToStarlark converts a Go value to a Starlark value.
|
// ── Instance Write API ──────────────
|
||||||
func goValToStarlark(v interface{}) starlark.Value {
|
|
||||||
switch val := v.(type) {
|
// workflowWriteGuard checks that the engine and user context are available.
|
||||||
case nil:
|
func workflowWriteGuard(engine WorkflowEngine, rc *RunContext) error {
|
||||||
return starlark.None
|
if engine == nil {
|
||||||
case bool:
|
return fmt.Errorf("workflow engine not available")
|
||||||
return starlark.Bool(val)
|
}
|
||||||
case float64:
|
if rc == nil || rc.UserID == "" {
|
||||||
if val == float64(int(val)) {
|
return fmt.Errorf("workflow write operations require an authenticated user context")
|
||||||
return starlark.MakeInt(int(val))
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// dictToJSON converts a Starlark dict to json.RawMessage.
|
||||||
|
func dictToJSON(d *starlark.Dict) (json.RawMessage, error) {
|
||||||
|
m := DictToMap(d)
|
||||||
|
b, err := json.Marshal(m)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return json.RawMessage(b), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func workflowStart(ctx context.Context, engine WorkflowEngine, rc *RunContext) func(*starlark.Thread, *starlark.Builtin, starlark.Tuple, []starlark.Tuple) (starlark.Value, error) {
|
||||||
|
return func(_ *starlark.Thread, _ *starlark.Builtin, args starlark.Tuple, kwargs []starlark.Tuple) (starlark.Value, error) {
|
||||||
|
var workflowID string
|
||||||
|
data := starlark.NewDict(0)
|
||||||
|
if err := starlark.UnpackArgs("workflow.start", args, kwargs,
|
||||||
|
"workflow_id", &workflowID,
|
||||||
|
"data?", &data,
|
||||||
|
); err != nil {
|
||||||
|
return nil, err
|
||||||
}
|
}
|
||||||
return starlark.Float(val)
|
if err := workflowWriteGuard(engine, rc); err != nil {
|
||||||
case string:
|
return nil, fmt.Errorf("workflow.start: %w", err)
|
||||||
return starlark.String(val)
|
|
||||||
case map[string]interface{}:
|
|
||||||
d := starlark.NewDict(len(val))
|
|
||||||
for k, v := range val {
|
|
||||||
d.SetKey(starlark.String(k), goValToStarlark(v))
|
|
||||||
}
|
}
|
||||||
return d
|
dataJSON, err := dictToJSON(data)
|
||||||
case []interface{}:
|
if err != nil {
|
||||||
elems := make([]starlark.Value, len(val))
|
return nil, fmt.Errorf("workflow.start: marshal data: %w", err)
|
||||||
for i, v := range val {
|
|
||||||
elems[i] = goValToStarlark(v)
|
|
||||||
}
|
}
|
||||||
return starlark.NewList(elems)
|
inst, err := engine.Start(ctx, workflowID, dataJSON, rc.UserID)
|
||||||
default:
|
if err != nil {
|
||||||
return starlark.String(fmt.Sprintf("%v", v))
|
return nil, fmt.Errorf("workflow.start: %w", err)
|
||||||
|
}
|
||||||
|
return instanceToDict(inst), nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// workflowRoute routes the workflow to a named stage.
|
func workflowAdvance(ctx context.Context, engine WorkflowEngine, rc *RunContext) func(*starlark.Thread, *starlark.Builtin, starlark.Tuple, []starlark.Tuple) (starlark.Value, error) {
|
||||||
// Starlark: workflow.route(instance_id, target_stage, reason)
|
return func(_ *starlark.Thread, _ *starlark.Builtin, args starlark.Tuple, kwargs []starlark.Tuple) (starlark.Value, error) {
|
||||||
|
var instanceID string
|
||||||
|
data := starlark.NewDict(0)
|
||||||
|
if err := starlark.UnpackArgs("workflow.advance", args, kwargs,
|
||||||
|
"instance_id", &instanceID,
|
||||||
|
"data?", &data,
|
||||||
|
); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := workflowWriteGuard(engine, rc); err != nil {
|
||||||
|
return nil, fmt.Errorf("workflow.advance: %w", err)
|
||||||
|
}
|
||||||
|
dataJSON, err := dictToJSON(data)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("workflow.advance: marshal data: %w", err)
|
||||||
|
}
|
||||||
|
inst, err := engine.Advance(ctx, instanceID, dataJSON, rc.UserID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("workflow.advance: %w", err)
|
||||||
|
}
|
||||||
|
return instanceToDict(inst), nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func workflowCancel(ctx context.Context, engine WorkflowEngine, rc *RunContext) func(*starlark.Thread, *starlark.Builtin, starlark.Tuple, []starlark.Tuple) (starlark.Value, error) {
|
||||||
|
return func(_ *starlark.Thread, _ *starlark.Builtin, args starlark.Tuple, kwargs []starlark.Tuple) (starlark.Value, error) {
|
||||||
|
var instanceID string
|
||||||
|
if err := starlark.UnpackPositionalArgs("workflow.cancel", args, kwargs, 1, &instanceID); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := workflowWriteGuard(engine, rc); err != nil {
|
||||||
|
return nil, fmt.Errorf("workflow.cancel: %w", err)
|
||||||
|
}
|
||||||
|
if err := engine.Cancel(ctx, instanceID, rc.UserID); err != nil {
|
||||||
|
return nil, fmt.Errorf("workflow.cancel: %w", err)
|
||||||
|
}
|
||||||
|
return starlark.None, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func workflowSubmitSignoff(ctx context.Context, engine WorkflowEngine, rc *RunContext) func(*starlark.Thread, *starlark.Builtin, starlark.Tuple, []starlark.Tuple) (starlark.Value, error) {
|
||||||
|
return func(_ *starlark.Thread, _ *starlark.Builtin, args starlark.Tuple, kwargs []starlark.Tuple) (starlark.Value, error) {
|
||||||
|
var instanceID, decision string
|
||||||
|
comment := ""
|
||||||
|
if err := starlark.UnpackArgs("workflow.submit_signoff", args, kwargs,
|
||||||
|
"instance_id", &instanceID,
|
||||||
|
"decision", &decision,
|
||||||
|
"comment?", &comment,
|
||||||
|
); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := workflowWriteGuard(engine, rc); err != nil {
|
||||||
|
return nil, fmt.Errorf("workflow.submit_signoff: %w", err)
|
||||||
|
}
|
||||||
|
signoff, err := engine.SubmitSignoff(ctx, instanceID, rc.UserID, decision, comment)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("workflow.submit_signoff: %w", err)
|
||||||
|
}
|
||||||
|
return signoffToDict(signoff), nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
262
server/sandbox/workflow_module_test.go
Normal file
262
server/sandbox/workflow_module_test.go
Normal file
@@ -0,0 +1,262 @@
|
|||||||
|
package sandbox
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"go.starlark.net/starlark"
|
||||||
|
|
||||||
|
"armature/models"
|
||||||
|
"armature/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ── Mock Engine ──────────────────────
|
||||||
|
|
||||||
|
type mockWorkflowEngine struct {
|
||||||
|
startResult *models.WorkflowInstance
|
||||||
|
advanceResult *models.WorkflowInstance
|
||||||
|
signoffResult *models.WorkflowSignoff
|
||||||
|
|
||||||
|
lastMethod string
|
||||||
|
lastWorkflowID string
|
||||||
|
lastInstanceID string
|
||||||
|
lastUserID string
|
||||||
|
lastData json.RawMessage
|
||||||
|
lastDecision string
|
||||||
|
lastComment string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *mockWorkflowEngine) Start(_ context.Context, workflowID string, data json.RawMessage, userID string) (*models.WorkflowInstance, error) {
|
||||||
|
m.lastMethod = "Start"
|
||||||
|
m.lastWorkflowID = workflowID
|
||||||
|
m.lastData = data
|
||||||
|
m.lastUserID = userID
|
||||||
|
return m.startResult, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *mockWorkflowEngine) Advance(_ context.Context, instanceID string, data json.RawMessage, userID string) (*models.WorkflowInstance, error) {
|
||||||
|
m.lastMethod = "Advance"
|
||||||
|
m.lastInstanceID = instanceID
|
||||||
|
m.lastData = data
|
||||||
|
m.lastUserID = userID
|
||||||
|
return m.advanceResult, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *mockWorkflowEngine) Cancel(_ context.Context, instanceID string, userID string) error {
|
||||||
|
m.lastMethod = "Cancel"
|
||||||
|
m.lastInstanceID = instanceID
|
||||||
|
m.lastUserID = userID
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *mockWorkflowEngine) SubmitSignoff(_ context.Context, instanceID, userID, decision, comment string) (*models.WorkflowSignoff, error) {
|
||||||
|
m.lastMethod = "SubmitSignoff"
|
||||||
|
m.lastInstanceID = instanceID
|
||||||
|
m.lastUserID = userID
|
||||||
|
m.lastDecision = decision
|
||||||
|
m.lastComment = comment
|
||||||
|
return m.signoffResult, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Helpers ──────────────────────────
|
||||||
|
|
||||||
|
func testInstance() *models.WorkflowInstance {
|
||||||
|
return &models.WorkflowInstance{
|
||||||
|
ID: "inst-1",
|
||||||
|
WorkflowID: "wf-1",
|
||||||
|
WorkflowVersion: 2,
|
||||||
|
CurrentStage: "review",
|
||||||
|
Status: "active",
|
||||||
|
StartedBy: "user-1",
|
||||||
|
StageData: json.RawMessage(`{"key":"val"}`),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func testSignoff() *models.WorkflowSignoff {
|
||||||
|
return &models.WorkflowSignoff{
|
||||||
|
ID: "sig-1",
|
||||||
|
InstanceID: "inst-1",
|
||||||
|
Stage: "review",
|
||||||
|
UserID: "user-1",
|
||||||
|
Decision: "approve",
|
||||||
|
Comment: "LGTM",
|
||||||
|
CreatedAt: time.Date(2026, 4, 3, 12, 0, 0, 0, time.UTC),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func runWorkflowScript(t *testing.T, engine WorkflowEngine, rc *RunContext, script string) (starlark.StringDict, error) {
|
||||||
|
t.Helper()
|
||||||
|
mod := BuildWorkflowModule(context.Background(), store.Stores{}, engine, rc)
|
||||||
|
predeclared := starlark.StringDict{"workflow": mod}
|
||||||
|
globals, err := starlark.ExecFile(&starlark.Thread{Name: "test"}, "test.star", script, predeclared)
|
||||||
|
return globals, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Tests ────────────────────────────
|
||||||
|
|
||||||
|
func TestWorkflowStart(t *testing.T) {
|
||||||
|
mock := &mockWorkflowEngine{startResult: testInstance()}
|
||||||
|
rc := &RunContext{UserID: "user-1"}
|
||||||
|
|
||||||
|
globals, err := runWorkflowScript(t, mock, rc, `
|
||||||
|
result = workflow.start("wf-1", data={"key": "val"})
|
||||||
|
`)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if mock.lastMethod != "Start" {
|
||||||
|
t.Fatalf("expected Start, got %s", mock.lastMethod)
|
||||||
|
}
|
||||||
|
if mock.lastWorkflowID != "wf-1" {
|
||||||
|
t.Fatalf("expected wf-1, got %s", mock.lastWorkflowID)
|
||||||
|
}
|
||||||
|
if mock.lastUserID != "user-1" {
|
||||||
|
t.Fatalf("expected user-1, got %s", mock.lastUserID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify data was marshaled
|
||||||
|
var d map[string]interface{}
|
||||||
|
if err := json.Unmarshal(mock.lastData, &d); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if d["key"] != "val" {
|
||||||
|
t.Fatalf("expected data key=val, got %v", d)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify returned dict
|
||||||
|
result := globals["result"].(*starlark.Dict)
|
||||||
|
id, _, _ := result.Get(starlark.String("id"))
|
||||||
|
if id.(starlark.String).GoString() != "inst-1" {
|
||||||
|
t.Fatalf("expected id=inst-1, got %v", id)
|
||||||
|
}
|
||||||
|
status, _, _ := result.Get(starlark.String("status"))
|
||||||
|
if status.(starlark.String).GoString() != "active" {
|
||||||
|
t.Fatalf("expected status=active, got %v", status)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWorkflowAdvance(t *testing.T) {
|
||||||
|
inst := testInstance()
|
||||||
|
inst.CurrentStage = "complete"
|
||||||
|
mock := &mockWorkflowEngine{advanceResult: inst}
|
||||||
|
rc := &RunContext{UserID: "user-1"}
|
||||||
|
|
||||||
|
globals, err := runWorkflowScript(t, mock, rc, `
|
||||||
|
result = workflow.advance("inst-1", data={"step": 2})
|
||||||
|
`)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if mock.lastMethod != "Advance" {
|
||||||
|
t.Fatalf("expected Advance, got %s", mock.lastMethod)
|
||||||
|
}
|
||||||
|
if mock.lastInstanceID != "inst-1" {
|
||||||
|
t.Fatalf("expected inst-1, got %s", mock.lastInstanceID)
|
||||||
|
}
|
||||||
|
|
||||||
|
result := globals["result"].(*starlark.Dict)
|
||||||
|
stage, _, _ := result.Get(starlark.String("current_stage"))
|
||||||
|
if stage.(starlark.String).GoString() != "complete" {
|
||||||
|
t.Fatalf("expected current_stage=complete, got %v", stage)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWorkflowCancel(t *testing.T) {
|
||||||
|
mock := &mockWorkflowEngine{}
|
||||||
|
rc := &RunContext{UserID: "user-1"}
|
||||||
|
|
||||||
|
globals, err := runWorkflowScript(t, mock, rc, `
|
||||||
|
result = workflow.cancel("inst-1")
|
||||||
|
`)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if mock.lastMethod != "Cancel" {
|
||||||
|
t.Fatalf("expected Cancel, got %s", mock.lastMethod)
|
||||||
|
}
|
||||||
|
if mock.lastInstanceID != "inst-1" {
|
||||||
|
t.Fatalf("expected inst-1, got %s", mock.lastInstanceID)
|
||||||
|
}
|
||||||
|
if mock.lastUserID != "user-1" {
|
||||||
|
t.Fatalf("expected user-1, got %s", mock.lastUserID)
|
||||||
|
}
|
||||||
|
|
||||||
|
if globals["result"] != starlark.None {
|
||||||
|
t.Fatalf("expected None, got %v", globals["result"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWorkflowSubmitSignoff(t *testing.T) {
|
||||||
|
mock := &mockWorkflowEngine{signoffResult: testSignoff()}
|
||||||
|
rc := &RunContext{UserID: "user-1"}
|
||||||
|
|
||||||
|
globals, err := runWorkflowScript(t, mock, rc, `
|
||||||
|
result = workflow.submit_signoff("inst-1", "approve", comment="LGTM")
|
||||||
|
`)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if mock.lastMethod != "SubmitSignoff" {
|
||||||
|
t.Fatalf("expected SubmitSignoff, got %s", mock.lastMethod)
|
||||||
|
}
|
||||||
|
if mock.lastDecision != "approve" {
|
||||||
|
t.Fatalf("expected approve, got %s", mock.lastDecision)
|
||||||
|
}
|
||||||
|
if mock.lastComment != "LGTM" {
|
||||||
|
t.Fatalf("expected LGTM, got %s", mock.lastComment)
|
||||||
|
}
|
||||||
|
|
||||||
|
result := globals["result"].(*starlark.Dict)
|
||||||
|
decision, _, _ := result.Get(starlark.String("decision"))
|
||||||
|
if decision.(starlark.String).GoString() != "approve" {
|
||||||
|
t.Fatalf("expected decision=approve, got %v", decision)
|
||||||
|
}
|
||||||
|
comment, _, _ := result.Get(starlark.String("comment"))
|
||||||
|
if comment.(starlark.String).GoString() != "LGTM" {
|
||||||
|
t.Fatalf("expected comment=LGTM, got %v", comment)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWorkflowWrite_NoUser(t *testing.T) {
|
||||||
|
mock := &mockWorkflowEngine{startResult: testInstance()}
|
||||||
|
|
||||||
|
_, err := runWorkflowScript(t, mock, nil, `
|
||||||
|
result = workflow.start("wf-1")
|
||||||
|
`)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for nil RunContext")
|
||||||
|
}
|
||||||
|
if got := err.Error(); !contains(got, "authenticated user context") {
|
||||||
|
t.Fatalf("unexpected error: %s", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWorkflowWrite_NoEngine(t *testing.T) {
|
||||||
|
rc := &RunContext{UserID: "user-1"}
|
||||||
|
|
||||||
|
_, err := runWorkflowScript(t, nil, rc, `
|
||||||
|
result = workflow.start("wf-1")
|
||||||
|
`)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for nil engine")
|
||||||
|
}
|
||||||
|
if got := err.Error(); !contains(got, "engine not available") {
|
||||||
|
t.Fatalf("unexpected error: %s", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// contains is a test helper — strings.Contains without importing strings.
|
||||||
|
func contains(s, substr string) bool {
|
||||||
|
for i := 0; i <= len(s)-len(substr); i++ {
|
||||||
|
if s[i:i+len(substr)] == substr {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
@@ -437,13 +437,14 @@ components:
|
|||||||
type: integer
|
type: integer
|
||||||
stage_mode:
|
stage_mode:
|
||||||
type: string
|
type: string
|
||||||
enum: [form, review, delegated, automated]
|
enum: [form, delegated, automated]
|
||||||
audience:
|
audience:
|
||||||
type: string
|
type: string
|
||||||
enum: [team, public, system]
|
enum: [team, public, system]
|
||||||
stage_type:
|
stage_type:
|
||||||
type: string
|
type: string
|
||||||
enum: [simple, dynamic, automated]
|
deprecated: true
|
||||||
|
description: Deprecated — retained for backward compatibility. Automation is determined by starlark_hook presence.
|
||||||
form_template:
|
form_template:
|
||||||
type: object
|
type: object
|
||||||
stage_config:
|
stage_config:
|
||||||
@@ -1777,7 +1778,7 @@ paths:
|
|||||||
enum: [full, summary, fresh]
|
enum: [full, summary, fresh]
|
||||||
stage_mode:
|
stage_mode:
|
||||||
type: string
|
type: string
|
||||||
enum: [form, review, delegated, automated]
|
enum: [form, delegated, automated]
|
||||||
form_template:
|
form_template:
|
||||||
type: object
|
type: object
|
||||||
transition_rules:
|
transition_rules:
|
||||||
@@ -1817,7 +1818,7 @@ paths:
|
|||||||
enum: [full, summary, fresh]
|
enum: [full, summary, fresh]
|
||||||
stage_mode:
|
stage_mode:
|
||||||
type: string
|
type: string
|
||||||
enum: [form, review, delegated, automated]
|
enum: [form, delegated, automated]
|
||||||
form_template:
|
form_template:
|
||||||
type: object
|
type: object
|
||||||
transition_rules:
|
transition_rules:
|
||||||
@@ -3209,7 +3210,7 @@ paths:
|
|||||||
enum: [full, summary, fresh]
|
enum: [full, summary, fresh]
|
||||||
stage_mode:
|
stage_mode:
|
||||||
type: string
|
type: string
|
||||||
enum: [form, review, delegated, automated]
|
enum: [form, delegated, automated]
|
||||||
form_template:
|
form_template:
|
||||||
type: object
|
type: object
|
||||||
transition_rules:
|
transition_rules:
|
||||||
@@ -3250,7 +3251,7 @@ paths:
|
|||||||
enum: [full, summary, fresh]
|
enum: [full, summary, fresh]
|
||||||
stage_mode:
|
stage_mode:
|
||||||
type: string
|
type: string
|
||||||
enum: [form, review, delegated, automated]
|
enum: [form, delegated, automated]
|
||||||
form_template:
|
form_template:
|
||||||
type: object
|
type: object
|
||||||
transition_rules:
|
transition_rules:
|
||||||
|
|||||||
@@ -188,6 +188,46 @@ type TeamStore interface {
|
|||||||
|
|
||||||
// MergeSettings merges a JSON string into the team's settings column.
|
// MergeSettings merges a JSON string into the team's settings column.
|
||||||
MergeSettings(ctx context.Context, teamID, settingsJSON string) error
|
MergeSettings(ctx context.Context, teamID, settingsJSON string) error
|
||||||
|
|
||||||
|
// ── v0.9.3 — Team User Roles (many-to-many) ──
|
||||||
|
|
||||||
|
// AddUserRole assigns an additional role to a team member. Idempotent.
|
||||||
|
AddUserRole(ctx context.Context, teamID, userID, role, assignedBy string) error
|
||||||
|
|
||||||
|
// RemoveUserRole removes one additional role from a team member.
|
||||||
|
RemoveUserRole(ctx context.Context, teamID, userID, role string) error
|
||||||
|
|
||||||
|
// ListUserRoles returns the additional roles for a member (excludes primary).
|
||||||
|
ListUserRoles(ctx context.Context, teamID, userID string) ([]string, error)
|
||||||
|
|
||||||
|
// GetMemberRoles returns the full effective role set (primary + additional).
|
||||||
|
GetMemberRoles(ctx context.Context, teamID, userID string) ([]string, error)
|
||||||
|
|
||||||
|
// HasRole checks whether a user holds a specific role (primary or additional).
|
||||||
|
HasRole(ctx context.Context, teamID, userID, role string) (bool, error)
|
||||||
|
|
||||||
|
// HasRoleInAnyTeam checks whether a user holds a specific role in any team.
|
||||||
|
HasRoleInAnyTeam(ctx context.Context, userID, role string) (bool, error)
|
||||||
|
|
||||||
|
// RemoveAllUserRoles deletes all additional roles for a member (cleanup on removal).
|
||||||
|
RemoveAllUserRoles(ctx context.Context, teamID, userID string) error
|
||||||
|
|
||||||
|
// ── v0.9.4 — Team Role Catalog ──
|
||||||
|
|
||||||
|
// AddRoleToCatalog registers a known role for the team. Idempotent.
|
||||||
|
AddRoleToCatalog(ctx context.Context, teamID, role, sourcePkgID string) error
|
||||||
|
|
||||||
|
// ListRoleCatalog returns all known roles for a team.
|
||||||
|
ListRoleCatalog(ctx context.Context, teamID string) ([]TeamRoleCatalogEntry, error)
|
||||||
|
|
||||||
|
// RemoveRoleCatalogBySource removes all catalog entries sourced from a package.
|
||||||
|
RemoveRoleCatalogBySource(ctx context.Context, teamID, sourcePkgID string) error
|
||||||
|
}
|
||||||
|
|
||||||
|
// TeamRoleCatalogEntry represents a known role sourced from an adopted package.
|
||||||
|
type TeamRoleCatalogEntry struct {
|
||||||
|
Role string `json:"role"`
|
||||||
|
SourcePackageID string `json:"source_package_id,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// =========================================
|
// =========================================
|
||||||
|
|||||||
@@ -93,6 +93,14 @@ type PackageStore interface {
|
|||||||
|
|
||||||
// DeleteTeamSettings removes team-scoped overrides, reverting to global defaults.
|
// DeleteTeamSettings removes team-scoped overrides, reverting to global defaults.
|
||||||
DeleteTeamSettings(ctx context.Context, pkgID, teamID string) error
|
DeleteTeamSettings(ctx context.Context, pkgID, teamID string) error
|
||||||
|
|
||||||
|
// ── v0.9.4 — Package Adoption ───────────────
|
||||||
|
|
||||||
|
// ListAdoptable returns global, adoptable, enabled packages.
|
||||||
|
ListAdoptable(ctx context.Context) ([]PackageRegistration, error)
|
||||||
|
|
||||||
|
// GetByAdoptedFrom returns a team's adopted copy of a source package, or nil.
|
||||||
|
GetByAdoptedFrom(ctx context.Context, sourceID, teamID string) (*PackageRegistration, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
// PackageRegistration is a row from the packages table.
|
// PackageRegistration is a row from the packages table.
|
||||||
@@ -114,6 +122,8 @@ type PackageRegistration struct {
|
|||||||
SchemaVersion int `json:"schema_version" db:"schema_version"`
|
SchemaVersion int `json:"schema_version" db:"schema_version"`
|
||||||
PackageSettings json.RawMessage `json:"package_settings" db:"package_settings"`
|
PackageSettings json.RawMessage `json:"package_settings" db:"package_settings"`
|
||||||
Source string `json:"source" db:"source"`
|
Source string `json:"source" db:"source"`
|
||||||
|
Adoptable bool `json:"adoptable" db:"adoptable"`
|
||||||
|
AdoptedFrom *string `json:"adopted_from,omitempty" db:"adopted_from"`
|
||||||
InstalledAt string `json:"installed_at" db:"installed_at"`
|
InstalledAt string `json:"installed_at" db:"installed_at"`
|
||||||
UpdatedAt string `json:"updated_at" db:"updated_at"`
|
UpdatedAt string `json:"updated_at" db:"updated_at"`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -107,15 +107,15 @@ func (s *PackageStore) Create(ctx context.Context, pkg *store.PackageRegistratio
|
|||||||
return DB.QueryRowContext(ctx, `
|
return DB.QueryRowContext(ctx, `
|
||||||
INSERT INTO packages (id, title, type, version, description, author, tier,
|
INSERT INTO packages (id, title, type, version, description, author, tier,
|
||||||
is_system, scope, team_id, installed_by, manifest, enabled, status,
|
is_system, scope, team_id, installed_by, manifest, enabled, status,
|
||||||
schema_version, package_settings, source)
|
schema_version, package_settings, source, adoptable, adopted_from)
|
||||||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17)
|
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19)
|
||||||
RETURNING installed_at, updated_at`,
|
RETURNING installed_at, updated_at`,
|
||||||
pkg.ID, pkg.Title, pkg.Type, pkg.Version, pkg.Description, pkg.Author,
|
pkg.ID, pkg.Title, pkg.Type, pkg.Version, pkg.Description, pkg.Author,
|
||||||
pkg.Tier, pkg.IsSystem, pkg.Scope,
|
pkg.Tier, pkg.IsSystem, pkg.Scope,
|
||||||
nullStrPtr(pkg.TeamID), nullStrPtr(pkg.InstalledBy),
|
nullStrPtr(pkg.TeamID), nullStrPtr(pkg.InstalledBy),
|
||||||
manifestJSON, pkg.Enabled, pkg.Status,
|
manifestJSON, pkg.Enabled, pkg.Status,
|
||||||
pkg.SchemaVersion, defaultJSON(pkg.PackageSettings),
|
pkg.SchemaVersion, defaultJSON(pkg.PackageSettings),
|
||||||
pkg.Source,
|
pkg.Source, pkg.Adoptable, nullStrPtr(pkg.AdoptedFrom),
|
||||||
).Scan(&pkg.InstalledAt, &pkg.UpdatedAt)
|
).Scan(&pkg.InstalledAt, &pkg.UpdatedAt)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -173,7 +173,7 @@ func (s *PackageStore) ListForUser(ctx context.Context, userID string) ([]store.
|
|||||||
var result []store.UserPackage
|
var result []store.UserPackage
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var up store.UserPackage
|
var up store.UserPackage
|
||||||
var teamID, installedBy sql.NullString
|
var teamID, installedBy, adoptedFrom sql.NullString
|
||||||
var manifestJSON []byte
|
var manifestJSON []byte
|
||||||
var pkgSettings []byte
|
var pkgSettings []byte
|
||||||
var userEnabled sql.NullBool
|
var userEnabled sql.NullBool
|
||||||
@@ -185,13 +185,15 @@ func (s *PackageStore) ListForUser(ctx context.Context, userID string) ([]store.
|
|||||||
&teamID, &installedBy,
|
&teamID, &installedBy,
|
||||||
&manifestJSON, &up.Enabled, &up.Status,
|
&manifestJSON, &up.Enabled, &up.Status,
|
||||||
&up.SchemaVersion, &pkgSettings,
|
&up.SchemaVersion, &pkgSettings,
|
||||||
&up.Source, &up.InstalledAt, &up.UpdatedAt,
|
&up.Source, &up.Adoptable, &adoptedFrom,
|
||||||
|
&up.InstalledAt, &up.UpdatedAt,
|
||||||
&userEnabled, &userSettings,
|
&userEnabled, &userSettings,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
up.TeamID = NullableStringPtr(teamID)
|
up.TeamID = NullableStringPtr(teamID)
|
||||||
up.InstalledBy = NullableStringPtr(installedBy)
|
up.InstalledBy = NullableStringPtr(installedBy)
|
||||||
|
up.AdoptedFrom = NullableStringPtr(adoptedFrom)
|
||||||
json.Unmarshal(manifestJSON, &up.Manifest)
|
json.Unmarshal(manifestJSON, &up.Manifest)
|
||||||
up.PackageSettings = json.RawMessage(pkgSettings)
|
up.PackageSettings = json.RawMessage(pkgSettings)
|
||||||
if userEnabled.Valid {
|
if userEnabled.Valid {
|
||||||
@@ -250,11 +252,12 @@ const pkgCols = `p.id, p.title, p.type, p.version, p.description, p.author,
|
|||||||
p.tier, p.is_system, p.scope, p.team_id, p.installed_by,
|
p.tier, p.is_system, p.scope, p.team_id, p.installed_by,
|
||||||
p.manifest, p.enabled, p.status,
|
p.manifest, p.enabled, p.status,
|
||||||
p.schema_version, p.package_settings,
|
p.schema_version, p.package_settings,
|
||||||
p.source, p.installed_at, p.updated_at`
|
p.source, p.adoptable, p.adopted_from,
|
||||||
|
p.installed_at, p.updated_at`
|
||||||
|
|
||||||
func (s *PackageStore) scanOne(ctx context.Context, query string, args ...interface{}) (*store.PackageRegistration, error) {
|
func (s *PackageStore) scanOne(ctx context.Context, query string, args ...interface{}) (*store.PackageRegistration, error) {
|
||||||
var pkg store.PackageRegistration
|
var pkg store.PackageRegistration
|
||||||
var teamID, installedBy sql.NullString
|
var teamID, installedBy, adoptedFrom sql.NullString
|
||||||
var manifestJSON []byte
|
var manifestJSON []byte
|
||||||
var pkgSettings []byte
|
var pkgSettings []byte
|
||||||
err := DB.QueryRowContext(ctx, query, args...).Scan(
|
err := DB.QueryRowContext(ctx, query, args...).Scan(
|
||||||
@@ -263,7 +266,8 @@ func (s *PackageStore) scanOne(ctx context.Context, query string, args ...interf
|
|||||||
&teamID, &installedBy,
|
&teamID, &installedBy,
|
||||||
&manifestJSON, &pkg.Enabled, &pkg.Status,
|
&manifestJSON, &pkg.Enabled, &pkg.Status,
|
||||||
&pkg.SchemaVersion, &pkgSettings,
|
&pkg.SchemaVersion, &pkgSettings,
|
||||||
&pkg.Source, &pkg.InstalledAt, &pkg.UpdatedAt,
|
&pkg.Source, &pkg.Adoptable, &adoptedFrom,
|
||||||
|
&pkg.InstalledAt, &pkg.UpdatedAt,
|
||||||
)
|
)
|
||||||
if err == sql.ErrNoRows {
|
if err == sql.ErrNoRows {
|
||||||
return nil, nil
|
return nil, nil
|
||||||
@@ -273,6 +277,7 @@ func (s *PackageStore) scanOne(ctx context.Context, query string, args ...interf
|
|||||||
}
|
}
|
||||||
pkg.TeamID = NullableStringPtr(teamID)
|
pkg.TeamID = NullableStringPtr(teamID)
|
||||||
pkg.InstalledBy = NullableStringPtr(installedBy)
|
pkg.InstalledBy = NullableStringPtr(installedBy)
|
||||||
|
pkg.AdoptedFrom = NullableStringPtr(adoptedFrom)
|
||||||
json.Unmarshal(manifestJSON, &pkg.Manifest)
|
json.Unmarshal(manifestJSON, &pkg.Manifest)
|
||||||
pkg.PackageSettings = json.RawMessage(pkgSettings)
|
pkg.PackageSettings = json.RawMessage(pkgSettings)
|
||||||
return &pkg, nil
|
return &pkg, nil
|
||||||
@@ -288,7 +293,7 @@ func (s *PackageStore) scanMany(ctx context.Context, query string, args ...inter
|
|||||||
var result []store.PackageRegistration
|
var result []store.PackageRegistration
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var pkg store.PackageRegistration
|
var pkg store.PackageRegistration
|
||||||
var teamID, installedBy sql.NullString
|
var teamID, installedBy, adoptedFrom sql.NullString
|
||||||
var manifestJSON []byte
|
var manifestJSON []byte
|
||||||
var pkgSettings []byte
|
var pkgSettings []byte
|
||||||
if err := rows.Scan(
|
if err := rows.Scan(
|
||||||
@@ -297,12 +302,14 @@ func (s *PackageStore) scanMany(ctx context.Context, query string, args ...inter
|
|||||||
&teamID, &installedBy,
|
&teamID, &installedBy,
|
||||||
&manifestJSON, &pkg.Enabled, &pkg.Status,
|
&manifestJSON, &pkg.Enabled, &pkg.Status,
|
||||||
&pkg.SchemaVersion, &pkgSettings,
|
&pkg.SchemaVersion, &pkgSettings,
|
||||||
&pkg.Source, &pkg.InstalledAt, &pkg.UpdatedAt,
|
&pkg.Source, &pkg.Adoptable, &adoptedFrom,
|
||||||
|
&pkg.InstalledAt, &pkg.UpdatedAt,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
pkg.TeamID = NullableStringPtr(teamID)
|
pkg.TeamID = NullableStringPtr(teamID)
|
||||||
pkg.InstalledBy = NullableStringPtr(installedBy)
|
pkg.InstalledBy = NullableStringPtr(installedBy)
|
||||||
|
pkg.AdoptedFrom = NullableStringPtr(adoptedFrom)
|
||||||
json.Unmarshal(manifestJSON, &pkg.Manifest)
|
json.Unmarshal(manifestJSON, &pkg.Manifest)
|
||||||
pkg.PackageSettings = json.RawMessage(pkgSettings)
|
pkg.PackageSettings = json.RawMessage(pkgSettings)
|
||||||
result = append(result, pkg)
|
result = append(result, pkg)
|
||||||
@@ -397,6 +404,23 @@ func (s *PackageStore) DeleteTeamSettings(ctx context.Context, pkgID, teamID str
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── v0.9.4 — Package Adoption ───────────────
|
||||||
|
|
||||||
|
func (s *PackageStore) ListAdoptable(ctx context.Context) ([]store.PackageRegistration, error) {
|
||||||
|
return s.scanMany(ctx, `
|
||||||
|
SELECT `+pkgCols+`
|
||||||
|
FROM packages p
|
||||||
|
WHERE p.adoptable = true AND p.scope = 'global' AND p.enabled = true
|
||||||
|
ORDER BY p.title`)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *PackageStore) GetByAdoptedFrom(ctx context.Context, sourceID, teamID string) (*store.PackageRegistration, error) {
|
||||||
|
return s.scanOne(ctx, `
|
||||||
|
SELECT `+pkgCols+`
|
||||||
|
FROM packages p
|
||||||
|
WHERE p.adopted_from = $1 AND p.team_id = $2`, sourceID, teamID)
|
||||||
|
}
|
||||||
|
|
||||||
// nullStrPtr converts *string to sql.NullString for nullable FK columns.
|
// nullStrPtr converts *string to sql.NullString for nullable FK columns.
|
||||||
func nullStrPtr(s *string) sql.NullString {
|
func nullStrPtr(s *string) sql.NullString {
|
||||||
if s == nil {
|
if s == nil {
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
|
||||||
"armature/models"
|
"armature/models"
|
||||||
|
"armature/store"
|
||||||
)
|
)
|
||||||
|
|
||||||
type TeamStore struct{}
|
type TeamStore struct{}
|
||||||
@@ -308,3 +309,136 @@ func (s *TeamStore) MergeSettings(ctx context.Context, teamID, settingsJSON stri
|
|||||||
settingsJSON, teamID)
|
settingsJSON, teamID)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── v0.9.3 — Team User Roles (many-to-many) ──────────────────
|
||||||
|
|
||||||
|
func (s *TeamStore) AddUserRole(ctx context.Context, teamID, userID, role, assignedBy string) error {
|
||||||
|
_, err := DB.ExecContext(ctx, `
|
||||||
|
INSERT INTO team_user_roles (team_id, user_id, role, assigned_by)
|
||||||
|
VALUES ($1, $2, $3, $4)
|
||||||
|
ON CONFLICT (team_id, user_id, role) DO NOTHING`,
|
||||||
|
teamID, userID, role, assignedBy)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *TeamStore) RemoveUserRole(ctx context.Context, teamID, userID, role string) error {
|
||||||
|
_, err := DB.ExecContext(ctx,
|
||||||
|
`DELETE FROM team_user_roles WHERE team_id = $1 AND user_id = $2 AND role = $3`,
|
||||||
|
teamID, userID, role)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *TeamStore) ListUserRoles(ctx context.Context, teamID, userID string) ([]string, error) {
|
||||||
|
rows, err := DB.QueryContext(ctx,
|
||||||
|
`SELECT role FROM team_user_roles WHERE team_id = $1 AND user_id = $2 ORDER BY role`,
|
||||||
|
teamID, userID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var roles []string
|
||||||
|
for rows.Next() {
|
||||||
|
var r string
|
||||||
|
if err := rows.Scan(&r); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
roles = append(roles, r)
|
||||||
|
}
|
||||||
|
if roles == nil {
|
||||||
|
roles = []string{}
|
||||||
|
}
|
||||||
|
return roles, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *TeamStore) GetMemberRoles(ctx context.Context, teamID, userID string) ([]string, error) {
|
||||||
|
rows, err := DB.QueryContext(ctx, `
|
||||||
|
SELECT role FROM team_members WHERE team_id = $1 AND user_id = $2
|
||||||
|
UNION
|
||||||
|
SELECT role FROM team_user_roles WHERE team_id = $1 AND user_id = $2
|
||||||
|
ORDER BY role`,
|
||||||
|
teamID, userID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var roles []string
|
||||||
|
for rows.Next() {
|
||||||
|
var r string
|
||||||
|
if err := rows.Scan(&r); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
roles = append(roles, r)
|
||||||
|
}
|
||||||
|
if roles == nil {
|
||||||
|
roles = []string{}
|
||||||
|
}
|
||||||
|
return roles, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *TeamStore) HasRole(ctx context.Context, teamID, userID, role string) (bool, error) {
|
||||||
|
var exists bool
|
||||||
|
err := DB.QueryRowContext(ctx, `
|
||||||
|
SELECT EXISTS(
|
||||||
|
SELECT 1 FROM team_members WHERE team_id = $1 AND user_id = $2 AND role = $3
|
||||||
|
UNION ALL
|
||||||
|
SELECT 1 FROM team_user_roles WHERE team_id = $1 AND user_id = $2 AND role = $3
|
||||||
|
)`, teamID, userID, role).Scan(&exists)
|
||||||
|
return exists, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *TeamStore) HasRoleInAnyTeam(ctx context.Context, userID, role string) (bool, error) {
|
||||||
|
var exists bool
|
||||||
|
err := DB.QueryRowContext(ctx, `
|
||||||
|
SELECT EXISTS(
|
||||||
|
SELECT 1 FROM team_members WHERE user_id = $1 AND role = $2
|
||||||
|
UNION ALL
|
||||||
|
SELECT 1 FROM team_user_roles WHERE user_id = $1 AND role = $2
|
||||||
|
)`, userID, role).Scan(&exists)
|
||||||
|
return exists, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *TeamStore) RemoveAllUserRoles(ctx context.Context, teamID, userID string) error {
|
||||||
|
_, err := DB.ExecContext(ctx,
|
||||||
|
`DELETE FROM team_user_roles WHERE team_id = $1 AND user_id = $2`,
|
||||||
|
teamID, userID)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── v0.9.4 — Team Role Catalog ──
|
||||||
|
|
||||||
|
func (s *TeamStore) AddRoleToCatalog(ctx context.Context, teamID, role, sourcePkgID string) error {
|
||||||
|
_, err := DB.ExecContext(ctx, `
|
||||||
|
INSERT INTO team_role_catalog (id, team_id, role, source_package_id)
|
||||||
|
VALUES (gen_random_uuid(), $1, $2, $3)
|
||||||
|
ON CONFLICT (team_id, role) DO NOTHING`,
|
||||||
|
teamID, role, sourcePkgID)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *TeamStore) ListRoleCatalog(ctx context.Context, teamID string) ([]store.TeamRoleCatalogEntry, error) {
|
||||||
|
rows, err := DB.QueryContext(ctx, `
|
||||||
|
SELECT role, COALESCE(source_package_id, '') FROM team_role_catalog
|
||||||
|
WHERE team_id = $1 ORDER BY role`, teamID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var result []store.TeamRoleCatalogEntry
|
||||||
|
for rows.Next() {
|
||||||
|
var e store.TeamRoleCatalogEntry
|
||||||
|
if err := rows.Scan(&e.Role, &e.SourcePackageID); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
result = append(result, e)
|
||||||
|
}
|
||||||
|
return result, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *TeamStore) RemoveRoleCatalogBySource(ctx context.Context, teamID, sourcePkgID string) error {
|
||||||
|
_, err := DB.ExecContext(ctx, `
|
||||||
|
DELETE FROM team_role_catalog WHERE team_id = $1 AND source_package_id = $2`,
|
||||||
|
teamID, sourcePkgID)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|||||||
@@ -109,18 +109,22 @@ func (s *PackageStore) Create(ctx context.Context, pkg *store.PackageRegistratio
|
|||||||
if pkg.Status == "" {
|
if pkg.Status == "" {
|
||||||
pkg.Status = "active"
|
pkg.Status = "active"
|
||||||
}
|
}
|
||||||
|
adoptableInt := 0
|
||||||
|
if pkg.Adoptable {
|
||||||
|
adoptableInt = 1
|
||||||
|
}
|
||||||
_, err := DB.ExecContext(ctx, `
|
_, err := DB.ExecContext(ctx, `
|
||||||
INSERT INTO packages (id, title, type, version, description, author, tier,
|
INSERT INTO packages (id, title, type, version, description, author, tier,
|
||||||
is_system, scope, team_id, installed_by, manifest, enabled, status,
|
is_system, scope, team_id, installed_by, manifest, enabled, status,
|
||||||
schema_version, package_settings, source,
|
schema_version, package_settings, source, adoptable, adopted_from,
|
||||||
installed_at, updated_at)
|
installed_at, updated_at)
|
||||||
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`,
|
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`,
|
||||||
pkg.ID, pkg.Title, pkg.Type, pkg.Version, pkg.Description, pkg.Author,
|
pkg.ID, pkg.Title, pkg.Type, pkg.Version, pkg.Description, pkg.Author,
|
||||||
pkg.Tier, pkg.IsSystem, pkg.Scope,
|
pkg.Tier, pkg.IsSystem, pkg.Scope,
|
||||||
nullStrPtr(pkg.TeamID), nullStrPtr(pkg.InstalledBy),
|
nullStrPtr(pkg.TeamID), nullStrPtr(pkg.InstalledBy),
|
||||||
manifestJSON, pkg.Enabled, pkg.Status,
|
manifestJSON, pkg.Enabled, pkg.Status,
|
||||||
pkg.SchemaVersion, defaultJSON(pkg.PackageSettings),
|
pkg.SchemaVersion, defaultJSON(pkg.PackageSettings),
|
||||||
pkg.Source,
|
pkg.Source, adoptableInt, nullStrPtr(pkg.AdoptedFrom),
|
||||||
now.Format(timeFmt), now.Format(timeFmt),
|
now.Format(timeFmt), now.Format(timeFmt),
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -184,11 +188,12 @@ func (s *PackageStore) ListForUser(ctx context.Context, userID string) ([]store.
|
|||||||
var result []store.UserPackage
|
var result []store.UserPackage
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var up store.UserPackage
|
var up store.UserPackage
|
||||||
var teamID, installedBy sql.NullString
|
var teamID, installedBy, adoptedFrom sql.NullString
|
||||||
var manifestJSON string
|
var manifestJSON string
|
||||||
var pkgSettings string
|
var pkgSettings string
|
||||||
var enabledInt int
|
var enabledInt int
|
||||||
var isSystemInt int
|
var isSystemInt int
|
||||||
|
var adoptableInt int
|
||||||
var userEnabled sql.NullBool
|
var userEnabled sql.NullBool
|
||||||
var userSettings []byte
|
var userSettings []byte
|
||||||
|
|
||||||
@@ -198,15 +203,18 @@ func (s *PackageStore) ListForUser(ctx context.Context, userID string) ([]store.
|
|||||||
&teamID, &installedBy,
|
&teamID, &installedBy,
|
||||||
&manifestJSON, &enabledInt, &up.Status,
|
&manifestJSON, &enabledInt, &up.Status,
|
||||||
&up.SchemaVersion, &pkgSettings,
|
&up.SchemaVersion, &pkgSettings,
|
||||||
&up.Source, &up.InstalledAt, &up.UpdatedAt,
|
&up.Source, &adoptableInt, &adoptedFrom,
|
||||||
|
&up.InstalledAt, &up.UpdatedAt,
|
||||||
&userEnabled, &userSettings,
|
&userEnabled, &userSettings,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
up.IsSystem = isSystemInt != 0
|
up.IsSystem = isSystemInt != 0
|
||||||
up.Enabled = enabledInt != 0
|
up.Enabled = enabledInt != 0
|
||||||
|
up.Adoptable = adoptableInt != 0
|
||||||
up.TeamID = NullableStringPtr(teamID)
|
up.TeamID = NullableStringPtr(teamID)
|
||||||
up.InstalledBy = NullableStringPtr(installedBy)
|
up.InstalledBy = NullableStringPtr(installedBy)
|
||||||
|
up.AdoptedFrom = NullableStringPtr(adoptedFrom)
|
||||||
json.Unmarshal([]byte(manifestJSON), &up.Manifest)
|
json.Unmarshal([]byte(manifestJSON), &up.Manifest)
|
||||||
up.PackageSettings = json.RawMessage(pkgSettings)
|
up.PackageSettings = json.RawMessage(pkgSettings)
|
||||||
if userEnabled.Valid {
|
if userEnabled.Valid {
|
||||||
@@ -263,22 +271,25 @@ const pkgCols = `p.id, p.title, p.type, p.version, p.description, p.author,
|
|||||||
p.tier, p.is_system, p.scope, p.team_id, p.installed_by,
|
p.tier, p.is_system, p.scope, p.team_id, p.installed_by,
|
||||||
p.manifest, p.enabled, p.status,
|
p.manifest, p.enabled, p.status,
|
||||||
p.schema_version, p.package_settings,
|
p.schema_version, p.package_settings,
|
||||||
p.source, p.installed_at, p.updated_at`
|
p.source, p.adoptable, p.adopted_from,
|
||||||
|
p.installed_at, p.updated_at`
|
||||||
|
|
||||||
func (s *PackageStore) scanOne(ctx context.Context, query string, args ...interface{}) (*store.PackageRegistration, error) {
|
func (s *PackageStore) scanOne(ctx context.Context, query string, args ...interface{}) (*store.PackageRegistration, error) {
|
||||||
var pkg store.PackageRegistration
|
var pkg store.PackageRegistration
|
||||||
var teamID, installedBy sql.NullString
|
var teamID, installedBy, adoptedFrom sql.NullString
|
||||||
var manifestJSON string
|
var manifestJSON string
|
||||||
var pkgSettings string
|
var pkgSettings string
|
||||||
var enabledInt int
|
var enabledInt int
|
||||||
var isSystemInt int
|
var isSystemInt int
|
||||||
|
var adoptableInt int
|
||||||
err := DB.QueryRowContext(ctx, query, args...).Scan(
|
err := DB.QueryRowContext(ctx, query, args...).Scan(
|
||||||
&pkg.ID, &pkg.Title, &pkg.Type, &pkg.Version, &pkg.Description,
|
&pkg.ID, &pkg.Title, &pkg.Type, &pkg.Version, &pkg.Description,
|
||||||
&pkg.Author, &pkg.Tier, &isSystemInt, &pkg.Scope,
|
&pkg.Author, &pkg.Tier, &isSystemInt, &pkg.Scope,
|
||||||
&teamID, &installedBy,
|
&teamID, &installedBy,
|
||||||
&manifestJSON, &enabledInt, &pkg.Status,
|
&manifestJSON, &enabledInt, &pkg.Status,
|
||||||
&pkg.SchemaVersion, &pkgSettings,
|
&pkg.SchemaVersion, &pkgSettings,
|
||||||
&pkg.Source, &pkg.InstalledAt, &pkg.UpdatedAt,
|
&pkg.Source, &adoptableInt, &adoptedFrom,
|
||||||
|
&pkg.InstalledAt, &pkg.UpdatedAt,
|
||||||
)
|
)
|
||||||
if err == sql.ErrNoRows {
|
if err == sql.ErrNoRows {
|
||||||
return nil, nil
|
return nil, nil
|
||||||
@@ -288,8 +299,10 @@ func (s *PackageStore) scanOne(ctx context.Context, query string, args ...interf
|
|||||||
}
|
}
|
||||||
pkg.IsSystem = isSystemInt != 0
|
pkg.IsSystem = isSystemInt != 0
|
||||||
pkg.Enabled = enabledInt != 0
|
pkg.Enabled = enabledInt != 0
|
||||||
|
pkg.Adoptable = adoptableInt != 0
|
||||||
pkg.TeamID = NullableStringPtr(teamID)
|
pkg.TeamID = NullableStringPtr(teamID)
|
||||||
pkg.InstalledBy = NullableStringPtr(installedBy)
|
pkg.InstalledBy = NullableStringPtr(installedBy)
|
||||||
|
pkg.AdoptedFrom = NullableStringPtr(adoptedFrom)
|
||||||
json.Unmarshal([]byte(manifestJSON), &pkg.Manifest)
|
json.Unmarshal([]byte(manifestJSON), &pkg.Manifest)
|
||||||
pkg.PackageSettings = json.RawMessage(pkgSettings)
|
pkg.PackageSettings = json.RawMessage(pkgSettings)
|
||||||
return &pkg, nil
|
return &pkg, nil
|
||||||
@@ -305,25 +318,29 @@ func (s *PackageStore) scanMany(ctx context.Context, query string, args ...inter
|
|||||||
var result []store.PackageRegistration
|
var result []store.PackageRegistration
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var pkg store.PackageRegistration
|
var pkg store.PackageRegistration
|
||||||
var teamID, installedBy sql.NullString
|
var teamID, installedBy, adoptedFrom sql.NullString
|
||||||
var manifestJSON string
|
var manifestJSON string
|
||||||
var pkgSettings string
|
var pkgSettings string
|
||||||
var enabledInt int
|
var enabledInt int
|
||||||
var isSystemInt int
|
var isSystemInt int
|
||||||
|
var adoptableInt int
|
||||||
if err := rows.Scan(
|
if err := rows.Scan(
|
||||||
&pkg.ID, &pkg.Title, &pkg.Type, &pkg.Version, &pkg.Description,
|
&pkg.ID, &pkg.Title, &pkg.Type, &pkg.Version, &pkg.Description,
|
||||||
&pkg.Author, &pkg.Tier, &isSystemInt, &pkg.Scope,
|
&pkg.Author, &pkg.Tier, &isSystemInt, &pkg.Scope,
|
||||||
&teamID, &installedBy,
|
&teamID, &installedBy,
|
||||||
&manifestJSON, &enabledInt, &pkg.Status,
|
&manifestJSON, &enabledInt, &pkg.Status,
|
||||||
&pkg.SchemaVersion, &pkgSettings,
|
&pkg.SchemaVersion, &pkgSettings,
|
||||||
&pkg.Source, &pkg.InstalledAt, &pkg.UpdatedAt,
|
&pkg.Source, &adoptableInt, &adoptedFrom,
|
||||||
|
&pkg.InstalledAt, &pkg.UpdatedAt,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
pkg.IsSystem = isSystemInt != 0
|
pkg.IsSystem = isSystemInt != 0
|
||||||
pkg.Enabled = enabledInt != 0
|
pkg.Enabled = enabledInt != 0
|
||||||
|
pkg.Adoptable = adoptableInt != 0
|
||||||
pkg.TeamID = NullableStringPtr(teamID)
|
pkg.TeamID = NullableStringPtr(teamID)
|
||||||
pkg.InstalledBy = NullableStringPtr(installedBy)
|
pkg.InstalledBy = NullableStringPtr(installedBy)
|
||||||
|
pkg.AdoptedFrom = NullableStringPtr(adoptedFrom)
|
||||||
json.Unmarshal([]byte(manifestJSON), &pkg.Manifest)
|
json.Unmarshal([]byte(manifestJSON), &pkg.Manifest)
|
||||||
pkg.PackageSettings = json.RawMessage(pkgSettings)
|
pkg.PackageSettings = json.RawMessage(pkgSettings)
|
||||||
result = append(result, pkg)
|
result = append(result, pkg)
|
||||||
@@ -418,6 +435,23 @@ func (s *PackageStore) DeleteTeamSettings(ctx context.Context, pkgID, teamID str
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── v0.9.4 — Package Adoption ───────────────
|
||||||
|
|
||||||
|
func (s *PackageStore) ListAdoptable(ctx context.Context) ([]store.PackageRegistration, error) {
|
||||||
|
return s.scanMany(ctx, `
|
||||||
|
SELECT `+pkgCols+`
|
||||||
|
FROM packages p
|
||||||
|
WHERE p.adoptable = 1 AND p.scope = 'global' AND p.enabled = 1
|
||||||
|
ORDER BY p.title`)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *PackageStore) GetByAdoptedFrom(ctx context.Context, sourceID, teamID string) (*store.PackageRegistration, error) {
|
||||||
|
return s.scanOne(ctx, `
|
||||||
|
SELECT `+pkgCols+`
|
||||||
|
FROM packages p
|
||||||
|
WHERE p.adopted_from = ? AND p.team_id = ?`, sourceID, teamID)
|
||||||
|
}
|
||||||
|
|
||||||
func nullStrPtr(s *string) sql.NullString {
|
func nullStrPtr(s *string) sql.NullString {
|
||||||
if s == nil {
|
if s == nil {
|
||||||
return sql.NullString{}
|
return sql.NullString{}
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ import (
|
|||||||
|
|
||||||
"armature/models"
|
"armature/models"
|
||||||
"armature/store"
|
"armature/store"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
)
|
)
|
||||||
|
|
||||||
type TeamStore struct{}
|
type TeamStore struct{}
|
||||||
@@ -314,3 +316,136 @@ func (s *TeamStore) MergeSettings(ctx context.Context, teamID, settingsJSON stri
|
|||||||
settingsJSON, teamID)
|
settingsJSON, teamID)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── v0.9.3 — Team User Roles (many-to-many) ──────────────────
|
||||||
|
|
||||||
|
func (s *TeamStore) AddUserRole(ctx context.Context, teamID, userID, role, assignedBy string) error {
|
||||||
|
_, err := DB.ExecContext(ctx, `
|
||||||
|
INSERT INTO team_user_roles (id, team_id, user_id, role, assigned_by)
|
||||||
|
VALUES (?, ?, ?, ?, ?)
|
||||||
|
ON CONFLICT (team_id, user_id, role) DO NOTHING`,
|
||||||
|
store.NewID(), teamID, userID, role, assignedBy)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *TeamStore) RemoveUserRole(ctx context.Context, teamID, userID, role string) error {
|
||||||
|
_, err := DB.ExecContext(ctx,
|
||||||
|
`DELETE FROM team_user_roles WHERE team_id = ? AND user_id = ? AND role = ?`,
|
||||||
|
teamID, userID, role)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *TeamStore) ListUserRoles(ctx context.Context, teamID, userID string) ([]string, error) {
|
||||||
|
rows, err := DB.QueryContext(ctx,
|
||||||
|
`SELECT role FROM team_user_roles WHERE team_id = ? AND user_id = ? ORDER BY role`,
|
||||||
|
teamID, userID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var roles []string
|
||||||
|
for rows.Next() {
|
||||||
|
var r string
|
||||||
|
if err := rows.Scan(&r); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
roles = append(roles, r)
|
||||||
|
}
|
||||||
|
if roles == nil {
|
||||||
|
roles = []string{}
|
||||||
|
}
|
||||||
|
return roles, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *TeamStore) GetMemberRoles(ctx context.Context, teamID, userID string) ([]string, error) {
|
||||||
|
rows, err := DB.QueryContext(ctx, `
|
||||||
|
SELECT role FROM team_members WHERE team_id = ? AND user_id = ?
|
||||||
|
UNION
|
||||||
|
SELECT role FROM team_user_roles WHERE team_id = ? AND user_id = ?
|
||||||
|
ORDER BY role`,
|
||||||
|
teamID, userID, teamID, userID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var roles []string
|
||||||
|
for rows.Next() {
|
||||||
|
var r string
|
||||||
|
if err := rows.Scan(&r); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
roles = append(roles, r)
|
||||||
|
}
|
||||||
|
if roles == nil {
|
||||||
|
roles = []string{}
|
||||||
|
}
|
||||||
|
return roles, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *TeamStore) HasRole(ctx context.Context, teamID, userID, role string) (bool, error) {
|
||||||
|
var count int
|
||||||
|
err := DB.QueryRowContext(ctx, `
|
||||||
|
SELECT COUNT(*) FROM (
|
||||||
|
SELECT 1 FROM team_members WHERE team_id = ? AND user_id = ? AND role = ?
|
||||||
|
UNION ALL
|
||||||
|
SELECT 1 FROM team_user_roles WHERE team_id = ? AND user_id = ? AND role = ?
|
||||||
|
)`, teamID, userID, role, teamID, userID, role).Scan(&count)
|
||||||
|
return count > 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *TeamStore) HasRoleInAnyTeam(ctx context.Context, userID, role string) (bool, error) {
|
||||||
|
var count int
|
||||||
|
err := DB.QueryRowContext(ctx, `
|
||||||
|
SELECT COUNT(*) FROM (
|
||||||
|
SELECT 1 FROM team_members WHERE user_id = ? AND role = ?
|
||||||
|
UNION ALL
|
||||||
|
SELECT 1 FROM team_user_roles WHERE user_id = ? AND role = ?
|
||||||
|
) LIMIT 1`, userID, role, userID, role).Scan(&count)
|
||||||
|
return count > 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *TeamStore) RemoveAllUserRoles(ctx context.Context, teamID, userID string) error {
|
||||||
|
_, err := DB.ExecContext(ctx,
|
||||||
|
`DELETE FROM team_user_roles WHERE team_id = ? AND user_id = ?`,
|
||||||
|
teamID, userID)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── v0.9.4 — Team Role Catalog ──
|
||||||
|
|
||||||
|
func (s *TeamStore) AddRoleToCatalog(ctx context.Context, teamID, role, sourcePkgID string) error {
|
||||||
|
_, err := DB.ExecContext(ctx, `
|
||||||
|
INSERT INTO team_role_catalog (id, team_id, role, source_package_id)
|
||||||
|
VALUES (?, ?, ?, ?)
|
||||||
|
ON CONFLICT (team_id, role) DO NOTHING`,
|
||||||
|
uuid.New().String(), teamID, role, sourcePkgID)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *TeamStore) ListRoleCatalog(ctx context.Context, teamID string) ([]store.TeamRoleCatalogEntry, error) {
|
||||||
|
rows, err := DB.QueryContext(ctx, `
|
||||||
|
SELECT role, COALESCE(source_package_id, '') FROM team_role_catalog
|
||||||
|
WHERE team_id = ? ORDER BY role`, teamID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var result []store.TeamRoleCatalogEntry
|
||||||
|
for rows.Next() {
|
||||||
|
var e store.TeamRoleCatalogEntry
|
||||||
|
if err := rows.Scan(&e.Role, &e.SourcePackageID); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
result = append(result, e)
|
||||||
|
}
|
||||||
|
return result, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *TeamStore) RemoveRoleCatalogBySource(ctx context.Context, teamID, sourcePkgID string) error {
|
||||||
|
_, err := DB.ExecContext(ctx, `
|
||||||
|
DELETE FROM team_role_catalog WHERE team_id = ? AND source_package_id = ?`,
|
||||||
|
teamID, sourcePkgID)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|||||||
@@ -194,7 +194,7 @@ func TestStageCRUD(t *testing.T) {
|
|||||||
WorkflowID: wf.ID,
|
WorkflowID: wf.ID,
|
||||||
Ordinal: 1,
|
Ordinal: 1,
|
||||||
Name: "Review",
|
Name: "Review",
|
||||||
StageMode: "review",
|
StageMode: "form",
|
||||||
}
|
}
|
||||||
s.CreateStage(ctx, stage2)
|
s.CreateStage(ctx, stage2)
|
||||||
|
|
||||||
@@ -208,7 +208,7 @@ func TestStageCRUD(t *testing.T) {
|
|||||||
if stages[0].Name != "Intake" || stages[0].StageMode != "form" {
|
if stages[0].Name != "Intake" || stages[0].StageMode != "form" {
|
||||||
t.Fatalf("stage 0: got %s/%s", stages[0].Name, stages[0].StageMode)
|
t.Fatalf("stage 0: got %s/%s", stages[0].Name, stages[0].StageMode)
|
||||||
}
|
}
|
||||||
if stages[1].Name != "Review" || stages[1].StageMode != "review" {
|
if stages[1].Name != "Review" || stages[1].StageMode != "form" {
|
||||||
t.Fatalf("stage 1: got %s/%s", stages[1].Name, stages[1].StageMode)
|
t.Fatalf("stage 1: got %s/%s", stages[1].Name, stages[1].StageMode)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -243,7 +243,7 @@ func TestStageReorder(t *testing.T) {
|
|||||||
s.Create(ctx, wf)
|
s.Create(ctx, wf)
|
||||||
|
|
||||||
s1 := &models.WorkflowStage{WorkflowID: wf.ID, Ordinal: 0, Name: "First", StageMode: "form"}
|
s1 := &models.WorkflowStage{WorkflowID: wf.ID, Ordinal: 0, Name: "First", StageMode: "form"}
|
||||||
s2 := &models.WorkflowStage{WorkflowID: wf.ID, Ordinal: 1, Name: "Second", StageMode: "review"}
|
s2 := &models.WorkflowStage{WorkflowID: wf.ID, Ordinal: 1, Name: "Second", StageMode: "form"}
|
||||||
s.CreateStage(ctx, s1)
|
s.CreateStage(ctx, s1)
|
||||||
s.CreateStage(ctx, s2)
|
s.CreateStage(ctx, s2)
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ package triggers
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
|
||||||
"log"
|
"log"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -72,7 +71,7 @@ func (e *Engine) fireEventTrigger(triggerID, packageID, entryPoint string, ev ev
|
|||||||
if len(ev.Payload) > 0 {
|
if len(ev.Payload) > 0 {
|
||||||
var payloadMap map[string]any
|
var payloadMap map[string]any
|
||||||
if json.Unmarshal(ev.Payload, &payloadMap) == nil {
|
if json.Unmarshal(ev.Payload, &payloadMap) == nil {
|
||||||
_ = ctxDict.SetKey(starlark.String("event_payload"), mapToStarlark(payloadMap))
|
_ = ctxDict.SetKey(starlark.String("event_payload"), sandbox.MapToDict(payloadMap))
|
||||||
} else {
|
} else {
|
||||||
_ = ctxDict.SetKey(starlark.String("event_payload"), starlark.String(string(ev.Payload)))
|
_ = ctxDict.SetKey(starlark.String("event_payload"), starlark.String(string(ev.Payload)))
|
||||||
}
|
}
|
||||||
@@ -114,43 +113,6 @@ func (e *Engine) hasPermission(ctx context.Context, packageID, permission string
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
// mapToStarlark converts a map[string]any to a Starlark dict.
|
|
||||||
func mapToStarlark(m map[string]any) *starlark.Dict {
|
|
||||||
d := starlark.NewDict(len(m))
|
|
||||||
for k, v := range m {
|
|
||||||
_ = d.SetKey(starlark.String(k), goToStarlark(v))
|
|
||||||
}
|
|
||||||
return d
|
|
||||||
}
|
|
||||||
|
|
||||||
// goToStarlark converts a Go value to a Starlark value.
|
|
||||||
func goToStarlark(v any) starlark.Value {
|
|
||||||
switch val := v.(type) {
|
|
||||||
case nil:
|
|
||||||
return starlark.None
|
|
||||||
case bool:
|
|
||||||
return starlark.Bool(val)
|
|
||||||
case int:
|
|
||||||
return starlark.MakeInt(val)
|
|
||||||
case int64:
|
|
||||||
return starlark.MakeInt64(val)
|
|
||||||
case float64:
|
|
||||||
return starlark.Float(val)
|
|
||||||
case string:
|
|
||||||
return starlark.String(val)
|
|
||||||
case map[string]any:
|
|
||||||
return mapToStarlark(val)
|
|
||||||
case []any:
|
|
||||||
elems := make([]starlark.Value, len(val))
|
|
||||||
for i, e := range val {
|
|
||||||
elems[i] = goToStarlark(e)
|
|
||||||
}
|
|
||||||
return starlark.NewList(elems)
|
|
||||||
default:
|
|
||||||
return starlark.String(fmt.Sprintf("%v", val))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// RunContext builds a sandbox.RunContext for a trigger invocation.
|
// RunContext builds a sandbox.RunContext for a trigger invocation.
|
||||||
func triggerRunContext(userID, teamID string) *sandbox.RunContext {
|
func triggerRunContext(userID, teamID string) *sandbox.RunContext {
|
||||||
if userID == "" {
|
if userID == "" {
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import (
|
|||||||
"go.starlark.net/starlark"
|
"go.starlark.net/starlark"
|
||||||
|
|
||||||
"armature/models"
|
"armature/models"
|
||||||
|
"armature/sandbox"
|
||||||
)
|
)
|
||||||
|
|
||||||
// HandleWebhook is the Gin handler for inbound webhook triggers.
|
// HandleWebhook is the Gin handler for inbound webhook triggers.
|
||||||
@@ -152,7 +153,7 @@ func parseWebhookResponse(val starlark.Value) webhookResponse {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if bodyVal, found, _ := d.Get(starlark.String("body")); found {
|
if bodyVal, found, _ := d.Get(starlark.String("body")); found {
|
||||||
resp.body = starlarkToGo(bodyVal)
|
resp.body = sandbox.StarlarkToGo(bodyVal)
|
||||||
} else {
|
} else {
|
||||||
resp.body = gin.H{"ok": true}
|
resp.body = gin.H{"ok": true}
|
||||||
}
|
}
|
||||||
@@ -168,41 +169,6 @@ func parseWebhookResponse(val starlark.Value) webhookResponse {
|
|||||||
return webhookResponse{status: 200, body: gin.H{"ok": true}}
|
return webhookResponse{status: 200, body: gin.H{"ok": true}}
|
||||||
}
|
}
|
||||||
|
|
||||||
// starlarkToGo converts a Starlark value to a Go value (for JSON serialization).
|
|
||||||
func starlarkToGo(v starlark.Value) any {
|
|
||||||
switch val := v.(type) {
|
|
||||||
case starlark.NoneType:
|
|
||||||
return nil
|
|
||||||
case starlark.Bool:
|
|
||||||
return bool(val)
|
|
||||||
case starlark.Int:
|
|
||||||
if i, ok := val.Int64(); ok {
|
|
||||||
return i
|
|
||||||
}
|
|
||||||
return val.String()
|
|
||||||
case starlark.Float:
|
|
||||||
return float64(val)
|
|
||||||
case starlark.String:
|
|
||||||
return string(val)
|
|
||||||
case *starlark.List:
|
|
||||||
result := make([]any, val.Len())
|
|
||||||
for i := 0; i < val.Len(); i++ {
|
|
||||||
result[i] = starlarkToGo(val.Index(i))
|
|
||||||
}
|
|
||||||
return result
|
|
||||||
case *starlark.Dict:
|
|
||||||
result := make(map[string]any, val.Len())
|
|
||||||
for _, item := range val.Items() {
|
|
||||||
if k, ok := item[0].(starlark.String); ok {
|
|
||||||
result[string(k)] = starlarkToGo(item[1])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return result
|
|
||||||
default:
|
|
||||||
return v.String()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// verifyHMAC checks the HMAC-SHA256 signature of a webhook payload.
|
// verifyHMAC checks the HMAC-SHA256 signature of a webhook payload.
|
||||||
func verifyHMAC(body []byte, secret, signature string) bool {
|
func verifyHMAC(body []byte, secret, signature string) bool {
|
||||||
if signature == "" {
|
if signature == "" {
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import (
|
|||||||
|
|
||||||
"armature/events"
|
"armature/events"
|
||||||
"armature/models"
|
"armature/models"
|
||||||
|
"armature/sandbox"
|
||||||
|
|
||||||
"go.starlark.net/starlark"
|
"go.starlark.net/starlark"
|
||||||
)
|
)
|
||||||
@@ -82,7 +83,7 @@ func (e *Engine) processAutomatedStage(ctx context.Context, inst *models.Workflo
|
|||||||
// Parse stage_data into Starlark dict
|
// Parse stage_data into Starlark dict
|
||||||
var dataMap map[string]interface{}
|
var dataMap map[string]interface{}
|
||||||
if json.Unmarshal(inst.StageData, &dataMap) == nil {
|
if json.Unmarshal(inst.StageData, &dataMap) == nil {
|
||||||
_ = ctxDict.SetKey(starlark.String("stage_data"), goToStarlark(dataMap))
|
_ = ctxDict.SetKey(starlark.String("stage_data"), sandbox.GoToStarlark(dataMap))
|
||||||
} else {
|
} else {
|
||||||
_ = ctxDict.SetKey(starlark.String("stage_data"), starlark.NewDict(0))
|
_ = ctxDict.SetKey(starlark.String("stage_data"), starlark.NewDict(0))
|
||||||
}
|
}
|
||||||
@@ -128,7 +129,7 @@ func (e *Engine) handleHookResult(ctx context.Context, inst *models.WorkflowInst
|
|||||||
var enrichedData json.RawMessage
|
var enrichedData json.RawMessage
|
||||||
if dataVal, found, _ := d.Get(starlark.String("data")); found {
|
if dataVal, found, _ := d.Get(starlark.String("data")); found {
|
||||||
if sd, ok := dataVal.(*starlark.Dict); ok {
|
if sd, ok := dataVal.(*starlark.Dict); ok {
|
||||||
goMap := starlarkDictToMap(sd)
|
goMap := sandbox.DictToMap(sd)
|
||||||
if data, err := json.Marshal(goMap); err == nil {
|
if data, err := json.Marshal(goMap); err == nil {
|
||||||
enrichedData = data
|
enrichedData = data
|
||||||
}
|
}
|
||||||
@@ -165,74 +166,3 @@ func parseHookRef(ref string) (string, string) {
|
|||||||
return ref, "on_run"
|
return ref, "on_run"
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Starlark conversion helpers ─────────────
|
|
||||||
|
|
||||||
func goToStarlark(v any) starlark.Value {
|
|
||||||
switch val := v.(type) {
|
|
||||||
case nil:
|
|
||||||
return starlark.None
|
|
||||||
case bool:
|
|
||||||
return starlark.Bool(val)
|
|
||||||
case float64:
|
|
||||||
if val == float64(int(val)) {
|
|
||||||
return starlark.MakeInt(int(val))
|
|
||||||
}
|
|
||||||
return starlark.Float(val)
|
|
||||||
case string:
|
|
||||||
return starlark.String(val)
|
|
||||||
case map[string]interface{}:
|
|
||||||
d := starlark.NewDict(len(val))
|
|
||||||
for k, v := range val {
|
|
||||||
_ = d.SetKey(starlark.String(k), goToStarlark(v))
|
|
||||||
}
|
|
||||||
return d
|
|
||||||
case []interface{}:
|
|
||||||
elems := make([]starlark.Value, len(val))
|
|
||||||
for i, v := range val {
|
|
||||||
elems[i] = goToStarlark(v)
|
|
||||||
}
|
|
||||||
return starlark.NewList(elems)
|
|
||||||
default:
|
|
||||||
return starlark.String(fmt.Sprintf("%v", v))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func starlarkDictToMap(d *starlark.Dict) map[string]any {
|
|
||||||
result := make(map[string]any, d.Len())
|
|
||||||
for _, item := range d.Items() {
|
|
||||||
k, ok := item[0].(starlark.String)
|
|
||||||
if !ok {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
result[string(k)] = starlarkToGo(item[1])
|
|
||||||
}
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
func starlarkToGo(v starlark.Value) any {
|
|
||||||
switch val := v.(type) {
|
|
||||||
case starlark.NoneType:
|
|
||||||
return nil
|
|
||||||
case starlark.Bool:
|
|
||||||
return bool(val)
|
|
||||||
case starlark.Int:
|
|
||||||
if i, ok := val.Int64(); ok {
|
|
||||||
return i
|
|
||||||
}
|
|
||||||
return val.String()
|
|
||||||
case starlark.Float:
|
|
||||||
return float64(val)
|
|
||||||
case starlark.String:
|
|
||||||
return string(val)
|
|
||||||
case *starlark.List:
|
|
||||||
result := make([]any, val.Len())
|
|
||||||
for i := 0; i < val.Len(); i++ {
|
|
||||||
result[i] = starlarkToGo(val.Index(i))
|
|
||||||
}
|
|
||||||
return result
|
|
||||||
case *starlark.Dict:
|
|
||||||
return starlarkDictToMap(val)
|
|
||||||
default:
|
|
||||||
return v.String()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -18,25 +18,6 @@ import (
|
|||||||
// MaxConsecutiveAutomated is the cycle guard limit for automated stages.
|
// MaxConsecutiveAutomated is the cycle guard limit for automated stages.
|
||||||
const MaxConsecutiveAutomated = 10
|
const MaxConsecutiveAutomated = 10
|
||||||
|
|
||||||
// parseSnapshotStages handles both snapshot formats:
|
|
||||||
// - Wrapped: {"stages": [...], "workflow": {...}} (from Publish handler)
|
|
||||||
// - Legacy: [...] (from early tests)
|
|
||||||
func parseSnapshotStages(raw json.RawMessage) ([]models.WorkflowStage, error) {
|
|
||||||
// Try wrapped format first
|
|
||||||
var wrapped struct {
|
|
||||||
Stages []models.WorkflowStage `json:"stages"`
|
|
||||||
}
|
|
||||||
if err := json.Unmarshal(raw, &wrapped); err == nil && len(wrapped.Stages) > 0 {
|
|
||||||
return wrapped.Stages, nil
|
|
||||||
}
|
|
||||||
// Fallback to flat array
|
|
||||||
var stages []models.WorkflowStage
|
|
||||||
if err := json.Unmarshal(raw, &stages); err != nil {
|
|
||||||
return nil, fmt.Errorf("corrupt version snapshot: %w", err)
|
|
||||||
}
|
|
||||||
return stages, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Engine orchestrates workflow instance lifecycle.
|
// Engine orchestrates workflow instance lifecycle.
|
||||||
type Engine struct {
|
type Engine struct {
|
||||||
stores store.Stores
|
stores store.Stores
|
||||||
@@ -64,7 +45,7 @@ func (e *Engine) Start(ctx context.Context, workflowID string, initialData json.
|
|||||||
return nil, fmt.Errorf("no published version: %w", err)
|
return nil, fmt.Errorf("no published version: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
stages, err := parseSnapshotStages(ver.Snapshot)
|
stages, err := models.ParseSnapshotStages(ver.Snapshot)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -142,7 +123,7 @@ func (e *Engine) advanceInternal(ctx context.Context, instanceID string, stageDa
|
|||||||
return nil, fmt.Errorf("version not found: %w", err)
|
return nil, fmt.Errorf("version not found: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
stages, err := parseSnapshotStages(ver.Snapshot)
|
stages, err := models.ParseSnapshotStages(ver.Snapshot)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -348,7 +329,7 @@ func (e *Engine) AdvancePublic(ctx context.Context, entryToken string, stageData
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("version not found: %w", err)
|
return nil, fmt.Errorf("version not found: %w", err)
|
||||||
}
|
}
|
||||||
stages, err := parseSnapshotStages(ver.Snapshot)
|
stages, err := models.ParseSnapshotStages(ver.Snapshot)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -383,7 +364,7 @@ func (e *Engine) SubmitSignoff(ctx context.Context, instanceID, userID, decision
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("version not found: %w", err)
|
return nil, fmt.Errorf("version not found: %w", err)
|
||||||
}
|
}
|
||||||
stages, err := parseSnapshotStages(ver.Snapshot)
|
stages, err := models.ParseSnapshotStages(ver.Snapshot)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -448,7 +429,7 @@ func CheckClaimRole(ctx context.Context, stores store.Stores, assignment *models
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
stages, parseErr := parseSnapshotStages(ver.Snapshot)
|
stages, parseErr := models.ParseSnapshotStages(ver.Snapshot)
|
||||||
if parseErr != nil {
|
if parseErr != nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -91,7 +91,7 @@ func (sc *Scanner) runScan() {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
stages, parseErr := parseSnapshotStages(v.Snapshot)
|
stages, parseErr := models.ParseSnapshotStages(v.Snapshot)
|
||||||
if parseErr != nil {
|
if parseErr != nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,15 +13,13 @@
|
|||||||
const { html } = window;
|
const { html } = window;
|
||||||
const { useState, useEffect } = hooks;
|
const { useState, useEffect } = hooks;
|
||||||
|
|
||||||
export const STAGE_MODES = ['form', 'review', 'delegated', 'automated'];
|
export const STAGE_MODES = ['form', 'delegated', 'automated'];
|
||||||
export const STAGE_TYPES = ['simple', 'dynamic', 'automated'];
|
|
||||||
export const AUDIENCES = ['team', 'public', 'system'];
|
export const AUDIENCES = ['team', 'public', 'system'];
|
||||||
|
|
||||||
export function StageForm({ stage, teams, onSave, onCancel }) {
|
export function StageForm({ stage, teams, onSave, onCancel }) {
|
||||||
const [name, setName] = useState(stage?.name || '');
|
const [name, setName] = useState(stage?.name || '');
|
||||||
const [mode, setMode] = useState(stage?.stage_mode || 'form');
|
const [mode, setMode] = useState(stage?.stage_mode || 'form');
|
||||||
const [audience, setAudience] = useState(stage?.audience || 'team');
|
const [audience, setAudience] = useState(stage?.audience || 'team');
|
||||||
const [stageType, setStageType] = useState(stage?.stage_type || 'simple');
|
|
||||||
const [starlarkHook, setStarlarkHook] = useState(stage?.starlark_hook || '');
|
const [starlarkHook, setStarlarkHook] = useState(stage?.starlark_hook || '');
|
||||||
const [assignTeam, setAssignTeam] = useState(stage?.assignment_team_id || '');
|
const [assignTeam, setAssignTeam] = useState(stage?.assignment_team_id || '');
|
||||||
const [autoTransition, setAutoTransition] = useState(stage?.auto_transition || false);
|
const [autoTransition, setAutoTransition] = useState(stage?.auto_transition || false);
|
||||||
@@ -60,7 +58,6 @@ export function StageForm({ stage, teams, onSave, onCancel }) {
|
|||||||
name,
|
name,
|
||||||
stage_mode: mode,
|
stage_mode: mode,
|
||||||
audience,
|
audience,
|
||||||
stage_type: stageType,
|
|
||||||
starlark_hook: starlarkHook || null,
|
starlark_hook: starlarkHook || null,
|
||||||
assignment_team_id: assignTeam || null,
|
assignment_team_id: assignTeam || null,
|
||||||
auto_transition: autoTransition,
|
auto_transition: autoTransition,
|
||||||
@@ -91,14 +88,8 @@ export function StageForm({ stage, teams, onSave, onCancel }) {
|
|||||||
${AUDIENCES.map(a => html`<option key=${a} value=${a}>${a}</option>`)}
|
${AUDIENCES.map(a => html`<option key=${a} value=${a}>${a}</option>`)}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
|
||||||
<label>Stage Type</label>
|
|
||||||
<select value=${stageType} onChange=${e => setStageType(e.target.value)}>
|
|
||||||
${STAGE_TYPES.map(t => html`<option key=${t} value=${t}>${t}</option>`)}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
${stageType !== 'simple' && html`
|
${mode === 'automated' && html`
|
||||||
<div class="form-row">
|
<div class="form-row">
|
||||||
<div class="form-group" style="flex:1;">
|
<div class="form-group" style="flex:1;">
|
||||||
<label>Starlark Hook</label>
|
<label>Starlark Hook</label>
|
||||||
|
|||||||
343
src/js/sw/sdk/forms.js
Normal file
343
src/js/sw/sdk/forms.js
Normal file
@@ -0,0 +1,343 @@
|
|||||||
|
// ==========================================
|
||||||
|
// Armature — SDK Forms Module (v0.9.5)
|
||||||
|
// ==========================================
|
||||||
|
// Reusable typed form system. Any package can
|
||||||
|
// declare and render forms, not just workflows.
|
||||||
|
//
|
||||||
|
// Usage:
|
||||||
|
// const handle = sw.forms.render(container, template, opts);
|
||||||
|
// const { valid, errors } = sw.forms.validate(template, data);
|
||||||
|
// const result = await sw.forms.validateRemote(template, data);
|
||||||
|
// ==========================================
|
||||||
|
|
||||||
|
const { html } = window;
|
||||||
|
const { h, render: preactRender } = preact;
|
||||||
|
const { useState, useEffect, useRef } = hooks;
|
||||||
|
|
||||||
|
// ── Field type → HTML input mapping ─────────
|
||||||
|
|
||||||
|
const EMAIL_RE = /^[^@\s]+@[^@\s]+\.[^@\s]+$/;
|
||||||
|
|
||||||
|
function escAttr(s) {
|
||||||
|
return String(s).replace(/&/g, '&').replace(/"/g, '"').replace(/</g, '<');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Client-side validation (mirrors Go forms.ValidateFormData) ──
|
||||||
|
|
||||||
|
function evaluateCondition(cond, data) {
|
||||||
|
const val = data[cond.when];
|
||||||
|
const exists = val !== undefined && val !== null;
|
||||||
|
const op = cond.op || 'eq';
|
||||||
|
|
||||||
|
switch (op) {
|
||||||
|
case 'exists': return exists;
|
||||||
|
case 'eq': return exists && String(val) === String(cond.value);
|
||||||
|
case 'neq': return !exists || String(val) !== String(cond.value);
|
||||||
|
case 'in': return exists && Array.isArray(cond.value) && cond.value.map(String).includes(String(val));
|
||||||
|
default: return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateField(f, val, present) {
|
||||||
|
const errs = [];
|
||||||
|
if (f.condition) return errs; // caller handles condition check
|
||||||
|
|
||||||
|
if (f.required && (!present || val === null || val === undefined || val === '')) {
|
||||||
|
errs.push({ key: f.key, message: (f.label || f.key) + ' is required' });
|
||||||
|
return errs;
|
||||||
|
}
|
||||||
|
if (!present || val === null || val === undefined) return errs;
|
||||||
|
|
||||||
|
const v = f.validation || {};
|
||||||
|
switch (f.type) {
|
||||||
|
case 'text': case 'textarea': {
|
||||||
|
if (typeof val !== 'string') { errs.push({ key: f.key, message: f.label + ' must be text' }); break; }
|
||||||
|
if (v.min_length != null && val.length < v.min_length) errs.push({ key: f.key, message: `${f.label} must be at least ${v.min_length} characters` });
|
||||||
|
if (v.max_length != null && val.length > v.max_length) errs.push({ key: f.key, message: `${f.label} must be at most ${v.max_length} characters` });
|
||||||
|
if (v.pattern) { try { if (!new RegExp(v.pattern).test(val)) errs.push({ key: f.key, message: f.label + ' does not match the required pattern' }); } catch (_) {} }
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'email': {
|
||||||
|
if (typeof val !== 'string') { errs.push({ key: f.key, message: f.label + ' must be text' }); break; }
|
||||||
|
if (!EMAIL_RE.test(val)) errs.push({ key: f.key, message: f.label + ' must be a valid email address' });
|
||||||
|
if (v.pattern) { try { if (!new RegExp(v.pattern).test(val)) errs.push({ key: f.key, message: f.label + ' does not match the required pattern' }); } catch (_) {} }
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'number': {
|
||||||
|
const n = typeof val === 'number' ? val : parseFloat(val);
|
||||||
|
if (isNaN(n)) { errs.push({ key: f.key, message: f.label + ' must be a number' }); break; }
|
||||||
|
if (v.min != null && n < v.min) errs.push({ key: f.key, message: `${f.label} must be at least ${v.min}` });
|
||||||
|
if (v.max != null && n > v.max) errs.push({ key: f.key, message: `${f.label} must be at most ${v.max}` });
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'date': {
|
||||||
|
if (typeof val !== 'string' || !/^\d{4}-\d{2}-\d{2}$/.test(val)) { errs.push({ key: f.key, message: f.label + ' must be a valid date (YYYY-MM-DD)' }); break; }
|
||||||
|
if (v.min_date && val < v.min_date) errs.push({ key: f.key, message: `${f.label} must be on or after ${v.min_date}` });
|
||||||
|
if (v.max_date && val > v.max_date) errs.push({ key: f.key, message: `${f.label} must be on or before ${v.max_date}` });
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'select': {
|
||||||
|
if (f.options && f.options.length > 0) {
|
||||||
|
const valid = f.options.some(o => o.value.toLowerCase() === String(val).toLowerCase());
|
||||||
|
if (!valid) errs.push({ key: f.key, message: f.label + ' is not a valid option' });
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'checkbox': {
|
||||||
|
if (typeof val !== 'boolean') errs.push({ key: f.key, message: f.label + ' must be true or false' });
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return errs;
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateAll(template, data) {
|
||||||
|
const fields = getAllFields(template);
|
||||||
|
const errs = [];
|
||||||
|
for (const f of fields) {
|
||||||
|
if (f.condition && !evaluateCondition(f.condition, data)) continue;
|
||||||
|
const val = data[f.key];
|
||||||
|
const present = f.key in data;
|
||||||
|
|
||||||
|
if (f.required && (!present || val === null || val === undefined || val === '')) {
|
||||||
|
errs.push({ key: f.key, message: (f.label || f.key) + ' is required' });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!present || val === null || val === undefined) continue;
|
||||||
|
|
||||||
|
const v = f.validation || {};
|
||||||
|
switch (f.type) {
|
||||||
|
case 'text': case 'textarea': {
|
||||||
|
if (typeof val !== 'string') { errs.push({ key: f.key, message: f.label + ' must be text' }); break; }
|
||||||
|
if (v.min_length != null && val.length < v.min_length) errs.push({ key: f.key, message: `${f.label} must be at least ${v.min_length} characters` });
|
||||||
|
if (v.max_length != null && val.length > v.max_length) errs.push({ key: f.key, message: `${f.label} must be at most ${v.max_length} characters` });
|
||||||
|
if (v.pattern) { try { if (!new RegExp(v.pattern).test(val)) errs.push({ key: f.key, message: f.label + ' does not match the required pattern' }); } catch (_) {} }
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'email': {
|
||||||
|
if (typeof val !== 'string') { errs.push({ key: f.key, message: f.label + ' must be text' }); break; }
|
||||||
|
if (!EMAIL_RE.test(val)) errs.push({ key: f.key, message: f.label + ' must be a valid email address' });
|
||||||
|
if (v.pattern) { try { if (!new RegExp(v.pattern).test(val)) errs.push({ key: f.key, message: f.label + ' does not match the required pattern' }); } catch (_) {} }
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'number': {
|
||||||
|
const n = typeof val === 'number' ? val : parseFloat(val);
|
||||||
|
if (isNaN(n)) { errs.push({ key: f.key, message: f.label + ' must be a number' }); break; }
|
||||||
|
if (v.min != null && n < v.min) errs.push({ key: f.key, message: `${f.label} must be at least ${v.min}` });
|
||||||
|
if (v.max != null && n > v.max) errs.push({ key: f.key, message: `${f.label} must be at most ${v.max}` });
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'date': {
|
||||||
|
if (typeof val !== 'string' || !/^\d{4}-\d{2}-\d{2}$/.test(val)) { errs.push({ key: f.key, message: f.label + ' must be a valid date (YYYY-MM-DD)' }); break; }
|
||||||
|
if (v.min_date && val < v.min_date) errs.push({ key: f.key, message: `${f.label} must be on or after ${v.min_date}` });
|
||||||
|
if (v.max_date && val > v.max_date) errs.push({ key: f.key, message: `${f.label} must be on or before ${v.max_date}` });
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'select': {
|
||||||
|
if (f.options && f.options.length > 0) {
|
||||||
|
const match = f.options.some(o => o.value.toLowerCase() === String(val).toLowerCase());
|
||||||
|
if (!match) errs.push({ key: f.key, message: f.label + ' is not a valid option' });
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'checkbox': {
|
||||||
|
if (typeof val !== 'boolean') errs.push({ key: f.key, message: f.label + ' must be true or false' });
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { valid: errs.length === 0, errors: errs };
|
||||||
|
}
|
||||||
|
|
||||||
|
function getAllFields(template) {
|
||||||
|
if (template.fieldsets && template.fieldsets.length > 0) {
|
||||||
|
return template.fieldsets.flatMap(fs => fs.fields || []);
|
||||||
|
}
|
||||||
|
return template.fields || [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Preact Form Components ──────────────────
|
||||||
|
|
||||||
|
function FormFieldInput({ field, value, onChange, error }) {
|
||||||
|
const f = field;
|
||||||
|
const id = 'sw-ff-' + f.key;
|
||||||
|
|
||||||
|
let input;
|
||||||
|
switch (f.type) {
|
||||||
|
case 'text': case 'email': case 'date':
|
||||||
|
input = html`<input type=${f.type} id=${id} value=${value || ''} placeholder=${f.placeholder || ''}
|
||||||
|
onInput=${e => onChange(f.key, e.target.value)} />`;
|
||||||
|
break;
|
||||||
|
case 'number': {
|
||||||
|
const attrs = {};
|
||||||
|
if (f.validation?.min != null) attrs.min = f.validation.min;
|
||||||
|
if (f.validation?.max != null) attrs.max = f.validation.max;
|
||||||
|
if (f.validation?.step != null) attrs.step = f.validation.step;
|
||||||
|
input = html`<input type="number" id=${id} value=${value ?? ''} placeholder=${f.placeholder || ''}
|
||||||
|
...${attrs} onInput=${e => onChange(f.key, e.target.value !== '' ? parseFloat(e.target.value) : null)} />`;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'textarea':
|
||||||
|
input = html`<textarea id=${id} rows="4" placeholder=${f.placeholder || ''}
|
||||||
|
onInput=${e => onChange(f.key, e.target.value)}>${value || ''}</textarea>`;
|
||||||
|
break;
|
||||||
|
case 'select':
|
||||||
|
input = html`<select id=${id} value=${value || ''} onChange=${e => onChange(f.key, e.target.value)}>
|
||||||
|
<option value="">— Select —</option>
|
||||||
|
${(f.options || []).map(o => html`<option value=${o.value}>${o.label}</option>`)}
|
||||||
|
</select>`;
|
||||||
|
break;
|
||||||
|
case 'checkbox':
|
||||||
|
return html`<div class="sw-form-field" data-key=${f.key}>
|
||||||
|
<div class="sw-form-check">
|
||||||
|
<input type="checkbox" id=${id} checked=${!!value}
|
||||||
|
onChange=${e => onChange(f.key, e.target.checked)} />
|
||||||
|
<label for=${id}>${f.label}${f.required ? html`<span class="required">*</span>` : ''}</label>
|
||||||
|
</div>
|
||||||
|
${error ? html`<div class="sw-form-error">${error}</div>` : ''}
|
||||||
|
</div>`;
|
||||||
|
case 'file': {
|
||||||
|
const accept = f.validation?.accept || '';
|
||||||
|
input = html`<input type="file" id=${id} accept=${accept}
|
||||||
|
onChange=${e => onChange(f.key, e.target.files?.[0] || null)} />`;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
input = html`<input type="text" id=${id} value=${value || ''} placeholder=${f.placeholder || ''}
|
||||||
|
onInput=${e => onChange(f.key, e.target.value)} />`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return html`<div class="sw-form-field" data-key=${f.key}>
|
||||||
|
<label for=${id}>${f.label}${f.required ? html`<span class="required">*</span>` : ''}</label>
|
||||||
|
${input}
|
||||||
|
${error ? html`<div class="sw-form-error">${error}</div>` : ''}
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function FormRenderer({ template, opts }) {
|
||||||
|
const allFields = getAllFields(template);
|
||||||
|
const [data, setData] = useState(() => {
|
||||||
|
const init = { ...(opts.values || {}) };
|
||||||
|
for (const f of allFields) {
|
||||||
|
if (!(f.key in init) && f.default != null) init[f.key] = f.default;
|
||||||
|
}
|
||||||
|
return init;
|
||||||
|
});
|
||||||
|
const [errors, setErrors] = useState({});
|
||||||
|
const [step, setStep] = useState(0);
|
||||||
|
const handleRef = useRef(null);
|
||||||
|
|
||||||
|
const onChange = (key, value) => {
|
||||||
|
setData(prev => ({ ...prev, [key]: value }));
|
||||||
|
setErrors(prev => { const next = { ...prev }; delete next[key]; return next; });
|
||||||
|
};
|
||||||
|
|
||||||
|
// Expose control handle
|
||||||
|
useEffect(() => {
|
||||||
|
if (handleRef.current) {
|
||||||
|
handleRef.current.getData = () => {
|
||||||
|
const out = {};
|
||||||
|
for (const f of allFields) {
|
||||||
|
if (f.condition && !evaluateCondition(f.condition, data)) continue;
|
||||||
|
if (f.key in data) out[f.key] = data[f.key];
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
};
|
||||||
|
handleRef.current.setErrors = (errArr) => {
|
||||||
|
const map = {};
|
||||||
|
for (const e of errArr) map[e.key] = e.message;
|
||||||
|
setErrors(map);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}, [data]);
|
||||||
|
|
||||||
|
const hasFieldsets = template.fieldsets && template.fieldsets.length > 0;
|
||||||
|
|
||||||
|
if (hasFieldsets) {
|
||||||
|
const fs = template.fieldsets[step];
|
||||||
|
const isLast = step === template.fieldsets.length - 1;
|
||||||
|
return html`<div class="sw-form sw-form-progressive">
|
||||||
|
<div class="sw-form-nav">
|
||||||
|
<span class="sw-form-step-label">${fs.label}</span>
|
||||||
|
<span class="sw-form-step-counter">Step ${step + 1} of ${template.fieldsets.length}</span>
|
||||||
|
</div>
|
||||||
|
${(fs.fields || []).map(f => {
|
||||||
|
if (f.condition && !evaluateCondition(f.condition, data)) return '';
|
||||||
|
return html`<${FormFieldInput} field=${f} value=${data[f.key]} onChange=${onChange}
|
||||||
|
error=${errors[f.key]} />`;
|
||||||
|
})}
|
||||||
|
<div class="sw-form-buttons">
|
||||||
|
${step > 0 ? html`<button type="button" class="sw-btn" onClick=${() => setStep(step - 1)}>Back</button>` : ''}
|
||||||
|
${!isLast ? html`<button type="button" class="sw-btn sw-btn-primary" onClick=${() => setStep(step + 1)}>Next</button>` : ''}
|
||||||
|
${isLast && opts.onSubmit ? html`<button type="button" class="sw-btn sw-btn-primary"
|
||||||
|
onClick=${() => opts.onSubmit(handleRef.current.getData())}>Submit</button>` : ''}
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return html`<div class="sw-form">
|
||||||
|
${allFields.map(f => {
|
||||||
|
if (f.condition && !evaluateCondition(f.condition, data)) return '';
|
||||||
|
return html`<${FormFieldInput} field=${f} value=${data[f.key]} onChange=${onChange}
|
||||||
|
error=${errors[f.key]} />`;
|
||||||
|
})}
|
||||||
|
${opts.onSubmit ? html`<div class="sw-form-buttons">
|
||||||
|
<button type="button" class="sw-btn sw-btn-primary"
|
||||||
|
onClick=${() => opts.onSubmit(handleRef.current.getData())}>Submit</button>
|
||||||
|
</div>` : ''}
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Public API ──────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates the sw.forms SDK module.
|
||||||
|
* @param {object} restClient — the SDK REST client
|
||||||
|
*/
|
||||||
|
export function createForms(restClient) {
|
||||||
|
return {
|
||||||
|
/**
|
||||||
|
* Render a typed form into a container element.
|
||||||
|
* @param {HTMLElement} container
|
||||||
|
* @param {object} template — TypedFormTemplate JSON
|
||||||
|
* @param {object} [opts] — { onSubmit, values, readOnly }
|
||||||
|
* @returns {{ getData, setErrors, destroy }}
|
||||||
|
*/
|
||||||
|
render(container, template, opts = {}) {
|
||||||
|
const handle = { getData: () => ({}), setErrors: () => {}, destroy: () => {} };
|
||||||
|
|
||||||
|
const ConnectedForm = () => {
|
||||||
|
const ref = useRef(handle);
|
||||||
|
return html`<${FormRenderer} template=${template} opts=${{ ...opts }} />`;
|
||||||
|
};
|
||||||
|
|
||||||
|
preactRender(html`<${ConnectedForm} />`, container);
|
||||||
|
handle.destroy = () => { preactRender(null, container); };
|
||||||
|
|
||||||
|
return handle;
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Client-side validate form data against a template.
|
||||||
|
* @param {object} template — TypedFormTemplate JSON
|
||||||
|
* @param {object} data — field values
|
||||||
|
* @returns {{ valid: boolean, errors: Array<{key: string, message: string}> }}
|
||||||
|
*/
|
||||||
|
validate(template, data) {
|
||||||
|
return validateAll(template, data || {});
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Server-side validate via REST endpoint.
|
||||||
|
* @param {object} template — TypedFormTemplate JSON
|
||||||
|
* @param {object} data — field values
|
||||||
|
* @returns {Promise<{ valid: boolean, errors: Array<{key: string, message: string}> }>}
|
||||||
|
*/
|
||||||
|
async validateRemote(template, data) {
|
||||||
|
return restClient.post('/api/v1/forms/validate', {
|
||||||
|
template,
|
||||||
|
data: data || {},
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -26,6 +26,7 @@ import { createRenderers } from './renderers.js';
|
|||||||
import { createMarkdown } from './markdown.js';
|
import { createMarkdown } from './markdown.js';
|
||||||
import { createUsers } from './users.js';
|
import { createUsers } from './users.js';
|
||||||
import { createTesting } from './testing.js';
|
import { createTesting } from './testing.js';
|
||||||
|
import { createForms } from './forms.js';
|
||||||
import { confirm } from '../primitives/confirm.js';
|
import { confirm } from '../primitives/confirm.js';
|
||||||
import { prompt } from '../primitives/prompt.js';
|
import { prompt } from '../primitives/prompt.js';
|
||||||
|
|
||||||
@@ -125,6 +126,9 @@ export async function boot() {
|
|||||||
// Testing — structured test framework for surface runners
|
// Testing — structured test framework for surface runners
|
||||||
sw.testing = createTesting(events, restClient);
|
sw.testing = createTesting(events, restClient);
|
||||||
|
|
||||||
|
// Forms — typed form rendering + validation (v0.9.5)
|
||||||
|
sw.forms = createForms(restClient);
|
||||||
|
|
||||||
// Shell helpers — imperative confirm/prompt backed by primitives
|
// Shell helpers — imperative confirm/prompt backed by primitives
|
||||||
sw.confirm = confirm;
|
sw.confirm = confirm;
|
||||||
sw.prompt = prompt;
|
sw.prompt = prompt;
|
||||||
@@ -186,8 +190,56 @@ export async function boot() {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Navigate — multi-surface intra-package routing
|
||||||
|
sw.navigate = function (path, params) {
|
||||||
|
const manifest = window.__MANIFEST__;
|
||||||
|
if (!manifest?.id) {
|
||||||
|
console.warn('[sw] navigate: no manifest — not inside an extension surface');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Interpolate :param placeholders
|
||||||
|
let resolved = path;
|
||||||
|
if (params) {
|
||||||
|
for (const [k, v] of Object.entries(params)) {
|
||||||
|
resolved = resolved.replace(':' + k, encodeURIComponent(v));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const base = window.__BASE__ || '';
|
||||||
|
const url = base + '/s/' + manifest.id + resolved;
|
||||||
|
|
||||||
|
// Update globals so the package can re-render
|
||||||
|
window.__SURFACE_PATH__ = path;
|
||||||
|
window.__SURFACE_PARAMS__ = params || {};
|
||||||
|
|
||||||
|
history.pushState({ surfacePath: path, surfaceParams: params }, '', url);
|
||||||
|
events.emit('surface.navigate', { path, params: params || {}, url }, { localOnly: true });
|
||||||
|
};
|
||||||
|
|
||||||
|
// Listen for popstate (back/forward) to emit navigation events
|
||||||
|
window.addEventListener('popstate', (e) => {
|
||||||
|
if (e.state?.surfacePath != null) {
|
||||||
|
window.__SURFACE_PATH__ = e.state.surfacePath;
|
||||||
|
window.__SURFACE_PARAMS__ = e.state.surfaceParams || {};
|
||||||
|
events.emit('surface.navigate', {
|
||||||
|
path: e.state.surfacePath,
|
||||||
|
params: e.state.surfaceParams || {},
|
||||||
|
url: location.pathname,
|
||||||
|
}, { localOnly: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Seed initial history state so back-navigation from sw.navigate() works.
|
||||||
|
// Without this, the initial server-rendered page has no pushState entry,
|
||||||
|
// so pressing back after sw.navigate() fires popstate with null state.
|
||||||
|
if (window.__SURFACE_PATH__) {
|
||||||
|
history.replaceState(
|
||||||
|
{ surfacePath: window.__SURFACE_PATH__, surfaceParams: window.__SURFACE_PARAMS__ || {} },
|
||||||
|
''
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// Marker for idempotency
|
// Marker for idempotency
|
||||||
sw._sdk = '0.7.1';
|
sw._sdk = '0.9.5';
|
||||||
|
|
||||||
// 8. Expose globally
|
// 8. Expose globally
|
||||||
window.sw = sw;
|
window.sw = sw;
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ export default function MembersSection({ teamId }) {
|
|||||||
const loadRoles = useCallback(async () => {
|
const loadRoles = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
const resp = await sw.api.get(`/api/v1/teams/${teamId}/roles`);
|
const resp = await sw.api.get(`/api/v1/teams/${teamId}/roles`);
|
||||||
setRoles(resp.data || ['admin', 'member']);
|
setRoles(Array.isArray(resp) ? resp : (resp.data || ['admin', 'member']));
|
||||||
} catch { setRoles(['admin', 'member']); }
|
} catch { setRoles(['admin', 'member']); }
|
||||||
}, [teamId]);
|
}, [teamId]);
|
||||||
|
|
||||||
@@ -63,6 +63,38 @@ export default function MembersSection({ teamId }) {
|
|||||||
} catch (e) { sw.toast(e.message, 'error'); }
|
} catch (e) { sw.toast(e.message, 'error'); }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Additional roles (many-to-many) ───────
|
||||||
|
const [memberRoles, setMemberRoles] = useState({});
|
||||||
|
|
||||||
|
async function loadMemberRoles(memberId) {
|
||||||
|
try {
|
||||||
|
const resp = await sw.api.get(`/api/v1/teams/${teamId}/members/${memberId}/roles`);
|
||||||
|
const roles = Array.isArray(resp) ? resp : (resp.data || []);
|
||||||
|
setMemberRoles(prev => ({ ...prev, [memberId]: roles }));
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function addExtraRole(memberId, role) {
|
||||||
|
try {
|
||||||
|
await sw.api.post(`/api/v1/teams/${teamId}/members/${memberId}/roles`, { role });
|
||||||
|
sw.toast('Role assigned', 'success');
|
||||||
|
loadMemberRoles(memberId);
|
||||||
|
} catch (e) { sw.toast(e.message, 'error'); }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function removeExtraRole(memberId, role) {
|
||||||
|
try {
|
||||||
|
await sw.api.del(`/api/v1/teams/${teamId}/members/${memberId}/roles/${role}`);
|
||||||
|
sw.toast('Role removed', 'success');
|
||||||
|
loadMemberRoles(memberId);
|
||||||
|
} catch (e) { sw.toast(e.message, 'error'); }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load additional roles for each member once members are loaded
|
||||||
|
useEffect(() => {
|
||||||
|
members.forEach(m => { if (m.id) loadMemberRoles(m.id); });
|
||||||
|
}, [members]);
|
||||||
|
|
||||||
async function removeMember(memberId) {
|
async function removeMember(memberId) {
|
||||||
const ok = await sw.confirm('Remove this member from the team?', true);
|
const ok = await sw.confirm('Remove this member from the team?', true);
|
||||||
if (!ok) return;
|
if (!ok) return;
|
||||||
@@ -106,16 +138,35 @@ export default function MembersSection({ teamId }) {
|
|||||||
|
|
||||||
<div class="admin-list">
|
<div class="admin-list">
|
||||||
${members.length === 0 && html`<div class="empty-hint">No members</div>`}
|
${members.length === 0 && html`<div class="empty-hint">No members</div>`}
|
||||||
${members.map(m => html`
|
${members.map(m => {
|
||||||
<div class="admin-surface-row" key=${m.id || m.user_id}>
|
const mid = m.id || m.user_id;
|
||||||
|
const extra = (memberRoles[mid] || []).filter(r => r !== m.role);
|
||||||
|
const available = roles.filter(r => r !== m.role && !extra.includes(r));
|
||||||
|
return html`
|
||||||
|
<div class="admin-surface-row" key=${mid} style="flex-wrap:wrap;">
|
||||||
<strong style="flex:1;">${m.display_name || m.username || 'Unknown'}</strong>
|
<strong style="flex:1;">${m.display_name || m.username || 'Unknown'}</strong>
|
||||||
<select value=${m.role || 'member'} style="width:100px;"
|
<select value=${m.role || 'member'} style="width:100px;"
|
||||||
onChange=${e => updateRole(m.id || m.user_id, e.target.value)}>
|
onChange=${e => updateRole(mid, e.target.value)}>
|
||||||
${roles.map(r => html`<option key=${r} value=${r}>${r}</option>`)}
|
${roles.map(r => html`<option key=${r} value=${r}>${r}</option>`)}
|
||||||
</select>
|
</select>
|
||||||
<button class="sw-btn sw-btn--danger sw-btn--sm" onClick=${() => removeMember(m.id || m.user_id)}>Remove</button>
|
<button class="sw-btn sw-btn--danger sw-btn--sm" onClick=${() => removeMember(mid)}>Remove</button>
|
||||||
|
${extra.length > 0 && html`
|
||||||
|
<div style="width:100%;display:flex;flex-wrap:wrap;gap:4px;margin-top:4px;">
|
||||||
|
${extra.map(r => html`
|
||||||
|
<span key=${r} class="badge" style="cursor:pointer;" title="Click to remove"
|
||||||
|
onClick=${() => removeExtraRole(mid, r)}>${r} \u00d7</span>
|
||||||
|
`)}
|
||||||
|
</div>
|
||||||
|
`}
|
||||||
|
${available.length > 0 && html`
|
||||||
|
<select style="width:auto;margin-top:4px;font-size:12px;"
|
||||||
|
onChange=${e => { if (e.target.value) { addExtraRole(mid, e.target.value); e.target.value = ''; } }}>
|
||||||
|
<option value="">+ Role\u2026</option>
|
||||||
|
${available.map(r => html`<option key=${r} value=${r}>${r}</option>`)}
|
||||||
|
</select>
|
||||||
|
`}
|
||||||
</div>
|
</div>
|
||||||
`)}
|
`})}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style="margin-top:24px;border-top:1px solid var(--border-1);padding-top:12px;">
|
<div style="margin-top:24px;border-top:1px solid var(--border-1);padding-top:12px;">
|
||||||
|
|||||||
Reference in New Issue
Block a user