@ultimat3/admin 24.0.0 → 25.1.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 (44) hide show
  1. package/CLAUDE.md +14 -11
  2. package/README.md +39 -8
  3. package/package.json +17 -17
  4. package/src/action-gate.ts +18 -1
  5. package/src/admin.module.scss +118 -4
  6. package/src/audit-pg.ts +3 -3
  7. package/src/audit.ts +1 -3
  8. package/src/authz.ts +8 -8
  9. package/src/batch.ts +3 -8
  10. package/src/crud-input.ts +2 -1
  11. package/src/describe.ts +6 -0
  12. package/src/dev/data.ts +2 -2
  13. package/src/dev/dev-t.ts +9 -0
  14. package/src/dev/jobs-state-chart.tsx +38 -0
  15. package/src/dev/panel-jobs.ts +10 -5
  16. package/src/dev/panel-timeline.ts +9 -1
  17. package/src/dev/panel.ts +6 -3
  18. package/src/dev/server.ts +42 -13
  19. package/src/dev/timeline-style.ts +47 -0
  20. package/src/dev/timeline-waterfall.ts +96 -0
  21. package/src/form-decode.ts +13 -4
  22. package/src/index.ts +8 -7
  23. package/src/jobs/operator.ts +2 -2
  24. package/src/jobs/overview-tab.tsx +2 -2
  25. package/src/layout.tsx +34 -8
  26. package/src/list.tsx +1 -1
  27. package/src/mcp-scopes.ts +36 -0
  28. package/src/mcp-tools.ts +16 -2
  29. package/src/mcp.ts +23 -11
  30. package/src/pagination.ts +22 -18
  31. package/src/policy-bridge.ts +6 -6
  32. package/src/registry.ts +7 -0
  33. package/src/related.ts +2 -2
  34. package/src/relations.ts +1 -1
  35. package/src/resource.ts +10 -0
  36. package/src/screen-home-access.tsx +117 -0
  37. package/src/screen-home-counts.ts +42 -0
  38. package/src/screen-home-kpis.tsx +64 -0
  39. package/src/screen-home.module.scss +169 -0
  40. package/src/screen-home.tsx +21 -82
  41. package/src/screen-lookup.tsx +1 -1
  42. package/src/screen-resource.tsx +4 -4
  43. package/src/search.ts +2 -2
  44. package/src/inert-jsx.ts +0 -182
package/CLAUDE.md CHANGED
@@ -14,7 +14,7 @@ Two products, one package, **two entry points**: `@ultimat3/admin/dev` (`src/dev
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
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`.
@@ -26,13 +26,13 @@ Two products, one package, **two entry points**: `@ultimat3/admin/dev` (`src/dev
26
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.
27
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`).
28
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 `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 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
30
  - **The admin's stylesheets are claimed for `app/`** (`stylesheet-scope.ts`, `claimStylesheets`). `stylesheet-scope.test.ts`.
31
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` 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, and calls `defineAdmin()` in `beforeAll`, NEVER at module scope — the leak guard keeps what a file's module scope registered for every later file, so one module-scope admin closed the permission set for `bun test packages/admin packages/query`.
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.
33
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.
34
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.
35
- - **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` — as do `batch.ts` (one row at a time) and `relations.ts` (label reads chunked at 200 ids, never cut). 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.
36
36
  - **The timeline panel renders the detector's verdicts** (`DevSources.statementLoops()`), never a second count; none wired is `null`, never `[]`.
37
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.
38
38
  - **`dev/panel-db.ts`'s `sanitize` decides "did they type a statement", not "is it safe"** — never cite it as a security property.
@@ -48,29 +48,32 @@ Two products, one package, **two entry points**: `@ultimat3/admin/dev` (`src/dev
48
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
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`.
50
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`).
51
- - **Every admin operation is audited, reads included.** `ListResult` carries its `AuditEntry` on both branches, keyed on the table (`entityId: null`). `AdminSearchResult.audit` carries one entry per resource it decided about — `allowed` per searched resource, a `deniedDraft` per refused one; a resource skipped for no text field, no repo or a repo that THREW (`admin.search.skipped.failed`) is listed in `skipped`, never a 500 for the rest.
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.
52
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`.
53
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).
54
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.
55
- - **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-queue.ts`, `batch-job.ts`) under a DERIVED `batchIdOf` (actor, action, rows, input — not the request id), so a retry dedupes; 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`.
56
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`).
57
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.
58
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.
59
- - **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`.
60
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.
61
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.
62
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.
63
66
  - **An action on a row the actor cannot see is refused, `when` or none** (`actionApplies`: a `null` row is no row).
64
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.
65
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.
66
- - 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.
67
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.
68
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).
69
72
  - Colours only via `ThemeTokenRef` (`--x-*`). Raw hex does not typecheck.
70
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.
71
74
  - **A `/_x` panel orders by CODE UNIT, never `localeCompare`**; **the locale picker reads `registeredLocales()`**, never a bundled list.
72
- - Views are pure functions of props — no `createSignal`, no local state, no event handler.
73
- - **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')`.
74
77
 
