diff --git a/zddc/cmd/zddc-server/main.go b/zddc/cmd/zddc-server/main.go index 3828c55..0e3d1ef 100644 --- a/zddc/cmd/zddc-server/main.go +++ b/zddc/cmd/zddc-server/main.go @@ -796,6 +796,13 @@ func dispatch(cfg config.Config, idx *archive.Index, ring *handler.LogRing, apps return } + // The .zddc JSON Schema (machine grammar) — drives the .zddc form view + + // client validation. Static, read-only, no auth. + if urlPath == handler.ZddcSchemaPath { + handler.ServeZddcSchema(w, r) + return + } + // Auth check endpoints — machine-only forward_auth targets used by // upstream proxies (e.g. the dev-shell pod's Caddy in front of // code-server) to gate routes on root-admin status. Handled before diff --git a/zddc/internal/handler/schemahandler.go b/zddc/internal/handler/schemahandler.go new file mode 100644 index 0000000..f219e96 --- /dev/null +++ b/zddc/internal/handler/schemahandler.go @@ -0,0 +1,28 @@ +package handler + +import ( + "net/http" + + "codeberg.org/VARASYS/ZDDC/zddc/internal/zddc" +) + +// ZddcSchemaPath is the JSON endpoint serving the .zddc JSON Schema (the machine +// grammar). The browse client + the .zddc form view fetch it to drive editing +// (per-property x-zddc-tier marks structure vs option) and validation. +const ZddcSchemaPath = "/.api/zddc-schema" + +// ServeZddcSchema returns the embedded .zddc JSON Schema. Read-only, no auth — +// it documents the policy grammar and leaks nothing. GET/HEAD only. +func ServeZddcSchema(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet && r.Method != http.MethodHead { + w.Header().Set("Allow", "GET, HEAD") + http.Error(w, "Method Not Allowed", http.StatusMethodNotAllowed) + return + } + w.Header().Set("Content-Type", "application/schema+json; charset=utf-8") + w.Header().Set("Cache-Control", "max-age=300") + if r.Method == http.MethodHead { + return + } + _, _ = w.Write(zddc.ZddcSchemaBytes()) +} diff --git a/zddc/internal/zddc/schema.go b/zddc/internal/zddc/schema.go new file mode 100644 index 0000000..501c17b --- /dev/null +++ b/zddc/internal/zddc/schema.go @@ -0,0 +1,20 @@ +package zddc + +import _ "embed" + +// schemaBytes is the embedded .zddc JSON Schema (draft 2020-12). The +// authoritative machine grammar: it drives the form view (per-property +// x-zddc-tier: structure|option decides editable vs read-only) and client +// validation. Server-side structural validation still lives in validate.go — +// this schema uses $ref + patternProperties, which the in-tree +// internal/jsonschema validator does not yet support. +// +//go:embed zddc.schema.json +var schemaBytes []byte + +// ZddcSchemaBytes returns a copy of the embedded .zddc JSON Schema. +func ZddcSchemaBytes() []byte { + out := make([]byte, len(schemaBytes)) + copy(out, schemaBytes) + return out +} diff --git a/zddc/internal/zddc/schema_test.go b/zddc/internal/zddc/schema_test.go new file mode 100644 index 0000000..b076ac9 --- /dev/null +++ b/zddc/internal/zddc/schema_test.go @@ -0,0 +1,59 @@ +package zddc + +import ( + "encoding/json" + "testing" +) + +// The embedded .zddc schema must be valid JSON, cover the grammar's keys, and +// tag every property structure|option — the contract the form view relies on. +func TestZddcSchemaTiers(t *testing.T) { + var doc struct { + Properties map[string]struct { + Tier string `json:"x-zddc-tier"` + } `json:"properties"` + } + if err := json.Unmarshal(ZddcSchemaBytes(), &doc); err != nil { + t.Fatalf("schema is not valid JSON: %v", err) + } + if len(doc.Properties) == 0 { + t.Fatal("schema has no properties") + } + + // Every property carries a valid tier. + for name, p := range doc.Properties { + if p.Tier != "structure" && p.Tier != "option" { + t.Errorf("property %q has x-zddc-tier=%q, want structure|option", name, p.Tier) + } + } + + // Spot-check the classification the form depends on. + wantTier := map[string]string{ + "roles": "option", // who's in a group — the headline customisation + "field_codes": "option", + "convert": "option", + "display": "option", + "admins": "option", + "title": "option", + "paths": "structure", // the project shape + "worm": "structure", + "party_source": "structure", + "default_tool": "structure", + "available_tools": "structure", + "auto_own": "structure", + "acl": "structure", + "records": "structure", + "views": "structure", + "created_by": "structure", + } + for name, want := range wantTier { + p, ok := doc.Properties[name] + if !ok { + t.Errorf("schema missing expected key %q", name) + continue + } + if p.Tier != want { + t.Errorf("key %q tier=%q, want %q", name, p.Tier, want) + } + } +} diff --git a/zddc/internal/zddc/zddc.schema.json b/zddc/internal/zddc/zddc.schema.json new file mode 100644 index 0000000..b14438f --- /dev/null +++ b/zddc/internal/zddc/zddc.schema.json @@ -0,0 +1,203 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://zddc.varasys.io/schema/zddc.schema.json", + "title": ".zddc policy document", + "description": "Machine schema for the .zddc grammar (see GRAMMAR.md). Each property carries x-zddc-tier: 'structure' (the project shape an end user should not change — paths, WORM, tools, behaviors) or 'option' (the blanks an operator fills — role members, field-code vocabularies, names, labels). A form view renders option fields editable and structure fields read-only. NOTE: not all keys are valid at every level; the cascade + the per-location form decide relevance. Server-side validation still lives in validate.go (this draft-2020-12 schema uses $ref + patternProperties, which the in-tree validator does not yet support); the schema drives the form + client today.", + "type": "object", + "additionalProperties": false, + "properties": { + "title": { + "type": "string", + "description": "Human title for this directory.", + "x-zddc-tier": "option" + }, + "created_by": { + "type": "string", + "description": "Email of the user who created this folder. Set by the server; audit only.", + "x-zddc-tier": "structure" + }, + "admins": { + "type": "array", + "items": { "type": "string" }, + "description": "Principals (emails, globs, or role names) who administer this subtree. Root admins are super-admins; deeper entries are subtree admins. Elevation-gated full bypass over scope.", + "x-zddc-tier": "option" + }, + "roles": { + "type": "object", + "description": "Named principal groups referenced by acl/worm/admins. Membership UNIONS across the cascade. The operator fills the members.", + "additionalProperties": { + "type": "object", + "additionalProperties": false, + "properties": { + "members": { + "type": "array", + "items": { "type": "string" }, + "description": "Email patterns (alice@x, *@acme.com, *) in this role." + }, + "reset": { + "type": "boolean", + "description": "Stop the membership union here: ancestor definitions above this level are excluded." + } + } + }, + "x-zddc-tier": "option" + }, + "acl": { + "type": "object", + "description": "Access control for this level. permissions maps a principal (email/glob/role) to a verb string from r w c d a (empty string = explicit deny). inherit:false clamps the ACL level-walk so ancestor levels' grants do not apply.", + "additionalProperties": false, + "properties": { + "inherit": { "type": "boolean", "description": "false = this level's ACL does not inherit ancestor levels." }, + "permissions": { + "type": "object", + "patternProperties": { + "^.+$": { "type": "string", "pattern": "^[rwcda]*$", "description": "Verb subset of r w c d a; empty = explicit deny." } + }, + "additionalProperties": false + } + }, + "x-zddc-tier": "structure" + }, + "worm": { + "type": "array", + "items": { "type": "string" }, + "description": "WORM zone: write/delete/admin stripped for all; create survives only for the listed principals; admins bypass. Unions across the cascade.", + "x-zddc-tier": "structure" + }, + "inherit": { + "type": "boolean", + "description": "false = stop the cascade here; everything below (ancestors + embedded defaults) is ignored. Makes a subtree a self-contained island.", + "x-zddc-tier": "structure" + }, + "default_tool": { + "type": "string", + "enum": ["archive", "transmittal", "classifier", "browse", "tables", "landing", "form"], + "description": "Tool served at