@ultimat3/admin 23.0.0 → 25.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (64) hide show
  1. package/CLAUDE.md +32 -30
  2. package/README.md +51 -16
  3. package/package.json +17 -17
  4. package/src/action-gate.ts +33 -42
  5. package/src/admin.module.scss +118 -4
  6. package/src/audit-pg.ts +2 -2
  7. package/src/audit.ts +1 -3
  8. package/src/authz.ts +8 -8
  9. package/src/batch-matching.ts +26 -31
  10. package/src/batch-queue.ts +90 -0
  11. package/src/batch.ts +11 -51
  12. package/src/crud-input.ts +2 -1
  13. package/src/crud-outcome.ts +96 -38
  14. package/src/crud.ts +48 -4
  15. package/src/describe.ts +6 -0
  16. package/src/dev/data.ts +5 -8
  17. package/src/dev/dev-t.ts +9 -0
  18. package/src/dev/facts.ts +1 -1
  19. package/src/dev/index.ts +1 -0
  20. package/src/dev/jobs-state-chart.tsx +38 -0
  21. package/src/dev/panel-cache.ts +8 -3
  22. package/src/dev/panel-jobs.ts +10 -5
  23. package/src/dev/panel-timeline.ts +16 -3
  24. package/src/dev/panel.ts +10 -5
  25. package/src/dev/server.ts +43 -17
  26. package/src/dev/sql-runner.ts +31 -0
  27. package/src/dev/timeline-style.ts +47 -0
  28. package/src/dev/timeline-waterfall.ts +96 -0
  29. package/src/errors.ts +9 -5
  30. package/src/form-decode.ts +55 -5
  31. package/src/form.tsx +19 -5
  32. package/src/index.ts +11 -9
  33. package/src/jobs/fleet-repo.ts +15 -3
  34. package/src/jobs/job-repo.ts +51 -17
  35. package/src/jobs/operator.ts +5 -5
  36. package/src/jobs/overview-tab.tsx +2 -2
  37. package/src/keyset-seek.ts +189 -0
  38. package/src/layout.tsx +34 -8
  39. package/src/list-filters.ts +30 -5
  40. package/src/list-request.ts +8 -6
  41. package/src/list.tsx +1 -1
  42. package/src/mcp-scopes.ts +36 -0
  43. package/src/mcp-tools.ts +16 -2
  44. package/src/mcp.ts +27 -11
  45. package/src/pagination.ts +32 -22
  46. package/src/policy-bridge.ts +16 -14
  47. package/src/registry.ts +9 -1
  48. package/src/related.ts +2 -2
  49. package/src/relations.ts +26 -10
  50. package/src/repo-entity.ts +37 -46
  51. package/src/resource-list.ts +9 -0
  52. package/src/resource.ts +10 -0
  53. package/src/row-version.ts +20 -0
  54. package/src/screen-home-access.tsx +117 -0
  55. package/src/screen-home-counts.ts +42 -0
  56. package/src/screen-home-kpis.tsx +64 -0
  57. package/src/screen-home.module.scss +169 -0
  58. package/src/screen-home.tsx +21 -82
  59. package/src/screen-lookup.tsx +1 -1
  60. package/src/screen-resource.tsx +60 -12
  61. package/src/screen-system.tsx +8 -2
  62. package/src/search.ts +12 -4
  63. package/src/widgets.tsx +2 -6
  64. package/src/inert-jsx.ts +0 -182
