feat(server): table/form specs resolve from .zddc.d/ + server-inject the table spec

The supporting config files (table.yaml, form.yaml) can now live in the
admin-gated, hidden `<dir>/.zddc.d/` reserve instead of the directory root —
the `.zddc`-declares / `.zddc.d/`-carries split. Backward-compatible: the
legacy root location still resolves (preferred order: .zddc.d/ → root →
embedded default).

Because `.zddc.d/` is non-fetchable over HTTP for non-admins, the spec is
resolved server-side and INJECTED:
- handler: LoadViewSpec(dir, name) resolves .zddc.d/ → root → embedded
  (classifyDefaultSpec is now location-agnostic — strips a `.zddc.d` segment).
- ServeTable injects the parsed table spec + row schema into the existing
  #table-context as {spec, rowSchema}; RecognizeTableRequest also recognizes a
  spec under .zddc.d/.
- formhandler loadFormSpec + specEligible prefer .zddc.d/form.yaml (forms
  already inject #form-context, so server-only).
- client (tables/js/context.js): walkServer uses the injected spec/rowSchema
  when present (server mode) and still walks the directory for ROW files; FS-
  Access mode reads .zddc.d/<name> (then legacy root) via readYamlFirst. load()
  passes the injected context through. Regenerated the embedded tables.html.

go build/vet/test ./... green; all 40 tables Playwright specs pass; the
ServeTable test now asserts the injected spec.

Remaining (next): file→form URL shape, retiring the recognizers in favour of
ServeView/views:, defaults.zddc.yaml views declaration, writers→.zddc.d/, and
the migration script.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
ZDDC 2026-06-04 10:20:55 -05:00
parent 45af24b2b1
commit 03fa366814
5 changed files with 218 additions and 56 deletions

View file

@ -22,13 +22,19 @@
// inline context (tests) or open the page through zddc-server.
async function load() {
const inline = readInlineContext();
if (inline && Object.keys(inline).length > 0) {
// A fully pre-assembled context (columns + rows) is used as-is — the
// test seam, or any host that renders the whole table server-side.
if (inline && Array.isArray(inline.columns)) {
return inline;
}
// Otherwise the inline context may still carry the server-injected
// SPEC ({spec, rowSchema}) sourced from <dir>/.zddc.d/ — pass it to
// walkServer, which uses it instead of fetching the spec and still
// walks the directory for row files.
if (typeof location !== 'undefined' &&
(location.protocol === 'http:' || location.protocol === 'https:')) {
try {
const walked = await walkServer();
const walked = await walkServer(inline || {});
if (walked) {
return walked;
}
@ -60,7 +66,8 @@
el.hidden = false;
}
async function walkServer() {
async function walkServer(injected) {
injected = injected || {};
const source = window.zddc && window.zddc.source;
if (!source) {
throw new Error('zddc.source not available');
@ -77,27 +84,32 @@
}
const dir = probe.handle;
// Spec lives at <currentdir>/table.yaml — the page URL is
// <currentdir>/table.html, so the spec is right next door.
const spec = await readYaml(dir, 'table.yaml');
// Spec: prefer the server-injected #table-context.spec (sourced from
// <dir>/.zddc.d/table.yaml). Falling back, read the spec from the
// supporting-files reserve, then the legacy directory root — the
// FS-Access path, where there's no server to inject.
let spec = (injected.spec && Array.isArray(injected.spec.columns))
? injected.spec : null;
if (!spec) {
spec = await readYamlFirst(dir, ['.zddc.d/table.yaml', 'table.yaml']);
}
if (!spec || !Array.isArray(spec.columns)) {
throw new Error('Spec table.yaml missing columns[]');
}
// Optional row schema from <dir>/form.yaml — same JSON Schema
// the form-mode renderer uses. Phase 2 derives per-cell editor
// widgets from it (text/number/date/select/checkbox).
// Best-effort: a directory with only table.yaml still renders
// as a sortable/filterable table; cells fall back to plain
// text inputs without per-property hints.
let rowSchema = null;
try {
const formSpec = await readYaml(dir, 'form.yaml');
if (formSpec && formSpec.schema) {
rowSchema = formSpec.schema;
// Row schema: prefer the injected #table-context.rowSchema, else read
// <dir>/.zddc.d/form.yaml (then legacy root). Best-effort — a table
// with no row schema still renders with plain-text cells.
let rowSchema = injected.rowSchema || null;
if (!rowSchema) {
try {
const formSpec = await readYamlFirst(dir, ['.zddc.d/form.yaml', 'form.yaml']);
if (formSpec && formSpec.schema) {
rowSchema = formSpec.schema;
}
} catch (_) {
// form.yaml missing or unreadable; carry on without it.
}
} catch (_) {
// form.yaml missing or unreadable; carry on without it.
}
// Rows are every *.yaml in <currentdir> EXCEPT the spec
@ -156,6 +168,22 @@
return window.jsyaml.load(text);
}
// readYamlFirst tries each relPath in order, returning the first that
// resolves + parses. Used to read a spec from the supporting-files
// reserve (.zddc.d/<name>) with a fallback to the legacy directory root.
async function readYamlFirst(dir, relPaths) {
let lastErr = null;
for (var i = 0; i < relPaths.length; i++) {
try {
return await readYaml(dir, relPaths[i]);
} catch (err) {
lastErr = err;
}
}
if (lastErr) throw lastErr;
return null;
}
// Walk a "/"-separated relative path under dir, returning the
// FileSystemFileHandle (or HttpFileHandle) at the leaf.
async function resolveFile(dir, relPath) {

View file

@ -159,6 +159,10 @@ func RecognizeFormRequest(fsRoot, method, urlPath string) *FormRequest {
// any of the default-spec virtual-fallback shapes (per-party
// mdl/rsk, per-party SSR schema, project-level virtual specs).
specEligible := func(specAbs string) bool {
dir, base := filepath.Split(specAbs)
if fileExists(filepath.Join(filepath.Clean(dir), ".zddc.d", base)) {
return true
}
if fileExists(specAbs) {
return true
}
@ -542,13 +546,19 @@ func serveFormUpdate(cfg config.Config, req *FormRequest, w http.ResponseWriter,
// --- Helpers -----------------------------------------------------------------
func loadFormSpec(fsRoot, path string) (*FormSpec, error) {
data, err := os.ReadFile(path)
// Prefer the supporting-files reserve: a spec at <dir>/.zddc.d/form.yaml
// takes precedence over the legacy <dir>/form.yaml. `path` is the legacy
// <dir>/form.yaml location the callers build.
dir, base := filepath.Split(path)
data, err := os.ReadFile(filepath.Join(filepath.Clean(dir), ".zddc.d", base))
if err != nil {
// Default-spec virtual fallback: when no operator file exists at
// path, serve the embedded default if path matches one of the
// recognized virtual fallback shapes (per-party mdl/rsk, per-
// party SSR schema, project-level virtual specs). Mirrors the
// static-handler fallback for direct YAML fetches.
data, err = os.ReadFile(path)
}
if err != nil {
// Default-spec virtual fallback: when no operator file exists in
// either location, serve the embedded default if path matches one of
// the recognized virtual fallback shapes (per-party mdl/rsk, per-
// party SSR schema, project-level virtual specs).
if os.IsNotExist(err) {
if bytes, ok := IsDefaultSpecAbs(fsRoot, path); ok {
data = bytes

View file

@ -28,11 +28,15 @@ package handler
import (
_ "embed"
"encoding/json"
"log/slog"
"net/http"
"os"
"path/filepath"
"strings"
"gopkg.in/yaml.v3"
"codeberg.org/VARASYS/ZDDC/zddc/internal/config"
"codeberg.org/VARASYS/ZDDC/zddc/internal/policy"
"codeberg.org/VARASYS/ZDDC/zddc/internal/zddc"
@ -161,6 +165,17 @@ func IsDefaultSpecAbs(fsRoot, absPath string) ([]byte, bool) {
// not name one of the recognized virtual fallback files.
func classifyDefaultSpec(rel string) []byte {
parts := strings.Split(rel, "/")
// A spec may live either in the directory root (<dir>/table.yaml) or in
// the supporting-files reserve (<dir>/.zddc.d/table.yaml). Strip a
// ".zddc.d" segment so both classify by the same dir shape.
clean := parts[:0:0]
for _, p := range parts {
if strings.EqualFold(p, ".zddc.d") {
continue
}
clean = append(clean, p)
}
parts = clean
switch len(parts) {
case 4:
// <project>/<peer>/<party>/<file> — per-party register specs
@ -309,8 +324,9 @@ func RecognizeTableRequest(fsRoot, method, urlPath string) *TableRequest {
specAbs := filepath.Join(dirAbs, "table.yaml")
// Presence-based discovery: <dir>/table.yaml on disk.
if fileExists(specAbs) {
// Presence-based discovery: the spec in the supporting-files reserve
// (<dir>/.zddc.d/table.yaml) or, legacy, the directory root.
if fileExists(filepath.Join(dirAbs, ".zddc.d", "table.yaml")) || fileExists(specAbs) {
return &TableRequest{Name: name, SpecPath: specAbs, Dir: dirAbs}
}
@ -362,10 +378,77 @@ func isNotExistError(err error) bool {
return err != nil && strings.Contains(err.Error(), "no such file or directory")
}
// ServeTable serves the static tables.html bytes for a recognized
// request. ACL gate is the read action at the request directory; on
// allow, the embedded HTML is written verbatim. The client takes over
// from there — see tables/js/main.js.
// LoadViewSpec resolves a config file's bytes for dir, preferring the
// supporting-files reserve <dir>/.zddc.d/<name>, then the legacy <dir>/<name>,
// then the embedded default for this dir's shape. Returns nil when none
// applies. This is the single seam that puts table/form specs under .zddc.d/
// (where they're admin-gated + hidden) while staying back-compatible.
func LoadViewSpec(fsRoot, dir, name string) []byte {
if b, err := os.ReadFile(filepath.Join(dir, ".zddc.d", name)); err == nil {
return b
}
if b, err := os.ReadFile(filepath.Join(dir, name)); err == nil {
return b
}
if rel, err := filepath.Rel(fsRoot, filepath.Join(dir, name)); err == nil {
if b := classifyDefaultSpec(filepath.ToSlash(rel)); b != nil {
return b
}
}
return nil
}
// injectTableContext writes the resolved table spec + row-form schema into the
// `#table-context` placeholder so the client reads them instead of fetching
// <dir>/table.yaml and <dir>/form.yaml over HTTP (impossible once the specs
// live under the admin-gated .zddc.d/). The client still walks the directory
// for ROW files — only the SPEC is injected. Shape:
//
// { "spec": <parsed table.yaml>, "rowSchema": <parsed form.yaml .schema> }
//
// Empty {} when neither resolves (the client then walks for the spec too,
// preserving legacy behavior). Returns an error only if the placeholder is
// absent from the template.
func injectTableContext(template, tableYAML, formYAML []byte) ([]byte, error) {
ctx := map[string]interface{}{}
if len(tableYAML) > 0 {
var spec interface{}
if err := yaml.Unmarshal(tableYAML, &spec); err == nil && spec != nil {
ctx["spec"] = spec
}
}
if len(formYAML) > 0 {
var fs map[string]interface{}
if err := yaml.Unmarshal(formYAML, &fs); err == nil {
if sch, ok := fs["schema"]; ok {
ctx["rowSchema"] = sch
}
}
}
js, err := json.Marshal(ctx)
if err != nil {
return nil, err
}
js = []byte(strings.ReplaceAll(string(js), "</", "<\\/"))
needle := []byte(`<script id="table-context" type="application/json">{}</script>`)
if !bytesContains(template, needle) {
return nil, errBundle("#table-context placeholder not found in template")
}
replacement := append([]byte(`<script id="table-context" type="application/json">`), js...)
replacement = append(replacement, []byte(`</script>`)...)
return bytesReplace(template, needle, replacement), nil
}
type errBundle string
func (e errBundle) Error() string { return string(e) }
// ServeTable serves the tables HTML for a recognized request, ACL-gated on
// read at the request directory. The resolved table.yaml + form.yaml (from
// .zddc.d/, legacy root, or the embedded default) are injected as
// #table-context so the client never fetches the spec over HTTP. If the
// template predates the placeholder, the bare HTML is served (the client
// falls back to fetching) — keeps this non-breaking before ./build.
func ServeTable(cfg config.Config, req *TableRequest, w http.ResponseWriter, r *http.Request) {
p := PrincipalFromContext(r)
decider := DeciderFromContext(r)
@ -384,7 +467,14 @@ func ServeTable(cfg config.Config, req *TableRequest, w http.ResponseWriter, r *
return
}
body := embeddedTablesHTML
tableYAML := LoadViewSpec(cfg.Root, req.Dir, "table.yaml")
formYAML := LoadViewSpec(cfg.Root, req.Dir, "form.yaml")
if injected, ierr := injectTableContext(embeddedTablesHTML, tableYAML, formYAML); ierr == nil {
body = injected
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Header().Set("Cache-Control", "no-store")
_, _ = w.Write(embeddedTablesHTML)
_, _ = w.Write(body)
}

View file

@ -184,8 +184,9 @@ func TestRecognizeTableRequest(t *testing.T) {
}
// TestServeTable_ServesEmbeddedHTML — an ACL-passing GET returns the
// embedded tables.html bytes verbatim, with the empty inline context
// placeholder intact (so the client knows to walk the directory).
// embedded tables.html with the resolved table spec server-injected into
// #table-context (the embedded default for this virtual MDL dir), so the
// client renders without a separate spec fetch.
func TestServeTable_ServesEmbeddedHTML(t *testing.T) {
rows := map[string]string{
"D-001.yaml": "id: D-001\ntitle: One\nstatus: pending\n",
@ -202,8 +203,13 @@ func TestServeTable_ServesEmbeddedHTML(t *testing.T) {
if !strings.Contains(body, `<table id="table-root"`) {
t.Error("body missing #table-root markup; embedded HTML may be stale or empty")
}
if !strings.Contains(body, `<script id="table-context" type="application/json">{}</script>`) {
t.Error("inline context placeholder not preserved verbatim — client expects {} so it knows to walk")
// #table-context is no longer the empty placeholder — the resolved spec
// is injected (the client uses it instead of fetching table.yaml).
if strings.Contains(body, `<script id="table-context" type="application/json">{}</script>`) {
t.Error("#table-context still empty; expected the resolved spec to be injected")
}
if !strings.Contains(body, `id="table-context"`) || !strings.Contains(body, `"spec"`) {
t.Error("expected the resolved table spec injected into #table-context")
}
}

View file

@ -1534,7 +1534,7 @@ body.is-elevated::after {
</svg>
<div class="header-title-group">
<span class="app-header__title" id="table-title">ZDDC Table</span>
<span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.27-beta · 2026-06-03 18:26:16 · f723323</span></span>
<span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.27-dev · 2026-06-04 15:19:25 · 45af24b-dirty</span></span>
</div>
</div>
<div class="header-right">
@ -3691,13 +3691,19 @@ body.is-elevated::after {
// inline context (tests) or open the page through zddc-server.
async function load() {
const inline = readInlineContext();
if (inline && Object.keys(inline).length > 0) {
// A fully pre-assembled context (columns + rows) is used as-is — the
// test seam, or any host that renders the whole table server-side.
if (inline && Array.isArray(inline.columns)) {
return inline;
}
// Otherwise the inline context may still carry the server-injected
// SPEC ({spec, rowSchema}) sourced from <dir>/.zddc.d/ — pass it to
// walkServer, which uses it instead of fetching the spec and still
// walks the directory for row files.
if (typeof location !== 'undefined' &&
(location.protocol === 'http:' || location.protocol === 'https:')) {
try {
const walked = await walkServer();
const walked = await walkServer(inline || {});
if (walked) {
return walked;
}
@ -3729,7 +3735,8 @@ body.is-elevated::after {
el.hidden = false;
}
async function walkServer() {
async function walkServer(injected) {
injected = injected || {};
const source = window.zddc && window.zddc.source;
if (!source) {
throw new Error('zddc.source not available');
@ -3746,27 +3753,32 @@ body.is-elevated::after {
}
const dir = probe.handle;
// Spec lives at <currentdir>/table.yaml — the page URL is
// <currentdir>/table.html, so the spec is right next door.
const spec = await readYaml(dir, 'table.yaml');
// Spec: prefer the server-injected #table-context.spec (sourced from
// <dir>/.zddc.d/table.yaml). Falling back, read the spec from the
// supporting-files reserve, then the legacy directory root — the
// FS-Access path, where there's no server to inject.
let spec = (injected.spec && Array.isArray(injected.spec.columns))
? injected.spec : null;
if (!spec) {
spec = await readYamlFirst(dir, ['.zddc.d/table.yaml', 'table.yaml']);
}
if (!spec || !Array.isArray(spec.columns)) {
throw new Error('Spec table.yaml missing columns[]');
}
// Optional row schema from <dir>/form.yaml — same JSON Schema
// the form-mode renderer uses. Phase 2 derives per-cell editor
// widgets from it (text/number/date/select/checkbox).
// Best-effort: a directory with only table.yaml still renders
// as a sortable/filterable table; cells fall back to plain
// text inputs without per-property hints.
let rowSchema = null;
try {
const formSpec = await readYaml(dir, 'form.yaml');
if (formSpec && formSpec.schema) {
rowSchema = formSpec.schema;
// Row schema: prefer the injected #table-context.rowSchema, else read
// <dir>/.zddc.d/form.yaml (then legacy root). Best-effort — a table
// with no row schema still renders with plain-text cells.
let rowSchema = injected.rowSchema || null;
if (!rowSchema) {
try {
const formSpec = await readYamlFirst(dir, ['.zddc.d/form.yaml', 'form.yaml']);
if (formSpec && formSpec.schema) {
rowSchema = formSpec.schema;
}
} catch (_) {
// form.yaml missing or unreadable; carry on without it.
}
} catch (_) {
// form.yaml missing or unreadable; carry on without it.
}
// Rows are every *.yaml in <currentdir> EXCEPT the spec
@ -3825,6 +3837,22 @@ body.is-elevated::after {
return window.jsyaml.load(text);
}
// readYamlFirst tries each relPath in order, returning the first that
// resolves + parses. Used to read a spec from the supporting-files
// reserve (.zddc.d/<name>) with a fallback to the legacy directory root.
async function readYamlFirst(dir, relPaths) {
let lastErr = null;
for (var i = 0; i < relPaths.length; i++) {
try {
return await readYaml(dir, relPaths[i]);
} catch (err) {
lastErr = err;
}
}
if (lastErr) throw lastErr;
return null;
}
// Walk a "/"-separated relative path under dir, returning the
// FileSystemFileHandle (or HttpFileHandle) at the leaf.
async function resolveFile(dir, relPath) {