75
78
  ## Layout
76
79
 
@@ -91,7 +94,7 @@ Two products, one package, **two entry points**: `@ultimat3/admin/dev` (`src/dev
91
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 |
92
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 |
93
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 |
94
- | `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 |
95
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 |
96
99
  | `jobs/` | the jobs dashboard: entities, repos, the where translation, actions, labels, resources, the overview and its `/_x` tab |
97
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,9 +18,9 @@ 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 |
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
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 |
@@ -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 |
@@ -211,7 +223,8 @@ export const suspendUser: AdminAction = {
211
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 |
212
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` |
213
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`) |
214
- | 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` |
215
228
  | MCP | still ONE tool, `admin.action.<name>`; a batch action's takes `ids: [...]` |
216
229
  | `labelKey` | the button's, the form's and the batch bar's label; absent: `admin.action.<name>` |
217
230
 
@@ -254,10 +267,11 @@ transaction as the write (`AuditLog.atomic`) — a CRUD write, an action's handl
254
267
  set and a queued batch's enqueues alike, so an entry that cannot be written rolls the work back
255
268
  and the log holds one `failed` entry — and reads it back by keyset. An allowed READ is not
256
269
  written unless `reads: true` — a refused one always is. Sealed and `sensitive` values reach the
257
- 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
258
271
  table is applied at boot with every other framework table (`@ultimat3/cli`'s `FRAMEWORK_SCHEMA`),
259
272
  from the leaf module `@ultimat3/admin/schema` (`SQL_ADMIN_AUDIT_TABLE`, `ADMIN_AUDIT_TABLE`) —
260
- 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.
261
275
 
262
276
  ### A sealed column
263
277
 
@@ -428,6 +442,23 @@ export const mcp = adminMcp({ app: admin, actor: (session) => actorFor(session.t
428
442
  adminMcpTools(admin, ctx); // admin.post.list · admin.post.read · admin.search · admin.action.post.publish
429
443
  ```
430
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
+
431
462
  The list tool takes the list screen's own grammar: `scope` by name and `where` as
432
463
  `[{ field, op?, value }]`, through the same validator a URL passes — and the resource's `rows`
433
464
  narrows what an agent reads whatever it sends.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ultimat3/admin",
3
- "version": "24.0.0",
3
+ "version": "25.1.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": "24.0.0",
38
- "@ultimat3/ai": "24.0.0",
39
- "@ultimat3/cache": "24.0.0",
40
- "@ultimat3/core": "24.0.0",
41
- "@ultimat3/db": "24.0.0",
42
- "@ultimat3/entity": "24.0.0",
43
- "@ultimat3/i18n": "24.0.0",
44
- "@ultimat3/jobs": "24.0.0",
45
- "@ultimat3/mcp": "24.0.0",
46
- "@ultimat3/money": "24.0.0",
47
- "@ultimat3/policy": "24.0.0",
48
- "@ultimat3/query": "24.0.0",
49
- "@ultimat3/render": "24.0.0",
50
- "@ultimat3/schema": "24.0.0",
51
- "@ultimat3/ui": "24.0.0"
37
+ "@ultimat3/action": "25.1.0",
38
+ "@ultimat3/ai": "25.1.0",
39
+ "@ultimat3/cache": "25.1.0",
40
+ "@ultimat3/core": "25.1.0",
41
+ "@ultimat3/db": "25.1.0",
42
+ "@ultimat3/entity": "25.1.0",
43
+ "@ultimat3/i18n": "25.1.0",
44
+ "@ultimat3/jobs": "25.1.0",
45
+ "@ultimat3/mcp": "25.1.0",
46
+ "@ultimat3/money": "25.1.0",
47
+ "@ultimat3/policy": "25.1.0",
48
+ "@ultimat3/query": "25.1.0",
49
+ "@ultimat3/render": "25.1.0",
50
+ "@ultimat3/schema": "25.1.0",
51
+ "@ultimat3/ui": "25.1.0"
52
52
  }