package/CLAUDE.md CHANGED
@@ -9,69 +9,71 @@ Two products, one package, **two entry points**: `@ultimat3/admin/dev` (`src/dev
9
9
  - **One authz.** `AdminAuthz` (`authz.ts`) is the only decision path. `action-gate.ts`, `crud.ts` and `page-guard.tsx` call `decideAll`; views render what the gate returned. Never add a second check in a view or an MCP handler.
10
10
  - **The subject carries the tenant and the loaded row.** `AdminActor.orgId` reaches `policyActor` and `AdminSubject.row` reaches `evaluate`; `adminDetail`/`adminUpdate`/`adminDestroy` load the row BEFORE the guard. `undefined` = not loaded (left off the subject), `null` = looked and found none (a row rule fails closed). Pinned in `policy-bridge.test.ts`, which asserts the SUBJECT a policy receives.
11
11
  - **The MCP caller is the ambient actor for the whole call, tenant included.** `mcp.ts` wraps `callAdminTool` in a child context (a fresh root one over stdio); `resolveToken` and `adminActorOf` both carry `orgId`. Pinned in `mcp-context.test.ts`, `mcp-tenant.test.ts`.
12
- - **`defineAdmin({ entities, db })` is a served admin; a host writes nothing.** `routes.ts` gives EVERY route a screen (`screens.ts` — a `Record<AdminView, …>`, so a new view cannot mount with nothing behind it) and `AdminRouteConfig.respond(request)` is the only way in: GET or a posted form in, `{ status, body }` or a 303 out. A generated route's `component` was `null` and "the host binds an adapter" was a comment, so the deployed demo hand-wrote 1,781 lines of repo, screen glue and page files. `repo-entity.ts` is the ONE adapter from the typed handle to `AdminRepo`; it adds no predicate (tenancy, soft delete, sealing and invariants are the handle's) and never spreads a row. A resource with neither a table in `db` nor a `repo:` is `X_ADMIN_REPO_UNBOUND` at `defineAdmin`. Pinned in `mounted-screens.test.ts`, `repo-entity.test.ts`, `admin-bind.test.ts`.
12
+ - **`defineAdmin({ entities, db })` is a served admin; a host writes nothing.** `routes.ts` gives EVERY route a screen (`screens.ts` — a `Record<AdminView, …>`, so a new view cannot mount with nothing behind it) and `AdminRouteConfig.respond(request)` is the only way in: GET or a posted form in, `{ status, body }` or a 303 out. `repo-entity.ts` is the ONE adapter from the typed handle to `AdminRepo`; it adds no predicate (tenancy, soft delete, sealing and invariants are the handle's) and never spreads a row. A resource with neither a table in `db` nor a `repo:` is `X_ADMIN_REPO_UNBOUND` at `defineAdmin`. Pinned in `mounted-screens.test.ts`, `repo-entity.test.ts`, `admin-bind.test.ts`.
13
13
  - **A screen's guard is composed, never written.** A resource screen asks `crud.ts` FIRST and renders what it answered — only it holds the row a row-level rule reads. Every other screen (a `pages:` entry, dashboard, jobs, audit, search) is built through `guardedScreen()` (`screen-frame.tsx`): `decideAll` over the route's permissions, the refusal audited (`auditRefusal`), answered 403. `pages.ts` composes `[admin:read, …declared]`; `AdminPageProps.ctx` is required by the type; an empty permission list is `X_ADMIN_PAGE_UNGUARDED` at `defineAdmin`.
14
14
  - **No screen ships a script.** Every route is `ssr` + `hydrate: 'never'`, so a control is a link or a native form: `<Pagination hrefFor>`, `<DataTable sortHrefFor>`, scope tabs as links, the filter bar one GET form, a form posted at the URL that rendered it, an action posted at its ROW's URL (`_operation`). `form-decode.ts` is the one place posted strings become typed input, per widget — an empty box of a typed widget (money, number, instant, JSON) is `null`, never `''`.
15
- - **A list's state is its URL, and a parameter the resource does not derive is REFUSED.** `list-request.ts` is the one parser and builder — `?cursor=`, `?sort=<field>:<asc|desc>`, `?scope=<name>`, `?f.<field>[.<op>]=<value>` (repeat for `in`; empty = absent). `list-filters.ts` owns the operators per filter shape and the ONE validator (`checkedFilter`) the URL and the MCP list tool's `where` both pass. Unknown field, operator, sort, scope or parameter, or a value that is not the column's type: `X_ADMIN_FILTER_INVALID`, a 400 naming what the list answers — never the unfiltered list.
15
+ - **A list's state is its URL, and a parameter the resource does not derive is REFUSED.** `list-request.ts` is the one parser and builder — `?cursor=`, `?sort=<field>:<asc|desc>`, `?scope=<name>` (`*` = none, not even the default; undeclarable), `?f.<field>[.<op>]=<value>` (repeat for `in`; empty = absent). `list-filters.ts` owns the operators per filter shape and the ONE validator (`checkedFilter`) the URL and the MCP list tool's `where` both pass. Unknown field, operator, sort, scope or parameter, or a value that is not the column's type: `X_ADMIN_FILTER_INVALID`, a 400 naming what the list answers — never the unfiltered list.
16
16
  - **One composition of predicates** (`list-scope.ts`): row scope (`rows`), then scope, then filters. Every read of a resource builds its `where` there, and every read of ONE row is `findRow` — never `repo.find` from a screen, `crud.ts` or `mcp.ts` — so a row outside `rows(actor)` is "not found" on list, search, detail, edit, action, lookup, labels and MCP alike (`row-scope.test.ts`). App-declared predicates (`scopes`, `rows`) pass `declaredFilters`: any column, never a sealed one.
17
- - **A widget never reads a row.** What a page shows of the resources it references is read ONCE per target by `relations.ts` (`relationsFor`: one statement, two for a picked target over `LOOKUP_SELECT_MAX`) and handed down as `WidgetContext.labelFor` / `optionsFor` / `lookupHref`. A target the actor may not list is an id with no name. `list-relations.test.ts` asserts the statement count at the driver. The picker is server-rendered: a `<select>` for a small target, else an id box + the target's lookup screen (`/admin/<resource>/lookup`, `screen-lookup.tsx`, `return` held to the admin's own paths).
17
+ - **A widget never reads a row.** What a page shows of the resources it references is read ONCE per target by `relations.ts` (`relationDataFor`: one statement, two for a picked target over `LOOKUP_SELECT_MAX`) and handed down as `WidgetContext.labelFor` / `optionsFor` / `lookupHref`. A target the actor may not list is an id with no name. `list-relations.test.ts` asserts the statement count at the driver. The picker is server-rendered: a `<select>` for a small target, else an id box + the target's lookup screen (`/admin/<resource>/lookup`, `screen-lookup.tsx`, `return` held to the admin's own paths).
18
18
  - **A sealed column is never a filter, a scope or row-scope predicate, a label, a lookup key or a computed column's input** — `.sealed({ lookup: true })` included: a filter value rides a URL, and a URL is logged. `computedRow` drops sealed names from the row a column's `value` is handed.
19
19
  - **The tenant column is the handle's, never an input** (`crud-input.ts`): not a form field, a list column or a filter; a create is stamped with the ACTOR's tenant and a patch never carries the column.
20
20
  - **Computed columns are a closed renderer set** (`renderers.tsx`: `badge`, `relative-time`, `money`, `truncate`, `link`, `json`) or a component; `AdminComputedColumn` is typed over `AdminRow` so a resource stays covariant in `Row`.
21
+ - **A page seeks on the PAIR `(sort, id)`** (`keyset-seek.ts`): the rest of the boundary's class by id, then past it, then the NULLs (last ascending, first descending), so any number of ties and a nullable sort column are positions; the cursor carries `null`, never `''`. An instant's class is its MILLISECOND — a row's `Date` drops Postgres' microseconds — read whole by id. `repo-entity-keyset(.contract).test.ts` walks to the end and back.
21
22
  - **A read by a mistyped id is a row that does not exist** — `repo-entity.ts`'s `find` answers `null` (the admin's 404); a write keeps the throw. An instant compared by the handle is a `Date`, never ISO text: the memory driver orders a string against a `Date` by its characters.
22
23
  - **Every admin document is `noindex, nofollow`**, a refusal and a 404 included (`route-config.ts`).
23
- - **A sealed column is written, never read.** `adminColumnsOf` skips `$meta.sealed`, so no field any screen reads by name exists for it; `resource.secretFields` is a list of its own that only the form (a password input, never a `value`) and `crud.ts` (`splitSecrets`: around `$schema`, empty = unchanged) touch. The audit diff is derived from that list, not from what a row enumerates. Pinned in `sealed-egress.test.ts`.
24
- - **The mount registry is on the global symbol registry** (`mounts.ts`, `Symbol.for('ultimate.admin.mounts')`), so the CLI can ask "did this app declare an admin?" without importing this package — importing it loads every screen and registers its stylesheets for an app that has none.
24
+ - **A sealed column is written, never read.** `adminColumnsOf` skips `$meta.sealed`, so no field any screen reads by name exists for it; `resource.secretFields` is touched only by the form (a password input, never a `value`) and `crud.ts` (`splitSecrets`: empty = unchanged). The audit diff is derived from that list, not from what a row enumerates. Pinned in `sealed-egress.test.ts`.
25
+ - **The mount registry is on the global symbol registry** (`mounts.ts`, `Symbol.for('ultimate.admin.mounts')`), so the CLI can ask "did this app declare an admin?" without importing this package.
25
26
  - **One `AdminAction.name`, one handler** — the MCP tool name, the default label key and `callAdminTool`'s lookup. A duplicate is `X_ADMIN_ACTION_DUPLICATE` at `defineAdmin`; identity, not name, is what "already seen" means.
26
27
  - **A `/_x` source reads a registry through its DESCRIPTOR TYPE, never as an untyped bag** — `import type { RouteDescriptor }` beside the dynamic `import()`; `dev/published-keys.test.ts` walks what the registries EMIT. A route fact carries `mount` (`{ by, permissions }` or `null`).
27
- - **A fact `/_x` cannot know is absent or `null` — never a value that happens to be constant.** `hasMeta` was dropped because `defineRoute()` refuses a route with no `meta`, so `missingMeta` was a defect list with no member it could hold; `JobDescriptor.idempotent` is published *because* `job()` refuses a definition without a key — a guarantee shown where the question is asked. `DbPanelData.drift` is `null` when nothing wired the check, because drift is the entities against the DATABASE and `[]` claims a match nobody verified.
28
- - **The routes are in the framework's ONE route list, as mounted routes** — `route-config.ts` calls `registerMountedRoutes` at `defineAdmin`, so `describeRoutes()` (`x routes`, `/_x`, `sw.js`, the manifest) lists each with `mount: { by: 'defineAdmin', permissions }` and `routeEntries()` holds none. `AdminApp.describe()` (`describe.ts`) is what the manifest's `admin` section records: filters, sorts, scopes, `rowScoped`, routes.
29
- - **The admin's stylesheets are claimed for `app/`** (`stylesheet-scope.ts`, `claimStylesheets`): a package sheet nobody claims rides both surface bundles, and a static `site/` document carried the dashboard's CSS. `stylesheet-scope.test.ts`.
30
- - **One bridge per foreign package.** `policy-bridge.ts` is the only file calling `evaluate`/`definePermissions`; `route-config.ts` the only one calling `defineRoute`/`registerMountedRoutes`; `mcp.ts` the only one calling `defineAppMcp`; `dev/data.ts` the only one importing introspection (dynamically — `/_x` must stay out of the production graph). Source only: a **test** declares the permissions its own `can()` fixtures use, because `definePermissions` writes a process-global registry and `bun test` seats several files in one process — `dev/data.test.ts` relied on the empty-registry-means-permissive fallback and went red the first time it shared a process with anything that imports this package.
28
+ - **A fact `/_x` cannot know is absent or `null` — never a value that happens to be constant.** `JobDescriptor.idempotent` is published *because* `job()` refuses a definition without a key. `DbPanelData.drift` is `null` when nothing wired the check, because drift is the entities against the DATABASE and `[]` claims a match nobody verified.
29
+ - **The routes are in the framework's ONE route list, as mounted routes** — `route-config.ts` calls `registerMountedRoutes` at `defineAdmin`, so `describePages()` (`x routes`, `/_x`, `sw.js`, the manifest) lists each with `mount: { by: 'defineAdmin', permissions }` and `routeEntries()` holds none. `AdminApp.describe()` (`describe.ts`) is what the manifest's `admin` section records: filters, sorts, scopes, `rowScoped`, routes.
30
+ - **The admin's stylesheets are claimed for `app/`** (`stylesheet-scope.ts`, `claimStylesheets`). `stylesheet-scope.test.ts`.
31
+ - **Importing this package declares NO permission.** `defineAdmin()` calls `declareAdminPermissions`, which registers `ADMIN_PERMISSIONS` plus what the mount derives (`policy-bridge.test.ts`).
32
+ - **One bridge per foreign package.** `policy-bridge.ts` → `evaluate`/`definePermissions`; `route-config.ts` → `defineRoute`/`registerMountedRoutes`; `mcp.ts` → `defineAppMcp`; `dev/data.ts` → introspection, dynamically. A test declares its own fixtures' permissions and calls `defineAdmin()` in `beforeAll`, never at module scope.
31
33
  - **One entity surface** (`registry.ts`) — the admin reads what `entity()` actually exposes: `$name`, `$primaryKey`, `$columns[c].$meta`, `$schema`, `$describe()`. It is a structural subset so a new column kind still derives, and `RegisteredEntity` is the `tsc`-checked proof that a real `entity()` result satisfies it — never a comment claiming it does.
32
34
  - **One flattener.** `entity-columns.ts` is the only file that reads `$meta` or calls `$describe()`; everything downstream takes `AdminColumnFacts`. Money stays one property (the admin renders rows, not tables), a FK target comes back resolved from `$describe()`, and only a **generated** default (`uuid`, `now`) is read-only — `.default('free')` is a starting value.
33
- - **A deliberate query loop declares itself.** `search.ts` runs one indexed lookup per text field on purpose — the query IR is a conjunction, so three small indexed reads beat one unindexed `OR` — and says so through `expectedQueryLoop()` from `@ultimat3/db` (tier 1, downward, and the only thing this package imports from it). That is the one suppression mechanism: never a comment pragma and never a list of exempt call sites. A new loop here either argues for itself in a `reason` or it is an N+1.
35
+ - **A deliberate query loop declares itself** — `expectedQueryLoop()` from `@ultimat3/db`, with a `reason` (`search.ts`, `batch.ts`, `relations.ts`). Never a comment pragma, never an exempt list; an undeclared loop is an N+1.
34
36
  - **The timeline panel renders the detector's verdicts** (`DevSources.statementLoops()`), never a second count; none wired is `null`, never `[]`.
35
- - **This catalog is the framework's one opt-OUT MCP surface, and that is deliberate.**
36
- `mcp-tools.ts` is the only file that does not call `isMcpExposed` from `@ultimat3/core`: every
37
- tool here is already gated on an admin permission, and the CRUD tools carry no `mcp` block at
38
- all, so opting in would list `admin.posts.delete` while hiding the action button beside it.
39
- `mcp: { expose: false }` withdraws one. Stated there, in core's `mcp-exposure.ts`, and in
40
- `wiki/Admin-Dashboard.md` — a *second* exception anywhere is the bug.
37
+ - **This catalog is the framework's one opt-OUT MCP surface, deliberately**: `mcp-tools.ts` does not call `isMcpExposed` — every tool is already gated on an admin permission. `mcp: { expose: false }` withdraws one. A *second* exception anywhere is the bug.
41
38
  - **`dev/panel-db.ts`'s `sanitize` decides "did they type a statement", not "is it safe"** — never cite it as a security property.
42
- - **A dev panel catches only the error that means "not wired".** `DevSourceUnavailableError` and nothing wider: a bare `catch` in `dev/panel-live.ts` reported an authz refusal and a dropped NATS connection from a *running* sync node as `dev.live.no-sync-node`, telling the reader to install a tier they already had. Everything else reaches `panelPayload`, which renders its code and its fix.
43
- - **What an entity does not declare, the admin does not invent.** `sensitive`, a fixed `currency` and `labelField` come from `AdminResourceOptions` or they are absent. Same rule for a URL: the reference widget links through `WidgetContext.hrefFor` or renders plain text — it used to build `/${entity}s/${id}`, which is English pluralisation by concatenation and drops `basePath`. The route table is `AdminApp`'s; a widget three layers down does not get to guess it.
39
+ - **A dev panel catches only the error that means "not wired".** `DevSourceUnavailableError` and nothing wider — in `panel-live`, `panel-db`, `panel-timeline` and `panel-cache` alike (`dev/panel-degrade.test.ts`). Everything else reaches `panelPayload`, which renders its code and its fix.
40
+ - **What an entity does not declare, the admin does not invent.** `sensitive`, a fixed `currency` and `labelField` come from `AdminResourceOptions` or they are absent. Same rule for a URL: the reference widget links through `WidgetContext.hrefFor` or renders plain text — the route table is `AdminApp`'s.
44
41
  - **`panel-db.ts` executes the string `assertReadOnlyQuery` RETURNS**, never the textarea's bytes (`ReadOnlyVerdict`).
45
42
  - **`decideAll([])` DENIES** (`admin.policy.none-declared`): an empty gate is a refusal for everyone, never a grant.
46
43
  - **Two resources may not claim one `path:`** — `X_ADMIN_PAGE_PATH_INVALID` (`subject: 'resource'`) at `defineAdmin`, every generated path included.
47
44
  - **The audit diff is TOTAL over every value a row holds** — compared with core's `canonicalJson`, never `JSON.stringify` (a `money()` bigint throws there, after the write committed).
48
- - **A write that does not happen leaves a `failed` entry** (`crud-outcome.ts`): a repo that throws (`auditedWrite` — try, record, re-throw UNCHANGED), an input the schema refused, a row that was not there. The reason is a key, never anything read off a thrown value.
49
- - **Nothing is read off a caught value in `action-gate.ts`, and the append runs first.**
45
+ - **A write and its entry commit together** (`atomicallyAudited`, `crud-outcome.ts`): a CRUD write, an action's handler, `matching` and a queued batch's enqueues each run inside `audit.atomic` with their `allowed` entry, so an entry that cannot be written rolls the work back; ONE `failed` entry is appended after, outside, and the thrown value is re-thrown UNCHANGED with nothing read off it. A schema refusal and a missing row are `failed` too. `audit-atomic(.contract).test.ts`.
46
+ - **An edit is optimistic** (`row-version.ts`): the form posts `_version` = `rowVersion(row)` (keyed), `adminUpdate(…, { version })` answers `stale` — 409, `admin.error.row-changed`, the form re-drawn over the row as it is now — when the row moved on; a post with no version is stale. An instant posted back as it was drawn (`datetimeInputValue`) is LEFT OUT of the patch (`decodeForm(…, before)`): `datetime-local` holds minutes. A create or update is decided again on the validated values as `subject.input` (update: beside `row`).
47
+ - **The audit trail is the actor's tenant's**: `/admin/audit` and the history card pass `orgId` to `entries()` when the actor has one (`audit-tenancy.test.ts`).
50
48
  - **A table read by a runtime key is `Object.hasOwn` / a `Map`, never a bare index** — `impliedBy`, the `/_x` counters, `list-filters.ts`' operator table, `renderers.tsx` (`bun run proto-index`).
49
+ - **The `/_x` SQL runs in `readOnlyQuery`** (`dev/sql-runner.ts`, `readOnlySql(client)` — what a host wires as `runSql`): read-only transaction, timeout, `DEV_SQL_MAX_ROWS`.
51
50
  - **One read-only SQL guard, and it is `@ultimat3/mcp`'s** — no local keyword scan; the panel keeps only the emptiness test and the way out (`psql "$DATABASE_URL"`, the developer's own client — `x db` has no `psql`).
52
- - **Every admin operation is audited, reads included.** `adminList` was the one call that logged nothing in either direction; `ListResult` now carries its `AuditEntry` on both branches, keyed on the table (`entityId: null`), because the subject of a listing is not a row. `adminSearch` was the second, and it read rows out of EVERY readable entity: `AdminSearchResult.audit` now carries one entry per resource it decided about — `allowed` per searched resource, a `deniedDraft` per refused one. A resource skipped for having no text field or no repo is not an authz event and writes none.
51
+ - **Every admin operation is audited, reads included**, not `/admin`'s counts (a count reads no row). `ListResult` carries its `AuditEntry` on both branches (`entityId: null`); `AdminSearchResult.audit` one entry per resource decided, and a skipped resource (no text field, no repo, a repo that threw) is in `skipped`, never a 500.
53
52
  - **A write's RESULT satisfies the actor's row scope** (`row-scope-write.ts`): a create, and an update over the fields it touches, outside `rows(actor)` is refused before the repo — `admin.error.row-out-of-scope`, audited as a denial. The one predicate evaluator in this package; an ordering over text is refused, never guessed. `row-scope-write.test.ts`.
54
53
  - **An action's `when(row)` is asked by the button AND by the call** (`actionApplies`, `action-gate.ts`): a row it excludes is `X_ADMIN_ACTION_NOT_APPLICABLE` (409 on a screen, the code over MCP), audited `admin.error.action-not-applicable`. The row it reads carries no sealed column. Every action on one row runs through `invokeRowAction` (`findRow` first).
55
54
  - **An action with an `input` schema is a form, never a bare post**: its button links to `<row>?action=<name>`, the form posts `input.<field>` (`action-input.ts`, through `tryIntrospect`), the gate validates against the schema and a refusal is a 422 with issues on their fields. No schema: a confirm only.
56
- - **A batch is the button's gate once per row** (`batch.ts`): policy, `when`, handler; counts `done`/`refused`/`failed`/`queued`/`remaining`; one audit entry per row. Selection is checked ids or "all matching" — the list URL's own `listWhere`, keyset by id, `MAX_BATCH_ROWS` inline / `MAX_BATCH_QUEUED_ROWS` queued per request. `batch: { threshold }` queues one `admin.batch` job per chunk (`batch-job.ts`), declared at `defineAdmin`; the worker runs it AS the operator. Zero JS: the bar is one form (`batch-bar.tsx`), row checkboxes join it by `form=`; a destructive or input-taking batch is a server round trip. ONE MCP tool per action — a batch action's tool takes `ids`.
55
+ - **A batch is the button's gate once per row** (`batch.ts`): policy, `when`, handler, one audit entry each. Checked ids or "all matching" (`listWhere`, keyset by id, `MAX_BATCH_ROWS` / `MAX_BATCH_QUEUED_ROWS`). `batch: { threshold }` queues `admin.batch` jobs under a derived `batchIdOf`, run AS the operator. Zero JS (`batch-bar.tsx`). ONE MCP tool per action — a batch action's tool takes `ids`.
57
56
  - **Sections and form groups never hide a field** (`resource-layout.ts`): undeclared fields fall into a default group drawn last; a name that is not a drawn field, or claimed twice, is refused at `defineAdmin`. `on: 'create' | 'update'` is enforced on the write too (`onlyFor`, `crud-input.ts`).
58
57
  - **A related list IS the related resource's list** (`related.ts`): a `hasMany` of the entity, read through `adminList` (its gate, its `rows`), filtered to this row; a resource the actor may not list is left out. One label read per target for the whole detail page.
59
58
  - **The audit log is the store, a sink is a copy.** `AuditLog.entries()` is async, keyset (`before`), `changes: true` drops allowed reads; `atomic(run)` puts a write and its entry in one transaction (`auditedWrite`). `postgresAuditLog()` writes `x_admin_audit` (`audit-pg.ts`), skips allowed reads unless `reads: true`. `audit-pg.contract.test.ts` boots PGlite.
60
- - **The jobs dashboard is every admin's, built from these pieces only** (`jobs/`): four resources — `x_jobs`, `x_job_queues`, `x_job_tasks`, `x_job_workers` — each with a `repo:` over `JobIntrospection` (`job-repo.ts`, `fleet-repo.ts`), scopes, a row scope and `job-actions.ts`'s `AdminAction`s, plus ONE screen (`overview.tsx`, the view `jobs`). `job:read` lists them (the resources' `permission` noun is `job`), `job:manage` runs every action. An actor with an `orgId` sees that org's job rows and none of the fleet. `jobs/boundary.test.ts` refuses a `fetch(`, a socket, an event stream or a hand-written table there; `/_x/jobs` draws `JobsOverview` over the dev queue (`dev/panel-jobs.ts` → `jobs/overview-tab.tsx`, dynamically).
59
+ - **The jobs dashboard is every admin's, built from these pieces only** (`jobs/`): resources `x_jobs`, `x_job_queues`, `x_job_tasks`, `x_job_workers` with `repo:`s over `JobIntrospection`, scopes, a row scope and `job-actions.ts`, plus ONE screen (`overview.tsx`). `job:read` lists, `job:manage` acts; an `orgId` actor sees its org's jobs and none of the fleet. `jobs/boundary.test.ts`.
61
60
  - **A job-store predicate the store cannot answer is refused by name** (`job-where.ts`, `X_ADMIN_FILTER_INVALID`): one order (`createdAt desc`), equality on state/queue/name/tenant, an id PREFIX for the id box. `listScreen` answers a repo's refusal 400, as the URL's. "All matching" over jobs is `AdminAction.matching` onto the store's bulk verbs, never a walk.
62
61
  - **A resource's permissions are named after its `permission` noun** (`resource.permission`, default the entity name) — every `permissionsForOperation` site reads it, never `resource.name`.
62
+ - **An action's admin gate is ONE function** (`adminPermissionForAction`, `action-gate.ts`): `destructive` → `admin:destroy`; `matching` → `admin:write`, `readonly` beside it or not — it changes every row the list matches; `readonly` → `admin:read`; else `admin:write`. A contradiction fails closed. The same value is its MCP tool's `scope`. `action-gate.test.ts`.
63
+ - **Every admin MCP tool carries ONE scope** — `admin:read`, `admin:write` or `admin:destroy`, by what it does (`mcp-tools.ts`); `adminToolScopes` hands that cut to `defineAppMcp({ scopes })`. A token's own scopes are `AdminMcpActor.tokenScopes` (not `scopes`: core's `Actor.scopes` is another thing), expanded `destroy ⇒ write ⇒ read` by `adminTokenScopes` (`mcp-scopes.ts`). **Unstated = full strength** (the actor's); **`[]` = granted nothing**, every tool refused `X_MCP_SCOPE_DENIED`; a non-admin scope is dropped. The scope gate runs before the actor's policy, which still decides. `mcp-scopes.test.ts`.
64
+ - **The `/admin` home counts only what opted in** — `count: true` on the resource (`screen-home-counts.ts`): one `count()` over `rowWhere(actor)` per visit, so it is never a default. Only resources the actor may list; a repo with no `count()`, or one that throws, has no tile — never a zero.
63
65
  - **`matching` is "all matching" as ONE call** (`batch-matching.ts`): the list's `where`, gated by the batch's grant, confirmed as `<entity>:all matching`, audited once (`admin.batch.matching`). A checked selection never reaches it.
64
- - **An action on a row the actor cannot see is refused, `when` or none** (`actionApplies`: a `null` row is no row). Without this, an action declaring no `when` ran its handler on an id outside the actor's `rows`.
66
+ - **An action on a row the actor cannot see is refused, `when` or none** (`actionApplies`: a `null` row is no row).
65
67
  - **An action's input field is labelled `admin.input.<action>.<field>`** — never under `admin.action.<action>`, which is a leaf: a JSON catalog cannot hold both.
66
68
  - **`x_admin_audit`'s DDL is the leaf `audit-schema.ts`** (`@ultimat3/admin/schema`): the boot applies it in every role; `serve-graph.test.ts` exempts exactly that file.
67
- - Money through `assertMoney`, timestamps through `assertZone`, pagination through `pagination.ts`. No `offset`, ever.
69
+ - Money via `assertMoney`, timestamps via `assertZone`, pagination via `pagination.ts`. No `offset`, ever.
68
70
  - **One cursor codec.** `pagination.ts` is the only file calling core's `encodeCursor`/`decodeCursor`; it wraps them as `encodeAdminCursor`/`decodeAdminCursor`, scoped `admin:<resource>`. An invalid cursor is page one here, not an error page — but the signature is checked first, so a forged one cannot seek.
69
71
  - Labels are i18n keys derived in `resource.ts` (`admin.<entity>.field.<name>`); only `.tsx` calls `t()`. An action's is `actionLabelKey` (`action-label.ts`), never respelled. `AdminApp.catalogKeys()` (`catalog-keys.ts`) is every derived key — the CLI's `i18n` step audits exactly that list. MCP tool descriptions are literal English (protocol payload, not UI copy).
70
72
  - Colours only via `ThemeTokenRef` (`--x-*`). Raw hex does not typecheck.
71
73
  - **One owner of `globalThis.React` is `@ultimat3/ui`'s** (`probe`/`unprobe`); `inert-jsx.ts` keeps only the walkers. `inert-jsx.test.ts` drives both install orders.
72
74
  - **A `/_x` panel orders by CODE UNIT, never `localeCompare`**; **the locale picker reads `registeredLocales()`**, never a bundled list.
73
- - Views are pure functions of props — no `createSignal`, no local state, no event handler.
74
- - **No test reaches a `.tsx` statically.** `routes.ts` reaches the screens, so a test does `await import('@ultimat3/render/server')` and then `await import('./routes')`: a static import compiles JSX before the loader exists and every later render dies with `React is not defined`.
75
+ - Views are pure functions of props: no `createSignal`, local state or event handler.
76
+ - **No test reaches a `.tsx` statically**: `await import('@ultimat3/render/server')`, then `await import('./routes')`.
75
77
 
76
78
  ## Layout
77
79
 
@@ -84,15 +86,15 @@ Two products, one package, **two entry points**: `@ultimat3/admin/dev` (`src/dev
84
86
  | `list-request.ts` / `list-filters.ts` / `list-scope.ts` | URL ⇄ list request / operators per shape + the validator / row scope → scope → filters, `findRow`, `scopeCounts` |
85
87
  | `list.tsx` / `list-filter-bar.tsx` / `list-scope-tabs.tsx` / `renderers.tsx` | the list view / one GET form / tabs as links / computed cells |
86
88
  | `resource-list.ts` / `relations.ts` / `describe.ts` | `scopes`·`rows`·`columns` normalised / labels + lookup, one read per target / the admin as manifest data |
87
- | `repo-entity.ts` / `form-decode.ts` | typed handle → `AdminRepo` / posted form → typed input |
89
+ | `repo-entity.ts` / `keyset-seek.ts` / `form-decode.ts` / `row-version.ts` | typed handle → `AdminRepo` / the `(sort, id)` seek / posted form → typed input / an edit's version |
88
90
  | `registry.ts` / `entity-columns.ts` | the entity surface the admin reads / one entity → column facts |
89
91
  | `resource.ts` / `fields.ts` / `widget-value.ts` | derivation, widget table, value guards |
90
92
  | `crud.ts` / `crud-input.ts` / `crud-outcome.ts` / `action-gate.ts` / `row-scope-write.ts` | policy → confirmation → validation → repo → audit / secrets, tenant, sides, diff / the entry of a write that did not happen / a write's result vs `rows` |
91
93
  | `action-row.ts` / `action-input.ts` / `action-form.tsx` / `screen-action.tsx` | one action on one row / input schema ⇄ form fields / the action's form / row-action and batch writes |
92
- | `batch.ts` / `batch-bar.tsx` / `batch-job.ts` | a batch through the gate / the bar and row checkboxes / the `admin.batch` job |
94
+ | `batch.ts` / `batch-queue.ts` / `batch-bar.tsx` / `batch-job.ts` | a batch through the gate / its queued half / the bar and row checkboxes / the `admin.batch` job |
93
95
  | `resource-fields.ts` / `resource-layout.ts` / `related.ts` / `list-columns.tsx` | one column → one field / sections and form groups / related lists / a resource's table columns |
94
96
  | `audit.ts` / `audit-pg.ts` / `json-text.ts` | the log interface + memory log / the Postgres log / readable JSON for a cell or a diff |
95
- | `mcp-tools.ts` / `mcp.ts` | tool derivation (pure) / transport wiring |
97
+ | `mcp-tools.ts` / `mcp-scopes.ts` / `mcp.ts` | tool derivation, each tool's scope (pure) / scope → tools for `defineAppMcp`, a token's expanded scopes / transport wiring |
96
98
  | `test-ctx.ts` | `adminTestCtx({ actor?, granted? })` — the `CrudCtx` a test hands a page or a CRUD call; `staticAuthz` over a grant list, its own audit log. What `x g admin:page`'s emitted render test calls |
97
99
  | `jobs/` | the jobs dashboard: entities, repos, the where translation, actions, labels, resources, the overview and its `/_x` tab |
98
100
  | `batch-matching.ts` / `audit-schema.ts` | a set-based "all matching" / the audit table's DDL, a leaf |
package/README.md CHANGED
@@ -8,7 +8,7 @@
8
8
  | Audience | you, debugging the framework | your operators, and their agents |
9
9
  | Environment | development **only** — mounting it with `env=production` or `role=production` throws `X_DEV_DASHBOARD_IN_PROD` | production |
10
10
  | Authz | none: it is your own machine | the app's policies, one decision per surface |
11
- | Data | introspection calls (`describeRoutes`, `inspect`, `dependentsOf`, …) | the entity registry + repos |
11
+ | Data | introspection calls (`describePages`, `inspect`, `dependentsOf`, …) | the entity registry + repos |
12
12
  | Shipped in the app image | never mounted | mounted at `/admin` |
13
13
 
14
14
  ## `/_x` panels
@@ -18,10 +18,10 @@ One panel per file. Each kills one question, and each is available as `--json`
18
18
  | Panel | Kills |
19
19
  |---|---|
20
20
  | `routes` | which handler serves this? — render mode, offline strategy, revalidate tags, budget |
21
- | `timeline` | where did the time go? — flamegraph of SQL, cache, action, policy spans + the N+1 count |
21
+ | `timeline` | where did the time go? — a server-rendered waterfall of SQL, cache, action, policy spans (one row per span, bar placed by the payload's own `offset`/`width`), time per kind, and the N+1 detector's verdicts with their fix lines; the payload folded under it |
22
22
  | `live` | what does each subscriber receive, and **why** — the matcher's decision trace |
23
- | `jobs` | the admin's own jobs overview (`/admin/jobs`'s component) over this process's queue; the payload — queue depth, step traces, retry-from-step target, dead letter — folded under it |
24
- | `db` | psql in a tab (read-only; `assertReadOnly` refuses DML), schema + drift (`null` unless a host wires the check) |
23
+ | `jobs` | a ring of the recent runs by state (`DonutChart`), then the admin's own jobs overview (`/admin/jobs`'s component) over this process's queue; the payload — queue depth, step traces, retry-from-step target, dead letter — folded under it |
24
+ | `db` | psql in a tab (read-only twice: `assertReadOnly` refuses DML, and `readOnlySql(client)` — what a host wires as `runSql` — runs it in a `BEGIN READ ONLY` transaction with a timeout and `DEV_SQL_MAX_ROWS`), schema + drift (`null` unless a host wires the check) |
25
25
  | `mail` | caught mail, rendered, per locale, with the locale gaps listed |
26
26
  | `cache` | the tag graph — what invalidated what, and which tags are orphans |
27
27
  | `policy` | the permission matrix per actor, every cell carrying its trace |
@@ -36,6 +36,8 @@ const authz = staticAuthz(['admin:read']);
36
36
  const actors = [{ id: 'dev-admin', roles: ['admin'] }];
37
37
 
38
38
  const dev = devDashboard({ sources: defaultDevSources({ authz, actors }) }); // throws in prod
39
+ // A drawn tab renders ui's components; their rules are in the app surface's stylesheet, so a host
40
+ // names its URL: devDashboard({ …, stylesheetHref: () => styleBundle().hrefFor('app') })
39
41
  const response = await dev.handle(request); // null when the path is not /_x
40
42
  await dev.json('jobs'); // the same payload /_x/jobs renders from
41
43
  ```
@@ -62,12 +64,22 @@ export const admin = defineAdmin({ entities: [posts, users], db });
62
64
  `/admin/posts/<id>`, `/admin/posts/<id>/edit`, plus `/admin/search`, `/admin/audit` and the jobs
63
65
  dashboard (`/admin/jobs`, below).
64
66
 
67
+ `/admin` itself opens on a KPI row — one `StatTile` per resource the actor may list AND that
68
+ declared `resources.<entity>.count: true`, its figure that resource's `AdminRepo.count()` over its
69
+ `rows` scope, each tile a link to the list — and a `DonutChart` of the same counts, then the
70
+ permission matrix per resource. Opt-in, as a scope tab's `count` is: a tile is one full `count()`
71
+ per visit, so an operator reloading the front page over a big table is a load generator. A resource
72
+ the actor may not list has no tile, no segment and no matrix; one that did not opt in, a repo with no
73
+ `count()`, or one whose count the store refuses (`X_TENANCY_UNSCOPED` for an actor with no org), has
74
+ no tile and never fails the page. A count reads no row, so it is not audited. Each matrix is a
75
+ closed `<details>` whose summary reads "N of M allowed".
76
+
65
77
  | Input | What it is | Omitted |
66
78
  |---|---|---|
67
79
  | `entities` | the objects `entity()` returned — `adminEntitiesOf(db)` is every entity the handle serves | required |
68
80
  | `db` | the app's `database()` handle. Each resource reads through its own table: tenancy, soft delete, sealing and invariants are the handle's, and the admin adds no predicate | `resources.<entity>.repo` per resource, or `X_ADMIN_REPO_UNBOUND` at declaration |
69
- | `resources` | per-entity overrides — `listFields`, `labelField`, `columns`, `scopes`, `rows`, `sections`, `formGroups`, `related`, `fields.<name>.{sensitive,hintKey,on}`, `operations`, `repo`, `permission` (the noun its operations are named after; default the entity's name) | derived |
70
- | `actions` | `AdminAction[]` — a `permission` (never optional) plus a handler; optional `input` schema, `when(row)`, `batch` | none |
81
+ | `resources` | per-entity overrides — `listFields`, `labelField`, `columns`, `scopes`, `count` (a tile on `/admin`), `rows`, `sections`, `formGroups`, `related`, `fields.<name>.{sensitive,hintKey,on}`, `operations`, `repo`, `permission` (the noun its operations are named after; default the entity's name) | derived |
82
+ | `actions` | `AdminAction[]` — a `permission` (never optional) plus a handler; optional `input` schema, `when(row)`, `batch`, `readonly`, `destructive` | none |
71
83
  | `pages` | custom screens, see below | none |
72
84
  | `auth.actor` | who is acting | the actor the HTTP pipeline resolved for the request |
73
85
  | `auth.authz` | the one decision path | `roleAuthz()` — the role map decides each permission by name |
@@ -91,11 +103,11 @@ The list's whole state is its URL — one parser and one builder (`pageRequestOf
91
103
 
92
104
  | Parameter | Means | Refused when |
93
105
  |---|---|---|
94
- | `?cursor=<signed>` | keyset position | never — an unreadable cursor is page one |
106
+ | `?cursor=<signed>` | keyset position on `(sort, id)` — any number of tied values, and NULLs (last ascending, first descending), are reached | never — an unreadable cursor is page one |
95
107
  | `?sort=<field>:<asc\|desc>` | order; a bare field is `desc` | the field is not sortable, or the direction is neither |
96
- | `?scope=<name>` | a declared scope; absent is the default one | no scope has that name |
108
+ | `?scope=<name>` | a declared scope; absent is the default one; `*` is NO scope, not even the default (a related card's "all" link) | no scope has that name; `*` cannot be declared |
97
109
  | `?f.<field>=<value>` | a filter, by the field's default operator | the field is not a filter of the resource |
98
- | `?f.<field>.<op>=<value>` | a named operator; repeat the parameter for `in` | the field's shape does not answer `<op>`, or the value is not the column's type |
110
+ | `?f.<field>.<op>=<value>` | a named operator; repeat the parameter for `in` | the field's shape does not answer `<op>`, or the value is not the column's type — a number is decimal digits (no blank, hex or exponent), a date is one the calendar has |
99
111
  | anything else | — | always |
100
112
 
101
113
  A refusal is `X_ADMIN_FILTER_INVALID`, a 400 naming what the list does answer — never the
@@ -179,8 +191,9 @@ export const resources: Readonly<Record<string, AdminResourceOptions>> = {
179
191
  | `on` | the field is an input on one side only, and a write from the other side drops it |
180
192
  | `hintKey` | the line under the control, through `t()` |
181
193
 
182
- The detail page's history card is `audit.entries({ entity, entityId, changes: true })`, newest
183
- first, 20 per page, older pages by keyset (`?history=`). Statements a detail page issues: 1 for
194
+ The detail page's history card is `audit.entries({ entity, entityId, changes: true })` — and
195
+ `orgId` when the actor has one, as on `/admin/audit` — newest first, 20 per page, older pages by
196
+ keyset (`?history=`). Statements a detail page issues: 1 for
184
197
  the row (2 with `rows`), 1 per related list, 1 per referenced target for the whole page, 1 for
185
198
  the history with `postgresAuditLog`.
186
199
 
@@ -210,7 +223,8 @@ export const suspendUser: AdminAction = {
210
223
  | `batch` | the list's batch bar — checked rows, or every row the list's URL matches — each row through the button's gate and on the audit log, answered with `done` / `refused` / `failed` / `queued` / `remaining`. 200 rows inline per request (`MAX_BATCH_ROWS`), 1,000 queued (`MAX_BATCH_QUEUED_ROWS`); "all matching" continues from where it stopped |
211
224
  | `matching` | "all matching" as ONE set-based call: handed the list's `where` (row scope, scope, filters), it answers `{ affected, remaining }` — a store's bounded bulk verb. Decided and audited once (`admin.batch.matching`); a destructive one types `<entity>:all matching`. Checked rows still run row by row. Requires `batch` |
212
225
  | `batch.threshold` | past it, one `admin.batch` job per `chunk` (default: the threshold), run by a worker as the operator who queued it. The worker must load the module calling `defineAdmin` (`X_ADMIN_MOUNT_MISSING`) |
213
- | destructive | one row: type `<entity>:<id>`; a batch: type `<entity>:<n> rows` |
226
+ | `readonly` | the action changes nothing (an export, a verification): its admin-level gate is `admin:read`, not `admin:write`, so a read-only staff role and an `admin:read` MCP token may run it. `destructive` (`admin:destroy`) and `matching` (`admin:write`) win over it |
227
+ | destructive | gated on `admin:destroy`; one row: type `<entity>:<id>`; a batch: type `<entity>:<n> rows` |
214
228
  | MCP | still ONE tool, `admin.action.<name>`; a batch action's takes `ids: [...]` |
215
229
  | `labelKey` | the button's, the form's and the batch bar's label; absent: `admin.action.<name>` |
216
230
 
@@ -249,12 +263,15 @@ admin's own projection. `/_x/jobs` draws the same overview component over the de
249
263
  ### The audit log
250
264
 
251
265
  `postgresAuditLog()` writes one row per audited write to `x_admin_audit`, in the same
252
- transaction as the write (`AuditLog.atomic`), and reads it back by keyset. An allowed READ is not
266
+ transaction as the write (`AuditLog.atomic`) — a CRUD write, an action's handler, a `matching`
267
+ set and a queued batch's enqueues alike, so an entry that cannot be written rolls the work back
268
+ and the log holds one `failed` entry — and reads it back by keyset. An allowed READ is not
253
269
  written unless `reads: true` — a refused one always is. Sealed and `sensitive` values reach the
254
- table only as `[redacted]`. `memoryAuditLog()` is a ring that forgets at every restart. The
270
+ table only as `[redacted]` — `@ultimat3/core`'s `REDACTED`, the logger's marker too. `memoryAuditLog()` is a ring that forgets at every restart. The
255
271
  table is applied at boot with every other framework table (`@ultimat3/cli`'s `FRAMEWORK_SCHEMA`),
256
272
  from the leaf module `@ultimat3/admin/schema` (`SQL_ADMIN_AUDIT_TABLE`, `ADMIN_AUDIT_TABLE`) —
257
- no migration, no screen loaded to install it.
273
+ no migration, no screen loaded to install it. That subpath is their one import path: 25.0.0 took
274
+ them off the barrel.
258
275
 
259
276
  ### A sealed column
260
277
 
@@ -406,6 +423,7 @@ shape already is, written down once so two apps do not invent two layouts. `x g
406
423
  | A button an actor cannot press is never rendered, and the call is refused by the same decision | `action-gate.ts` |
407
424
  | Destructive operations re-confirm (`<entity>:<id>`) and are always audited | `permissions.ts`, `crud.ts` |
408
425
  | Every mutation and every denial is on the audit log, with a before/after diff | `audit.ts` |
426
+ | An edit carries the row's version (`_version`); a row changed since the form was drawn is a 409, never overwritten. An instant posted back as drawn is left out of the patch | `row-version.ts`, `form-decode.ts` |
409
427
  | Branding aliases tokens only — `accent: '#7c3aed'` is a compile error | `theme.ts` |
410
428
 
411
429
  Reads are audited too, and on both branches: `adminDetail` keys its entry on the row,
@@ -424,6 +442,23 @@ export const mcp = adminMcp({ app: admin, actor: (session) => actorFor(session.t
424
442
  adminMcpTools(admin, ctx); // admin.post.list · admin.post.read · admin.search · admin.action.post.publish
425
443
  ```
426
444
 
445
+ Every tool carries a **scope**, the admin-level permission it needs. The MCP registry asks, in
446
+ order: visibility — the actor's own admin authz (`adminMcpTools`); a tool it denies is ABSENT and
447
+ answers tool-not-found, whatever the token holds — then the token's scope (`X_MCP_SCOPE_DENIED`
448
+ naming it, before any argument is validated or a handler runs), then the call's own row-level
449
+ decision inside the tool:
450
+
451
+ | Scope | Tools |
452
+ |---|---|
453
+ | `admin:read` | `admin.<entity>.list`, `admin.<entity>.read`, `admin.search`, a `readonly` action |
454
+ | `admin:write` | `admin.<entity>.create`, `admin.<entity>.update`, any other action |
455
+ | `admin:destroy` | `admin.<entity>.delete`, a `destructive` action |
456
+
457
+ The token's scopes are what `actor(session)` returns as `tokenScopes` (`AdminMcpActor`), expanded by
458
+ `adminTokenScopes` (`admin:destroy` ⇒ `admin:write` ⇒ `admin:read`): `tokenScopes: ['admin:read']`
459
+ is a read-only agent token. Absent, the token is as strong as its actor. A stdio host builds its
460
+ caller's `scopes` with `adminTokenScopes(granted)` too — an empty set is refused every tool.
461
+
427
462
  The list tool takes the list screen's own grammar: `scope` by name and `where` as
428
463
  `[{ field, op?, value }]`, through the same validator a URL passes — and the resource's `rows`
429
464
  narrows what an agent reads whatever it sends.
@@ -460,4 +495,4 @@ a job boundary the class is gone and the `code` is what survives — match on th
460
495
  | `AdminPolicyMissingError` | `X_ADMIN_POLICY_MISSING` | `src/errors.ts` |
461
496
  | `AdminRepoUnboundError` | `X_ADMIN_REPO_UNBOUND` | `src/errors.ts` |
462
497
  | `DevDashboardInProdError` | `X_DEV_DASHBOARD_IN_PROD` | `src/errors.ts` |
463
- | `DevSourceUnavailableError` | `X_NOT_IMPLEMENTED` | `src/errors.ts` |
498
+ | `DevSourceUnavailableError` (extends core's `NotImplementedError`) | `X_NOT_IMPLEMENTED` | `src/errors.ts` |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ultimat3/admin",
3
- "version": "23.0.0",
3
+ "version": "25.0.0",
4
4
  "description": "Two dashboards: the /_x framework dev panels and the generated, AI-first app admin",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -27,27 +27,27 @@
27
27
  "LICENSE"
28
28
  ],
29
29
  "engines": {
30
- "bun": ">=1.4.0"
30
+ "bun": ">=1.4.2"
31
31
  },
32
32
  "scripts": {
33
33
  "typecheck": "tsc --noEmit -p tsconfig.json",
34
34
  "test": "bun test"
35
35
  },
36
36
  "dependencies": {
37
- "@ultimat3/action": "23.0.0",
38
- "@ultimat3/ai": "23.0.0",
39
- "@ultimat3/cache": "23.0.0",
40
- "@ultimat3/core": "23.0.0",
41
- "@ultimat3/db": "23.0.0",
42
- "@ultimat3/entity": "23.0.0",
43
- "@ultimat3/i18n": "23.0.0",
44
- "@ultimat3/jobs": "23.0.0",
45
- "@ultimat3/mcp": "23.0.0",
46
- "@ultimat3/money": "23.0.0",
47
- "@ultimat3/policy": "23.0.0",
48
- "@ultimat3/query": "23.0.0",
49
- "@ultimat3/render": "23.0.0",
50
- "@ultimat3/schema": "23.0.0",
51
- "@ultimat3/ui": "23.0.0"
37
+ "@ultimat3/action": "25.0.0",
38
+ "@ultimat3/ai": "25.0.0",
39
+ "@ultimat3/cache": "25.0.0",
40
+ "@ultimat3/core": "25.0.0",
41
+ "@ultimat3/db": "25.0.0",
42
+ "@ultimat3/entity": "25.0.0",
43
+ "@ultimat3/i18n": "25.0.0",
44
+ "@ultimat3/jobs": "25.0.0",
45
+ "@ultimat3/mcp": "25.0.0",
46
+ "@ultimat3/money": "25.0.0",
47
+ "@ultimat3/policy": "25.0.0",
48
+ "@ultimat3/query": "25.0.0",
49
+ "@ultimat3/render": "25.0.0",
50
+ "@ultimat3/schema": "25.0.0",
51
+ "@ultimat3/ui": "25.0.0"
52
52
  }
53
53
  }
@@ -12,10 +12,13 @@ import {
12
12
  type AdminSubject,
13
13
  decideAll,
14
14
  } from './authz';
15
+ import { atomicallyAudited } from './crud-outcome';
15
16
  import { AdminActionNotApplicableError } from './errors';
16
17
  import {
17
18
  ADMIN_DESTROY,
19
+ ADMIN_READ,
18
20
  ADMIN_WRITE,
21
+ type AdminPermission,
19
22
  CONFIRMATION_REQUIRED_REASON,
20
23
  confirmationToken,
21
24
  } from './permissions';
@@ -46,11 +49,26 @@ export const ACTION_NOT_APPLICABLE_REASON = 'admin.error.action-not-applicable';
46
49
  /** The reason an action whose input its own schema refused is audited with. */
47
50
  const ACTION_INVALID_REASON = 'admin.error.invalid-input';
48
51
 
52
+ /**
53
+ * The admin-level gate an action needs, and the MCP scope its tool carries. `destructive` is
54
+ * checked first so a declaration marked both `readonly` and `destructive` gets the stricter gate —
55
+ * a contradiction that fails closed rather than open.
56
+ */
57
+ export function adminPermissionForAction<Input, Output>(
58
+ action: AdminAction<Input, Output>,
59
+ ): AdminPermission {
60
+ if (action.destructive === true) return ADMIN_DESTROY;
61
+ // `matching` CHANGES every row the list matches (its contract answers how many it changed), so a
62
+ // `readonly` beside it is the same contradiction as beside `destructive`: the write gate holds.
63
+ if (action.matching !== undefined) return ADMIN_WRITE;
64
+ return action.readonly === true ? ADMIN_READ : ADMIN_WRITE;
65
+ }
66
+
49
67
  /** The permissions an action needs: the admin-level gate, then the action's own policy. */
50
68
  export function permissionsForAction<Input, Output>(
51
69
  action: AdminAction<Input, Output>,
52
70
  ): readonly string[] {
53
- return [action.destructive === true ? ADMIN_DESTROY : ADMIN_WRITE, action.permission];
71
+ return [adminPermissionForAction(action), action.permission];
54
72
  }
55
73
 
56
74
  export function decideAction<Input, Output>(
@@ -278,51 +296,24 @@ export async function invokeAdminAction<Input, Output>(
278
296
  timeZone: args.timeZone ?? actor.timeZone ?? 'UTC',
279
297
  };
280
298
 
281
- try {
282
- const value = await action.handle({ input, ctx });
283
- return {
284
- ok: true,
285
- value,
286
- audit: await audit.append({
287
- requestId,
288
- actor,
289
- operation: action.name,
290
- kind: 'action',
291
- entity,
292
- entityId,
293
- permission: action.permission,
294
- outcome: 'allowed',
295
- reason: decision.reason,
296
- diff: args.diff ?? [],
297
- }),
298
- };
299
- } catch (error) {
300
- // NOTHING is read off `error` before the append, and nothing is read off it at all.
301
- //
302
- // This built an `AdminDecision` first, whose `trace` rendered the caught value with
303
- // `String(error)` — and a `catch` binding is annotated by nobody, so it holds whatever an
304
- // app's handler threw. `Object.create(null)` has no `toString`, no `valueOf` and no
305
- // `Symbol.toPrimitive`, so `String(it)` raises `TypeError: No default value` from inside the
306
- // block that owes the auditor an entry: measured, ZERO entries, and the caller received the
307
- // TypeError instead of what was thrown. Ordering was the second half — the render ran BEFORE
308
- // `append`, so its throw skipped the append rather than merely spoiling one field.
309
- //
310
- // The decision object was also DEAD: only its `reason` was ever read, and `append` takes no
311
- // trace. So there is no destination for a rendered value here and `renderThrowable` is not
312
- // needed either — an audit reason is a key the view renders, never a sentence from a
313
- // database, an upstream or an attacker.
314
- await audit.append({
299
+ // The handler and its entry are ONE unit (`atomicallyAudited`): a handler whose writes ride the
300
+ // ambient transaction commits with its `allowed` entry or not at all, and a throw from either
301
+ // leaves exactly one `failed` entry, appended after the rollback, with nothing read off the
302
+ // thrown value — which reaches the caller unchanged.
303
+ const { value, audit: appended } = await atomicallyAudited(
304
+ audit,
305
+ {
315
306
  requestId,
316
307
  actor,
317
308
  operation: action.name,
318
309
  kind: 'action',
319
310
  entity,
320
- entityId,
321
311
  permission: action.permission,
322
- outcome: 'failed',
323
- reason: ACTION_FAILED_REASON,
324
- diff: [],
325
- });
326
- throw error;
327
- }
312
+ },
313
+ { allowed: decision.reason, failed: ACTION_FAILED_REASON },
314
+ entityId,
315
+ () => action.handle({ input, ctx }),
316
+ () => ({ entityId, diff: args.diff ?? [] }),
317
+ );
318
+ return { ok: true, value, audit: appended };
328
319
  }