From 0c6396d246f15d28226e68a8b4ae7b5779707645 Mon Sep 17 00:00:00 2001 From: ZDDC Date: Sat, 6 Jun 2026 16:55:20 -0500 Subject: [PATCH] docs+test: document the apiActions / server-injected-table primitive MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Capture the mechanism the tokens + profile consolidation now rests on: AGENTS.md gains a "Server-injected collections (apiActions)" section under the Tables system (pre-assembled #table-context + the create/deleteRow/ rowNav layer, with server-side per-role gating), and the ARCHITECTURE ADR marks step 2 done (/.tokens + /.profile render via the engine) and flags that the remaining folds (archive/landing/transmittal) are feature-rich PLUGIN migrations — not quick tables-fications. Adds TestBuildTokensTableContext locking the contract: only the caller's own tokens become rows, each row carries its id for the delete action, and apiActions wires create (one-time secret) + per-row delete to /.api/tokens. Co-Authored-By: Claude Opus 4.8 (1M context) --- AGENTS.md | 16 +++++++++ ARCHITECTURE.md | 9 ++--- zddc/internal/handler/tokenhandler_test.go | 41 ++++++++++++++++++++++ 3 files changed, 62 insertions(+), 4 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index dfe8509..023a864 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -406,6 +406,22 @@ Read/aggregate counterpart to the form system. Renders a directory of YAML row f **Default-MDL fallback at `archive//mdl/`**: when no `table.yaml` (or `form.yaml`) exists on disk in this exact location, the server serves embedded default bytes. The `mdl/` directory itself doesn't even need to exist — the URL renders the default MDL view fully virtually so a fresh archive surfaces the master document list without operator setup. Outside `archive//mdl/`, presence-based discovery is the rule. +### Server-injected collections (`apiActions`) — dynamic/virtual tables + +The tables renderer also accepts a **fully pre-assembled, server-injected** `#table-context` (`{title, description, columns[], rows[]}` — used as-is, no directory walk; see `tables/js/context.js` and `handler.injectTableContextObj`). This lets a server handler render a *dynamic or virtual* record collection through the same engine + header chrome as an on-disk table, instead of a bespoke page. When the injected context also carries an **`apiActions`** block, the generic `tables/js/api-actions.js` layer turns the read-only table into a managed collection backed by a REST endpoint — **without touching the file-save/row-ops machinery** (which is bound to `/*.yaml` row files): + +``` +apiActions: { + create: { url, title?, fixed?{k:v}, fields:[{name,label,placeholder?,type?,required?}], secretField?, secretLabel? }, + deleteRow: { urlTemplate (with {id} ← row data-url), label?, confirm? }, + rowNav: true // clicking a row navigates to its data-url (capture-phase) +} +``` + +`create` → modal form → `POST` (date fields → RFC3339; `fixed` adds constants; a `secretField` in the response is shown once); `deleteRow` → per-row button → `DELETE`; both reload on success. It also hides the file-model toolbar buttons (`+ Add row`, `Save`). + +**Consumers:** `/.tokens` (`handler.buildTokensTableContext` → `/.api/tokens`) and `/.profile` (`handler.buildProfileTableContext` → effective access + `POST /.profile/projects` + super-admin diagnostic rows). Per-role correctness is enforced **server-side** — a row/action only appears when the caller is authorized (e.g. profile diagnostics gated on elevated super-admin), so a non-admin's bytes never name a capability they lack. This is the "any dynamic collection is a declarative table, not a bespoke page" primitive from ARCHITECTURE.md's browse-as-shell ADR. + **Default-MDL columns mirror the tracking-number components** documented at `zddc.varasys.io/reference.html#tracking-numbers`. The default ships the five required components + an optional per-deliverable `suffix`: `originator`, `project`, `discipline`, `type`, `sequence`, `suffix` — each a slot of the deliverable's permanent identifier — plus `title`, `plannedRevision`, `plannedDate`, `status`, `owner`. The project-wide `phase` / `area` components are shipped only as commented-out templates in the default form/table YAML (a project that uses them must enable them on *every* deliverable to keep filenames lexically consistent, so the simplest default omits them). `originator` is **folder-bound**: the cascade's `folder_fields` pins it to the party-folder name, so the form renders it read-only and the server sets it from the path. The form schema accepts free-text on every other component by default; projects narrow the vocabulary via the cascade's `field_codes:` (see below). Operator overrides at `archive//mdl/{table,form}.yaml` still win atomically over the embedded defaults. Source: `zddc/internal/handler/default-mdl.{table,form}.yaml`. **Adding a new table**: create a directory `/` and drop `table.yaml` (and optionally `form.yaml` for row editing) into it. No code change required. Visit `/table.html`. diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index e16cdd5..3d6286e 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -54,10 +54,11 @@ The md / yaml / `.zddc`-form editors already follow this shape (`handles` / `ren **Migration (incremental; standalone tools keep working throughout).** 1. ✓ Editors are in-pane modules; classifier / tables / forms embed in the pane; the shell header carries the profile menu + progressive-enhancement elevation. -2. Fold `archive` into the tree + a listing plugin. -3. Make `landing` the shell's root ("no project selected") view. -4. Move `transmittal` into a workflow plugin. -5. Flip `default_tool` routing to "browse + plugin X"; retire each standalone `.html` only once its plugin lands. +2. ✓ The two bespoke, chrome-less server pages — `/.tokens` and `/.profile` — now render through the tables engine via server-injected `#table-context` + the generic `apiActions` layer (see AGENTS.md "Server-injected collections"). That's the "dynamic collection → declarative table, not a bespoke page" half proven. +3. Fold `archive` into the tree + a listing plugin. +4. Make `landing` the shell's root ("no project selected") view — note `landing` is feature-rich (saved groups, multi-select, filters), so this is a *plugin* migration that preserves those, NOT a tables-fication. +5. Move `transmittal` into a workflow plugin. +6. Flip `default_tool` routing to "browse + plugin X"; retire each standalone `.html` only once its plugin lands. (`archive`/`landing`/`transmittal` are all feature-rich — each fold is a deliberate, scoped effort, not a quick tables swap.) **Consequences / tradeoffs.** - Preserves the single-file + offline value: the shell still builds to one `browse.html` that runs from `file://`. Heavy plugins should lazy-load in server mode to keep the bundle reasonable. diff --git a/zddc/internal/handler/tokenhandler_test.go b/zddc/internal/handler/tokenhandler_test.go index 2b6b4e5..0fdac8f 100644 --- a/zddc/internal/handler/tokenhandler_test.go +++ b/zddc/internal/handler/tokenhandler_test.go @@ -456,3 +456,44 @@ func TestWithEmail(t *testing.T) { t.Errorf("EmailFromContext = %q", got) } } + +// TestBuildTokensTableContext locks the server-injected token table contract: +// only the caller's own tokens become rows, each row carries its id (for the +// delete action), and apiActions wires create (with the one-time secret) + +// per-row delete to /.api/tokens. +func TestBuildTokensTableContext(t *testing.T) { + store := newTestTokenStore(t) + if _, _, err := store.Generate("alice@example.com", "Field laptop", time.Time{}); err != nil { + t.Fatalf("Generate alice: %v", err) + } + if _, _, err := store.Generate("mallory@example.com", "other", time.Time{}); err != nil { + t.Fatalf("Generate mallory: %v", err) + } + + ctx := buildTokensTableContext(store, "alice@example.com") + if ctx["title"] != "API tokens" { + t.Errorf("title = %v", ctx["title"]) + } + + rows, ok := ctx["rows"].([]map[string]interface{}) + if !ok || len(rows) != 1 { + t.Fatalf("rows = %#v, want exactly alice's one token", ctx["rows"]) + } + data, _ := rows[0]["data"].(map[string]interface{}) + if data["description"] != "Field laptop" { + t.Errorf("row description = %v", data["description"]) + } + if id, _ := rows[0]["url"].(string); id == "" { + t.Errorf("row missing url (token id needed for the delete action)") + } + + api, _ := ctx["apiActions"].(map[string]interface{}) + create, _ := api["create"].(map[string]interface{}) + if create["url"] != TokensAPIPathPrefix || create["secretField"] != "token" { + t.Errorf("apiActions.create = %#v, want url=%s secretField=token", create, TokensAPIPathPrefix) + } + del, _ := api["deleteRow"].(map[string]interface{}) + if del["urlTemplate"] != TokensAPIPathPrefix+"/{id}" { + t.Errorf("apiActions.deleteRow.urlTemplate = %v", del["urlTemplate"]) + } +}