53
53
  }
@@ -16,7 +16,9 @@ import { atomicallyAudited } from './crud-outcome';
16
16
  import { AdminActionNotApplicableError } from './errors';
17
17
  import {
18
18
  ADMIN_DESTROY,
19
+ ADMIN_READ,
19
20
  ADMIN_WRITE,
21
+ type AdminPermission,
20
22
  CONFIRMATION_REQUIRED_REASON,
21
23
  confirmationToken,
22
24
  } from './permissions';
@@ -47,11 +49,26 @@ export const ACTION_NOT_APPLICABLE_REASON = 'admin.error.action-not-applicable';
47
49
  /** The reason an action whose input its own schema refused is audited with. */
48
50
  const ACTION_INVALID_REASON = 'admin.error.invalid-input';
49
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
+
50
67
  /** The permissions an action needs: the admin-level gate, then the action's own policy. */
51
68
  export function permissionsForAction<Input, Output>(
52
69
  action: AdminAction<Input, Output>,
53
70
  ): readonly string[] {
54
- return [action.destructive === true ? ADMIN_DESTROY : ADMIN_WRITE, action.permission];
71
+ return [adminPermissionForAction(action), action.permission];
55
72
  }
56
73
 
57
74
  export function decideAction<Input, Output>(
@@ -12,27 +12,125 @@
12
12
  background: t.role('bg');
13
13
  color: t.role('fg');
14
14
 
15
- // One column under the `md` rung: the sidebar becomes a block above the screen it navigates.
15
+ // One column under the `md` rung: the menu bar, then the screen; the sidebar is the panel the
16
+ // bar opens — or, in an engine with no popovers, a block above the screen it navigates.
16
17
  @include t.respond-down('md') {
17
18
  grid-template-columns: 1fr;
19
+ gap: 0;
18
20
  }
19
21
  }
20
22
 
