docs+test: document the apiActions / server-injected-table primitive

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) <noreply@anthropic.com>
This commit is contained in:
ZDDC 2026-06-06 16:55:20 -05:00
parent d9256050d2
commit 0c6396d246
3 changed files with 62 additions and 4 deletions

View file

@ -406,6 +406,22 @@ Read/aggregate counterpart to the form system. Renders a directory of YAML row f
**Default-MDL fallback at `archive/<party>/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/<party>/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 `<dir>/*.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/<party>/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 `<dir>/` and drop `table.yaml` (and optionally `form.yaml` for row editing) into it. No code change required. Visit `<dir>/table.html`.

View file

@ -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 `<app>.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 `<app>.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.

View file

@ -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"])
}
}