Feat v0.9.5 typed forms sdk (#79)
All checks were successful
CI/CD / detect-changes (push) Successful in 4s
CI/CD / test-runners (push) Has been skipped
CI/CD / e2e-smoke (push) Has been skipped
CI/CD / test-frontend (push) Successful in 5s
CI/CD / test-go-pg (push) Successful in 2m44s
CI/CD / test-sqlite (push) Successful in 3m6s
CI/CD / build-and-deploy (push) Successful in 29s

Co-authored-by: Jeffrey Smith <jasafpro@gmail.com>
Co-committed-by: Jeffrey Smith <jasafpro@gmail.com>
This commit was merged in pull request #79.
This commit is contained in:
2026-04-03 17:04:29 +00:00
committed by xcaliber
parent 6b9ce92103
commit 75d7abc089
18 changed files with 1451 additions and 324 deletions

347
server/forms/forms.go Normal file
View 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
View 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
View 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,
})
}

View File

@@ -5,6 +5,8 @@ import (
"fmt"
"regexp"
"strings"
"armature/forms"
)
// validManifestID matches lowercase alphanumeric slugs with optional hyphens.
@@ -35,6 +37,7 @@ type ManifestInfo struct {
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,
@@ -184,6 +187,18 @@ func ValidateManifest(manifest map[string]any) (*ManifestInfo, error) {
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)
// ── Type-specific constraints ────────────────────────────────

View File

@@ -545,6 +545,10 @@ func main() {
protected.POST("/instances/:iid/signoffs", wfSignoffH.Submit)
protected.GET("/instances/:iid/signoffs", wfSignoffH.List)
// Forms validation
formsH := &handlers.FormsHandler{}
protected.POST("/forms/validate", formsH.Validate)
// Surface discovery
pkgH := handlers.NewPackageHandler(stores)
protected.GET("/surfaces", pkgH.ListEnabledSurfaces)

View File

@@ -2,9 +2,6 @@ package models
import (
"encoding/json"
"fmt"
"regexp"
"strings"
"time"
)
@@ -118,320 +115,6 @@ var ValidAudiences = map[string]bool{
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 ───────────────
const (

View 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
}
}

View 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)
}
}

View File

@@ -417,6 +417,9 @@ func (r *Runner) buildModulesWithLibCtx(ctx context.Context, packageID string, m
})
}
case models.ExtPermFormValidate:
modules["forms"] = BuildFormsModule(ctx)
case models.ExtPermBatchExec:
hasBatchExec = true
}