23
+ // Off-screen until focused, then pinned at the inline-start corner. It used to stay visually
24
+ // hidden even WITH focus — the focus ring drew around a 1px box nobody could see.
21
25
  .skip {
22
- @include t.visually-hidden;
26
+ @include t.focus-ring;
27
+
28
+ position: fixed;
29
+ z-index: t.z(skip-nav);
30
+ inset-block-start: t.space(2);
31
+ inset-inline-start: t.space(2);
32
+ padding-block: t.space(2);
33
+ padding-inline: t.space(4);
34
+ border-radius: t.radius('md');
35
+ background: t.role('surface-raised');
36
+ color: t.role('fg-strong');
37
+ box-shadow: t.shadow(md);
38
+ text-decoration: none;
23
39
 
24
- &:focus-visible {
25
- @include t.focus-ring;
40
+ &:not(:focus-visible) {
41
+ @include t.visually-hidden;
26
42
  }
27
43
  }
28
44
 
45
+ // Hidden unless the engine can open the panel: a menu button that does nothing is worse than the
46
+ // block an engine without popovers still shows.
47
+ .bar {
48
+ display: none;
49
+ }
50
+
51
+ // A touch target: this bar exists for a phone.
52
+ .menuButton,
53
+ .close {
54
+ @include t.focus-ring;
55
+ @include t.touch-target;
56
+
57
+ display: inline-flex;
58
+ align-items: center;
59
+ gap: t.space(2);
60
+ padding-inline: t.space(3);
61
+ border: 0;
62
+ border-radius: t.radius('md');
63
+ background: none;
64
+ color: t.role('fg-strong');
65
+ font: inherit;
66
+ cursor: pointer;
67
+
68
+ &:hover {
69
+ background: t.role('bg-soft');
70
+ }
71
+ }
72
+
73
+ .close {
74
+ display: none;
75
+ justify-content: center;
76
+ align-self: flex-end;
77
+ }
78
+
79
+ // Every `[popover]` gets a UA style — fixed, centred, hidden while closed — so the column undoes
80
+ // each; this box is the sidebar at `md` and up, and at every width where there is no popover.
29
81
  .side {
30
82
  @include t.surface('surface', 'sm');
31
83
  @include t.padding-block(t.space(6));
32
84
  @include t.padding-inline(t.space(4));
33
85
  @include t.column(t.space(4));
34
86
 
87
+ position: static;
88
+ inset: auto;
89
+ inline-size: auto;
90
+ block-size: auto;
91
+ margin: 0;
92
+ color: t.role('fg');
35
93
  border-inline-end: t.stroke(hairline) solid t.role('line');
94
+ overflow: auto;
95
+ }
96
+
97
+ @include t.respond-down('md') {
98
+ @supports selector(:popover-open) {
99
+ .bar {
100
+ display: flex;
101
+ align-items: center;
102
+ padding-block: t.space(1);
103
+ padding-inline: t.space(2);
104
+ border-block-end: t.stroke(hairline) solid t.role('line');
105
+ background: t.role('surface');
106
+ }
107
+
108
+ .side:not(:popover-open) {
109
+ display: none;
110
+ }
111
+
112
+ // The same edge sheet as `Drawer`'s inline-start side: raised surface, line edge, scrim.
113
+ .side:popover-open {
114
+ @include t.scrim-backdrop;
115
+
116
+ position: fixed;
117
+ inset-block: 0;
118
+ inset-inline-start: 0;
119
+ inset-inline-end: auto;
120
+ inline-size: min(t.rem(320), calc(100vw - #{t.space(12)}));
121
+ block-size: 100dvh;
122
+ max-block-size: 100dvh;
123
+ border: 0;
124
+ border-inline-end: t.stroke(hairline) solid t.role('line');
125
+ border-radius: 0;
126
+ background: t.role('surface-raised');
127
+ box-shadow: t.shadow(xl);
128
+ }
129
+
130
+ .close {
131
+ display: inline-flex;
132
+ }
133
+ }
36
134
  }
37
135
 
38
136
  .brand {
@@ -50,8 +148,17 @@
50
148
  color: t.role('fg-muted');
51
149
  }
52
150
 
151
+ // One track that may shrink to the column: an `auto` track is at least its content's min-content
152
+ // width, and a text input's is its `size` — wider than a 256px sidebar's content box, so the box
153
+ // stuck out past the sidebar's edge at every width.
53
154
  .search {
54
155
  display: grid;
156
+ grid-template-columns: minmax(0, 1fr);
157
+ min-inline-size: 0;
158
+
159
+ > form {
160
+ min-inline-size: 0;
161
+ }
55
162
  }
56
163
 
57
164
  .hidden {
@@ -96,6 +203,13 @@
96
203
  &:focus-visible {
97
204
  @include t.focus-ring;
98
205
  }
206
+
207
+ // A touch-target row on a narrow screen, where the link is tapped rather than clicked.
208
+ @include t.respond-down('md') {
209
+ display: flex;
210
+ align-items: center;
211
+ min-block-size: t.touch(target);
212
+ }
99
213
  }
100
214
 
101
215
  .main {
package/src/audit-pg.ts CHANGED
@@ -2,7 +2,7 @@
2
2
  // `defineAdmin({ audit: postgresAuditLog() })` names — the memory log is a ring that forgets at
3
3
  // every restart, and a row's history that ends at the last deploy is not a history.
4
4
 
5
- import { isUltimateError, uuid } from '@ultimat3/core';
5
+ import { isUltimateError, uuidV7 } from '@ultimat3/core';
6
6
  import { type DbClient, db, withTransaction } from '@ultimat3/db';
7
7
  import {
8
8
  AUDIT_READ_OPERATIONS,
@@ -149,7 +149,7 @@ export interface PostgresAuditLogOptions {
149
149
  */
150
150
  export function postgresAuditLog(options: PostgresAuditLogOptions = {}): AuditLog {
151
151
  const now = options.now ?? ((): Date => new Date());
152
- const nextId = options.nextId ?? uuid;
152
+ const nextId = options.nextId ?? uuidV7;
153
153
  const sinks = options.sinks ?? [];
154
154
  const reads = options.reads === true;
155
155
  // Resolved per statement, never captured: inside `atomic` it is the open transaction.
@@ -160,7 +160,7 @@ export function postgresAuditLog(options: PostgresAuditLogOptions = {}): AuditLo
160
160
  async append(draft: AuditDraft): Promise<AuditEntry> {
161
161
  const entry = auditEntry(draft, nextId(), now());
162
162
  if (reads || !isAllowedRead(entry)) {
163
- // A plain `{ text, values }`, the shape `pgExecutorFor` hands the same client: the text
163
+ // A plain `{ text, values }`, the shape `dbExecutor` hands the same client: the text
164
164
  // is this file's constant and every value is bound.
165
165
  await client().query({
166
166
  text: SQL_ADMIN_AUDIT_INSERT,
package/src/audit.ts CHANGED
@@ -2,12 +2,10 @@
2
2
  // If it isn't logged, it didn't happen — so denied and failed attempts are logged too, and
3
3
  // there is deliberately no update or delete on this interface.
4
4
 
5
- import { canonicalJson, finiteCount } from '@ultimat3/core';
5
+ import { canonicalJson, finiteCount, REDACTED } from '@ultimat3/core';
6
6
  import type { AdminActor, AdminDecision } from './authz';
7
7
  import type { AdminRow } from './registry';
8
8
 
9
- export const REDACTED = '[redacted]';
10
-
11
9
  /** Named once, so both refusals below say the same thing about the same call. */
12
10
  const SUBJECT = 'memoryAuditLog';
13
11
 
package/src/authz.ts CHANGED
@@ -62,7 +62,7 @@ export interface AdminAuthz {
62
62
  decide(query: AdminAuthzQuery): AdminDecision;
63
63
  }
64
64
 
65
- export function allowed(
65
+ export function adminAllowed(
66
66
  permission: string,
67
67
  reason: string,
68
68
  trace: readonly string[] = [],
@@ -70,7 +70,7 @@ export function allowed(
70
70
  return { allowed: true, permission, reason, trace };
71
71
  }
72
72
 
73
- export function denied(
73
+ export function adminDenied(
74
74
  permission: string,
75
75
  reason: string,
76
76
  trace: readonly string[] = [],
@@ -82,7 +82,7 @@ export function denied(
82
82
  * Every permission must hold. The first denial wins, and carries its own reason.
83
83
  *
84
84
  * An EMPTY list is refused, never granted. `permissions[length - 1] ?? ''` used to fall through to
85
- * `allowed('')`, so a declared-but-empty gate opened for every actor, anonymous included — and the
85
+ * `adminAllowed('')`, so a declared-but-empty gate opened for every actor, anonymous included — and the
86
86
  * decision it returned named no permission at all. `visibleNav` hands an author's
87
87
  * `item.permissions` straight here, so `permissions: []` on a nav item was that gate. `pages.ts`
88
88
  * already refuses an empty page list at declaration time (`X_ADMIN_PAGE_UNGUARDED`); this is the
@@ -96,7 +96,7 @@ export function decideAll(
96
96
  subject?: AdminSubject,
97
97
  ): AdminDecision {
98
98
  if (permissions.length === 0) {
99
- return denied('', 'admin.policy.none-declared', [
99
+ return adminDenied('', 'admin.policy.none-declared', [
100
100
  'no permission was declared for this surface, so there is nothing to satisfy',
101
101
  ]);
102
102
  }
@@ -107,10 +107,10 @@ export function decideAll(
107
107
  );
108
108
  trace.push(`${permission}: ${decision.allowed ? 'allow' : 'deny'} (${decision.reason})`);
109
109
  if (!decision.allowed)
110
- return denied(permission, decision.reason, [...trace, ...decision.trace]);
110
+ return adminDenied(permission, decision.reason, [...trace, ...decision.trace]);
111
111
  }
112
112
  const last = permissions[permissions.length - 1] ?? '';
113
- return allowed(last, 'admin.policy.all-granted', trace);
113
+ return adminAllowed(last, 'admin.policy.all-granted', trace);
114
114
  }
115
115
 
116
116
  export function isAllowed(
@@ -167,8 +167,8 @@ export function staticAuthz(granted: readonly string[]): AdminAuthz {
167
167
  return {
168
168
  decide({ permission }): AdminDecision {
169
169
  return set.has(permission)
170
- ? allowed(permission, 'admin.policy.granted', [`static: ${permission} in grant list`])
171
- : denied(permission, 'admin.policy.not-granted', [
170
+ ? adminAllowed(permission, 'admin.policy.granted', [`static: ${permission} in grant list`])
171
+ : adminDenied(permission, 'admin.policy.not-granted', [
172
172
  `static: ${permission} not in [${[...set].join(', ')}]`,
173
173
  ]);
174
174
  },
package/src/batch.ts CHANGED
@@ -4,19 +4,14 @@
4
4
 
5
5
  import { finiteCount } from '@ultimat3/core';
6
6
  import { expectedQueryLoop } from '@ultimat3/db';
7
- import { decideAction } from './action-gate';
7
+ import { adminPermissionForAction, decideAction } from './action-gate';
8
8
  import { invokeRowAction } from './action-row';
9
9
  import type { AdminDecision } from './authz';
10
10
  import { runMatching } from './batch-matching';
11
11
  import { queueBatch } from './batch-queue';
12
12
  import { type CrudCtx, decideOperation } from './crud';
13
13
  import { type AdminListRequest, listWhere } from './list-scope';
14
- import {
15
- ADMIN_DESTROY,
16
- ADMIN_WRITE,
17
- CONFIRMATION_REQUIRED_REASON,
18
- confirmationToken,
19
- } from './permissions';
14
+ import { ADMIN_DESTROY, CONFIRMATION_REQUIRED_REASON, confirmationToken } from './permissions';
20
15
  import type { AdminAction, AdminFilter } from './registry';
21
16
  import { rowId } from './registry';
22
17
  import { type AdminResource, repoOf } from './resource';
@@ -202,7 +197,7 @@ export async function runAdminBatch(input: AdminBatchInput): Promise<AdminBatchA
202
197
  if (action.batch === undefined) {
203
198
  return denied({
204
199
  allowed: false,
205
- permission: action.destructive === true ? ADMIN_DESTROY : ADMIN_WRITE,
200
+ permission: adminPermissionForAction(action),
206
201
  reason: BATCH_NOT_OFFERED_REASON,
207
202
  trace: [`batch: ${action.name} does not declare batch`],
208
203
  });
package/src/crud-input.ts CHANGED
@@ -2,7 +2,8 @@
2
2
  // tenant column taken out of the caller's hands, and the audit diff of what the write changed.
3
3
  // Pure over the resource — `crud.ts` decides and writes, this file only shapes the input.
4
4
 
5
- import { type AuditFieldDiff, diffRows, REDACTED } from './audit';
5
+ import { REDACTED } from '@ultimat3/core';
6
+ import { type AuditFieldDiff, diffRows } from './audit';
6
7
  import type { CrudCtx } from './crud';
7
8
  import type { AdminRow } from './registry';
8
9
  import type { AdminResource } from './resource';
package/src/describe.ts CHANGED
@@ -36,6 +36,10 @@ export interface AdminActionDescription {
36
36
  readonly batch: boolean;
37
37
  /** Rows a batch runs inline before it is queued as `admin.batch` jobs. `null`: always inline. */
38
38
  readonly threshold: number | null;
39
+ /** Declared `readonly`: its admin gate is `admin:read` — unless `destructive` or `matching` holds it. */
40
+ readonly readonly: boolean;
41
+ /** It declares `matching`: "all matching" is ONE set-based write, which keeps the write gate. */
42
+ readonly matching: boolean;
39
43
  }
40
44
 
41
45
  export interface AdminResourceDescription {
@@ -88,6 +92,8 @@ const actionOf = (action: AdminAction): AdminActionDescription => ({
88
92
  when: action.when !== undefined,
89
93
  batch: action.batch !== undefined,
90
94
  threshold: batchPlan(action)?.threshold ?? null,
95
+ readonly: action.readonly === true,
96
+ matching: action.matching !== undefined,
91
97
  });
92
98
 
93
99
  /** Sorted by name and by URL, so two boots of one app describe it in the same bytes. */
package/src/dev/data.ts CHANGED
@@ -144,8 +144,8 @@ export function defaultDevSources(opts: DevSourceOptions = {}): DevSources {
144
144
 
145
145
  const sources: DevSources = {
146
146
  async routes(): Promise<readonly RouteFact[]> {
147
- const { describeRoutes } = await import('@ultimat3/render');
148
- return describeRoutes().map((route) => ({
147
+ const { describePages } = await import('@ultimat3/render');
148
+ return describePages().map((route) => ({
149
149
  path: route.path,
150
150
  // `RouteDescriptor` calls the render mode `mode`; the panel's own word is `render`, and
151
151
  // the two are bridged here, once. Reading `route['render']` answered `undefined` for