@ultimat3/admin 22.14.0 → 23.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 (89) hide show
  1. package/CLAUDE.md +73 -80
  2. package/README.md +276 -87
  3. package/package.json +19 -17
  4. package/src/action-form.tsx +139 -0
  5. package/src/action-gate.ts +127 -28
  6. package/src/action-input.ts +119 -0
  7. package/src/action-label.ts +9 -0
  8. package/src/action-row.ts +37 -0
  9. package/src/actions.tsx +89 -66
  10. package/src/actor.ts +53 -0
  11. package/src/admin.module.scss +298 -0
  12. package/src/admin.ts +187 -23
  13. package/src/audit-pg.ts +207 -0
  14. package/src/audit-schema.ts +47 -0
  15. package/src/audit.ts +109 -22
  16. package/src/authz.ts +11 -0
  17. package/src/batch-bar.tsx +66 -0
  18. package/src/batch-job.ts +152 -0
  19. package/src/batch-matching.ts +92 -0
  20. package/src/batch.ts +431 -0
  21. package/src/catalog-keys.ts +55 -0
  22. package/src/crud-input.ts +104 -0
  23. package/src/crud-outcome.ts +166 -0
  24. package/src/crud.ts +129 -177
  25. package/src/describe.ts +131 -0
  26. package/src/detail.tsx +115 -43
  27. package/src/dev/data.ts +24 -6
  28. package/src/dev/facts.ts +27 -0
  29. package/src/dev/panel-db.ts +37 -5
  30. package/src/dev/panel-jobs.ts +8 -0
  31. package/src/dev/panel-routes.ts +10 -0
  32. package/src/dev/panel.ts +6 -0
  33. package/src/dev/server.ts +15 -7
  34. package/src/entity-columns.ts +24 -5
  35. package/src/errors.ts +83 -0
  36. package/src/fields.ts +63 -4
  37. package/src/form-decode.ts +91 -0
  38. package/src/form.tsx +78 -45
  39. package/src/index.ts +198 -9
  40. package/src/jobs/fleet-repo.ts +143 -0
  41. package/src/jobs/job-actions.ts +172 -0
  42. package/src/jobs/job-entities.ts +118 -0
  43. package/src/jobs/job-labels.ts +108 -0
  44. package/src/jobs/job-repo.ts +103 -0
  45. package/src/jobs/job-resources.ts +211 -0
  46. package/src/jobs/job-where.ts +170 -0
  47. package/src/jobs/operator.ts +34 -0
  48. package/src/jobs/overview-data.ts +129 -0
  49. package/src/jobs/overview-tab.tsx +61 -0
  50. package/src/jobs/overview.tsx +189 -0
  51. package/src/json-text.ts +52 -0
  52. package/src/layout.tsx +55 -43
  53. package/src/list-columns.tsx +53 -0
  54. package/src/list-filter-bar.tsx +207 -0
  55. package/src/list-filters.ts +217 -0
  56. package/src/list-request.ts +127 -0
  57. package/src/list-scope-tabs.tsx +60 -0
  58. package/src/list-scope.ts +113 -0
  59. package/src/list.tsx +111 -72
  60. package/src/mcp-tools.ts +57 -13
  61. package/src/mcp.ts +108 -38
  62. package/src/mounts.ts +44 -0
  63. package/src/page-guard.tsx +26 -29
  64. package/src/policy-bridge.ts +110 -15
  65. package/src/registry.ts +138 -8
  66. package/src/related.ts +105 -0
  67. package/src/relations.ts +224 -0
  68. package/src/renderers.tsx +89 -0
  69. package/src/repo-entity.ts +204 -0
  70. package/src/resource-fields.ts +116 -0
  71. package/src/resource-layout.ts +89 -0
  72. package/src/resource-list.ts +185 -0
  73. package/src/resource.ts +129 -79
  74. package/src/route-config.ts +71 -0
  75. package/src/routes.ts +55 -54
  76. package/src/row-scope-write.ts +130 -0
  77. package/src/screen-action.tsx +333 -0
  78. package/src/screen-frame.tsx +167 -0
  79. package/src/screen-home.tsx +201 -0
  80. package/src/screen-lookup.tsx +157 -0
  81. package/src/screen-resource.tsx +393 -0
  82. package/src/screen-system.tsx +69 -0
  83. package/src/screens.ts +56 -0
  84. package/src/search.ts +13 -9
  85. package/src/stylesheet-scope.ts +8 -0
  86. package/src/test-ctx.ts +31 -0
  87. package/src/validate.ts +3 -0
  88. package/src/widget-value.ts +36 -8
  89. package/src/widgets.tsx +84 -12
package/README.md CHANGED
@@ -20,7 +20,7 @@ One panel per file. Each kills one question, and each is available as `--json`
20
20
  | `routes` | which handler serves this? — render mode, offline strategy, revalidate tags, budget |
21
21
  | `timeline` | where did the time go? — flamegraph of SQL, cache, action, policy spans + the N+1 count |
22
22
  | `live` | what does each subscriber receive, and **why** — the matcher's decision trace |
23
- | `jobs` | queue depth, step traces, retry-from-step target, dead letter |
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
24
  | `db` | psql in a tab (read-only; `assertReadOnly` refuses DML), 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 |
@@ -28,7 +28,12 @@ One panel per file. Each kills one question, and each is available as `--json`
28
28
  | `manifest` | emitted `x.manifest.json` diffed against the committed one |
29
29
 
30
30
  ```ts
31
- import { devDashboard, defaultDevSources } from '@ultimat3/admin/dev';
31
+ import { staticAuthz } from '@ultimat3/admin';
32
+ import { defaultDevSources, devDashboard } from '@ultimat3/admin/dev';
33
+
34
+ declare const request: Request;
35
+ const authz = staticAuthz(['admin:read']);
36
+ const actors = [{ id: 'dev-admin', roles: ['admin'] }];
32
37
 
33
38
  const dev = devDashboard({ sources: defaultDevSources({ authz, actors }) }); // throws in prod
34
39
  const response = await dev.handle(request); // null when the path is not /_x
@@ -40,50 +45,260 @@ component tree into the process, and an admin view cannot reach a dev panel by a
40
45
 
41
46
  ## The generated admin
42
47
 
43
- One call, a working CRUD admin: columns from the entity's columns, filters from indexed columns, validation from the entity's schema, labels from i18n keys.
48
+ One call, a working CRUD admin **at a URL**: columns from the entity's columns, filters from indexed columns, validation from the entity's schema, labels from i18n keys, rows through the app's own typed handle. The app writes no page, no screen glue and no repo adapter.
49
+
50
+ ```ts
51
+ import { type AdminDb, type AdminEntity, defineAdmin } from '@ultimat3/admin';
52
+
53
+ // Your app's `entity()` results and its `database()` handle — `@app/db` in a scaffolded app.
54
+ declare const posts: AdminEntity;
55
+ declare const users: AdminEntity;
56
+ declare const db: AdminDb;
57
+
58
+ export const admin = defineAdmin({ entities: [posts, users], db });
59
+ ```
60
+
61
+ `x dev` and the container mount it: `/admin`, `/admin/posts`, `/admin/posts/new`,
62
+ `/admin/posts/<id>`, `/admin/posts/<id>/edit`, plus `/admin/search`, `/admin/audit` and the jobs
63
+ dashboard (`/admin/jobs`, below).
64
+
65
+ | Input | What it is | Omitted |
66
+ |---|---|---|
67
+ | `entities` | the objects `entity()` returned — `adminEntitiesOf(db)` is every entity the handle serves | required |
68
+ | `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 |
71
+ | `pages` | custom screens, see below | none |
72
+ | `auth.actor` | who is acting | the actor the HTTP pipeline resolved for the request |
73
+ | `auth.authz` | the one decision path | `roleAuthz()` — the role map decides each permission by name |
74
+ | `audit` | `postgresAuditLog()` (the table `x_admin_audit`) or `memoryAuditLog({ sinks })` | a memory ring |
75
+
76
+ Three authz constructors, one interface: `roleAuthz()` (the default), `policyAuthz({ policies })`
77
+ when a rule reads the row or the tenant, `singlePolicyAuthz(policy)` when one grant runs the whole
78
+ admin.
44
79
 
45
- `entities` takes the entities themselves — the objects `entity()` returned, not their `describeEntities()` projection. The admin reads `$columns`, `$primaryKey`, `$schema` and `$describe()` off them; `RegisteredEntity` in `registry.ts` is the compile-time check that it may.
80
+ ### No script
81
+
82
+ Every route is `ssr`, `hydrate: 'never'`, and every control is a link or a native form: the pager
83
+ is `<Pagination hrefFor>`, a sort header is a link, scope tabs are links, the filter bar is one
84
+ GET form, a form posts at the URL that rendered it and a refused one comes back 422 with what was
85
+ typed. A write that worked is a 303. Every admin document is `noindex, nofollow`, and carries no
86
+ `og:*` or `twitter:*` tag (`@ultimat3/seo` withdraws them from every `noindex` document).
87
+
88
+ ### The list an operator works in
89
+
90
+ The list's whole state is its URL — one parser and one builder (`pageRequestOf`, `listHref`):
91
+
92
+ | Parameter | Means | Refused when |
93
+ |---|---|---|
94
+ | `?cursor=<signed>` | keyset position | never — an unreadable cursor is page one |
95
+ | `?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 |
97
+ | `?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 |
99
+ | anything else | — | always |
100
+
101
+ A refusal is `X_ADMIN_FILTER_INVALID`, a 400 naming what the list does answer — never the
102
+ unfiltered list. An empty value is no parameter: a GET form submits every input it holds.
103
+
104
+ | Filter shape | Derived for | Control | Operators (first is the default) |
105
+ |---|---|---|---|
106
+ | `text` | a text column `LIKE` runs against — the label always, others when indexed | search box | `contains` `eq` `neq` `is-null` |
107
+ | `exact` | a uuid, `bigint` or `numeric` column | text box | `eq` `neq` `in` `is-null` |
108
+ | `choice` | an enum (its own values, labelled by the catalog), a locale, a zone | select | `eq` `neq` `in` `is-null` |
109
+ | `boolean` | a boolean | any / yes / no | `eq` |
110
+ | `range` | a number, a date, a timestamp (typed in UTC) | from / to | `eq` `neq` `gt` `gte` `lt` `lte` `is-null` |
111
+ | `reference` | a foreign key | the target's rows by label, or its lookup | `eq` `neq` `in` `is-null` |
112
+
113
+ Money, JSON, file, sealed and tenant columns derive no filter.
46
114
 
47
115
  ```ts
48
- import { defineAdmin, adminRoutes, policyAuthz, memoryAuditLog } from '@ultimat3/admin';
49
- import { posts, users } from '@app/db/schema';
50
-
51
- export const admin = defineAdmin({
52
- entities: [posts, users],
53
- // `AdminAction` is the admin's own shape: a `permission` (never optional) plus a handler.
54
- actions: [{ name: 'post.publish', permission: 'post:publish', entity: 'posts', handle }],
55
- resources: { posts: { repo: postsAdminRepo, listFields: ['title', 'status', 'publishedAt'] } },
56
- branding: { nameKey: 'admin.brand.name', accent: '--x-color-brand', mode: 'system' },
57
- auth: { actor: (request) => session(request), authz: policyAuthz({ policies }) },
58
- audit: memoryAuditLog({ sinks: [auditTable] }),
116
+ defineAdmin({
117
+ entities: [tickets, owners],
118
+ db,
119
+ resources: {
120
+ tickets: {
121
+ labelField: 'title',
122
+ // Tabs. At most one default; a count is one query per tab that asks for it.
123
+ scopes: {
124
+ open: { where: [{ field: 'status', op: 'eq', value: 'open' }], default: true, count: true },
125
+ mine: { where: (actor) => [{ field: 'assigneeId', op: 'eq', value: actor.id }] },
126
+ },
127
+ // Who sees which rows at all — list, search, detail, lookup, labels and MCP alike.
128
+ rows: (actor) => [{ field: 'region', op: 'eq', value: actor.locale ?? '' }],
129
+ // Computed list columns: one of six renderers, or a component.
130
+ columns: {
131
+ age: { value: (row) => row.createdAt, render: 'relative-time' },
132
+ state: { value: (row) => ({ label: row.status, tone: 'warning' }), render: 'badge' },
133
+ },
134
+ },
135
+ },
59
136
  });
137
+ ```
60
138
 
61
- export const routes = adminRoutes(admin); // every view `ssr`, gated — an admin screen is per-actor
139
+ | Declared | Rule |
140
+ |---|---|
141
+ | `scopes.<name>` | `where` is predicates or `(actor) => predicates`; `default: true` at most once; `count: true` reads `AdminRepo.count` — never the caller's filters, so a tab's number is the scope's size |
142
+ | `rows` | applied by every read of the resource; a row outside it is "not found", exactly as a missing one |
143
+ | `columns.<name>` | `render`: `badge` · `relative-time` · `money` · `truncate` · `link` · `json`, or `(props) => JSX`; the row it reads carries no sealed column; a name that is also an entity column is refused |
144
+ | `labelField` | what a reference to this resource SHOWS, linked to the row; read once per page per target, never per row |
145
+
146
+ A reference filter or input is a `<select>` of the target's rows when it has at most 50
147
+ (`LOOKUP_SELECT_MAX`), else an id box beside the target's lookup screen
148
+ (`/admin/<resource>/lookup`) — one derived lookup per resource, `contains` on its label, behind
149
+ its own `list` gate and row scope, keyset-paged. No picker endpoint is written in the app.
150
+
151
+ Statements a list page issues: 1 for the rows, 1 per scope with `count: true`, 1 per referenced
152
+ resource (2 for one that is picked from and has more than 50 rows). 50 rows, two reference columns
153
+ and three scopes of which one is counted: 4, or 6 when both targets are large.
154
+
155
+ ### The detail page and the form
156
+
157
+ ```ts
158
+ import type { AdminResourceOptions } from '@ultimat3/admin';
159
+
160
+ export const resources: Readonly<Record<string, AdminResourceOptions>> = {
161
+ users: {
162
+ // Titled groups; every field named in none falls into a default section drawn last.
163
+ sections: [{ titleKey: 'admin.users.section.profile', fields: ['handle', 'displayName'] }],
164
+ formGroups: [{ titleKey: 'admin.users.section.profile', fields: ['handle', 'displayName'] }],
165
+ // hasMany relations, by the name @ultimat3/entity gives them — each drawn as THAT
166
+ // resource's own list (its columns, its rows scope, its policy) filtered to this row.
167
+ related: ['posts', 'media'],
168
+ fields: {
169
+ handle: { hintKey: 'admin.users.hint.handle', on: 'create' }, // set once, never edited
170
+ },
171
+ },
172
+ };
173
+ ```
174
+
175
+ | Declared | Rule |
176
+ |---|---|
177
+ | `sections` / `formGroups` | a name that is not a drawn field, or that two groups claim, is `X_ADMIN_FIELD_UNSUPPORTED` at `defineAdmin` |
178
+ | `related` | a `hasMany` of the entity whose rows are a resource of this admin; anything else refused at `defineAdmin`. A target the actor may not list is left off the page |
179
+ | `on` | the field is an input on one side only, and a write from the other side drops it |
180
+ | `hintKey` | the line under the control, through `t()` |
181
+
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
184
+ the row (2 with `rows`), 1 per related list, 1 per referenced target for the whole page, 1 for
185
+ the history with `postgresAuditLog`.
186
+
187
+ ### Actions: a form, a row state, a batch
188
+
189
+ ```ts
190
+ import type { AdminAction } from '@ultimat3/admin';
191
+ import { t } from '@ultimat3/schema';
192
+
193
+ declare function suspend(id: unknown, reason: unknown): Promise<void>;
194
+
195
+ export const suspendUser: AdminAction = {
196
+ name: 'user.suspend',
197
+ permission: 'users:suspend',
198
+ entity: 'users',
199
+ input: t.object({ reason: t.string.min(3) }), // the action's own form; absent: a confirm only
200
+ when: (row) => row['suspended'] !== true, // the button, each list row, AND the server
201
+ batch: { threshold: 200 }, // or `true`: always inline
202
+ handle: async ({ input }) => suspend(input['id'], input['reason']),
203
+ };
62
204
  ```
63
205
 
206
+ | Piece | Behaviour |
207
+ |---|---|
208
+ | `input` | the button links to `<row>?action=<name>`; the form posts `input.<field>`, labelled `admin.input.<name>.<field>`; a refusal is a 422 with each issue on its field |
209
+ | `when(row)` | hides the button where it answers false and is asked again before the handler: `X_ADMIN_ACTION_NOT_APPLICABLE`, a 409. The row carries no sealed column |
210
+ | `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
+ | `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
+ | `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` |
214
+ | MCP | still ONE tool, `admin.action.<name>`; a batch action's takes `ids: [...]` |
215
+ | `labelKey` | the button's, the form's and the batch bar's label; absent: `admin.action.<name>` |
216
+
217
+ Every key a declaration derives — branding, nav, titles, fields, sections, scopes, columns, action
218
+ labels and their input fields — is `admin.catalogKeys()`, sorted. `x verify`'s `i18n` step asks
219
+ each of the app's catalogs for exactly that list (`X_CATALOG_MISSING_KEYS`).
220
+
221
+ No script, anywhere: row checkboxes join the bar's form by their `form` attribute, and a
222
+ confirmation is a server round trip. An action on a row the actor cannot see — gone, or outside
223
+ its `rows` — is refused whether or not it declares `when`.
224
+
225
+ ### The jobs dashboard
226
+
227
+ Every `defineAdmin()` carries it; the app declares nothing. Four resources over the queue's
228
+ operator surface (`JobIntrospection`), each an ordinary resource with a `repo:`, and one screen:
229
+
230
+ | Path | What | Actions (`job:manage`) |
231
+ |---|---|---|
232
+ | `/admin/jobs` | depth tiles from one `stats()`, workers, oldest ready; done-vs-failed over `?range=1h\|24h\|7d\|30d`; volume, failure rate and mean duration per job name. A declared 30 s `<meta http-equiv="refresh">` | — |
233
+ | `/admin/jobs/runs` (`x_jobs`) | one tab per state; filters by id prefix (the search box), name, queue, state, tenant, created time; a row's input (redacted by `@ultimat3/jobs`), steps, error and stack, progress, trace | retry (dead), retry from a step, run now (waiting on its clock), cancel (live), remove — the batched ones over checked rows or "all matching" through `requeueMany` / `promoteMany` / `removeMany` |
234
+ | `/admin/jobs/queues` (`x_job_queues`) | depth per state, paused | pause, resume |
235
+ | `/admin/jobs/tasks` (`x_job_tasks`) | schedule, zone, last fire (`taskFires()`), next fire (`nextTaskRun`), paused | pause, resume, run now |
236
+ | `/admin/jobs/workers` (`x_job_workers`) | identity, host, queues, in flight, heartbeat | — |
237
+
238
+ | Permission | Grants |
239
+ |---|---|
240
+ | `job:read` (`JOB_READ`) | every jobs screen and MCP read; no control renders |
241
+ | `job:manage` (`JOB_MANAGE`) | every action, behind `admin:write` (or `admin:destroy` for remove) — refused on the server when forged |
242
+
243
+ An actor with an `orgId` sees that org's job rows only (`jobRowScope`) and none of the fleet —
244
+ queues, tasks, workers and the overview's figures are every tenant's. Override either with
245
+ `resources: { x_jobs: { rows } }`. MCP: `admin.x_jobs.list`, `admin.x_jobs.read`,
246
+ `admin.x_job_tasks.list`, `admin.action.job.retry`, `admin.action.job.queue.pause`, … — the
247
+ admin's own projection. `/_x/jobs` draws the same overview component over the dev queue.
248
+
249
+ ### The audit log
250
+
251
+ `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
253
+ 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
255
+ table is applied at boot with every other framework table (`@ultimat3/cli`'s `FRAMEWORK_SCHEMA`),
256
+ from the leaf module `@ultimat3/admin/schema` (`SQL_ADMIN_AUDIT_TABLE`, `ADMIN_AUDIT_TABLE`) —
257
+ no migration, no screen loaded to install it.
258
+
259
+ ### A sealed column
260
+
261
+ `.sealed()` on the entity is read by nothing here: no list column, no detail row, no filter, no
262
+ search field, no MCP argument. It is in `resource.secretFields` — a list of its own — and the form
263
+ renders it as a password input with no value. Empty on an edit means unchanged; a required one is
264
+ required to create. The audit diff records *that* it changed, as `[redacted]`.
265
+
64
266
  ### The views are TSX, and the pieces come apart
65
267
 
66
- There is no view DSL here, and there will not be one. Every renderer is an ordinary SolidJS
67
- component in an ordinary `.tsx` file importing `@ultimat3/ui` — `AdminList`, `AdminForm`,
68
- `AdminDetail`, `Widget`, `AdminLayout` are each exported on their own, so a table can be lifted
69
- out of its page and dropped into a screen the generator never wrote:
268
+ Every renderer is an ordinary component in an ordinary `.tsx` file importing `@ultimat3/ui` —
269
+ `AdminList`, `AdminForm`, `AdminDetail`, `AdminActions`, `Widget`, `AdminLayout` are each exported,
270
+ so a table can be lifted into a screen the generator never wrote:
70
271
 
71
272
  ```tsx
72
- import { AdminList, AdminForm, Widget } from '@ultimat3/admin';
273
+ import { AdminList, listHref, Widget } from '@ultimat3/admin';
73
274
 
74
- <AdminList resource={admin.resource('posts')} page={page} ctx={ctx} … />; // just the table
75
- <Widget field={field} value={row.total} ctx={ctx} mode="read" />; // just one cell
275
+ <AdminList resource={resource} page={page} error={null} ctx={ctx} actor={actor} authz={authz}
276
+ basePath={admin.basePath} request={request} scope={scope} counts={counts}
277
+ hrefFor={(location) => listHref(admin.basePath, resource, location)} />;
278
+ <Widget field={field} value={row.total} ctx={ctx} mode="read" />;
76
279
  ```
77
280
 
78
281
  `Widget` takes the field and the raw row value, not pre-derived props: `widgetProps()` is the
79
282
  guard (money is minor units, a timestamp has an IANA zone) and the component calls it, so there is
80
- no way to render a cell that skipped it. `mode` is `read` or `edit`; an edit cell also takes the
81
- `control` the surrounding `<Field>` hands its child, and `onInput`.
283
+ no way to render a cell that skipped it.
284
+
285
+ ### Mounting it yourself
286
+
287
+ `x dev` and the container already do. A host that is neither asks the route table per request:
288
+
289
+ ```ts
290
+ import { adminRouteMatch } from '@ultimat3/admin';
291
+
292
+ const matched = adminRouteMatch(admin, url.pathname); // null → 404
293
+ const answer = await matched.route.respond({
294
+ ctx: await admin.requestCtx(request), params: matched.params, url: url.href,
295
+ method: 'GET', form: null, // or 'POST' + the parsed form
296
+ });
297
+ // { kind: 'document', status, body } — the framed screen — or { kind: 'redirect', location }
298
+ ```
82
299
 
83
- An admin route is an ordinary `route` primitive; an admin action is an ordinary `action`. Nothing
84
- in this package is written in a second language that only the dashboard understands — the escape
85
- hatch is the same TSX as the main path, which is why there is no cliff to fall off when a screen
86
- stops being CRUD.
300
+ `respond` is the only way in: the screen decides before it renders, so a host serialises and
301
+ nothing else.
87
302
 
88
303
  ### Custom pages
89
304
 
@@ -114,75 +329,41 @@ defineAdmin({
114
329
 
115
330
  | What you get | How |
116
331
  |---|---|
117
- | a row in `app.routes`, `adminRoutes()`, `x manifest` | `pageRoutes()` folds it in beside the generated screens |
332
+ | a row in `app.routes`, `adminRoutes()`, `x routes`, `x manifest` | `pageRoutes()` folds it in beside the generated screens; every admin route is in the framework's route list as a MOUNTED route with its permissions |
118
333
  | a nav item that disappears for an actor who cannot open it | `NavItem.permissions` → `visibleNav` |
119
334
  | a `defineRoute({ policy })` you never wrote and cannot omit | `adminRouteConfig` composes `permissions[0]` into it |
120
- | a per-request refusal, audited, before your component runs | `guardedPage()` wraps it in `decideAll` |
121
-
122
- **The guard is not yours to remember.** `adminRoutes()` hands the router the *wrapped* component,
123
- never the one you wrote, and `AdminPageProps.ctx` is required by the type — a page component
124
- cannot be called without the handle the guard decides on. `permissions: []` throws
125
- `X_ADMIN_PAGE_UNGUARDED` where it is written, not on the first unauthenticated request. A path
126
- that shadows a generated screen throws `X_ADMIN_PAGE_PATH_INVALID` the same way.
127
-
128
- Custom pages are `render: 'ssr'`, `hydrate: 'never'`: the guard runs on the server, so there is no
129
- shell to ship and nothing to decide twice.
335
+ | a per-request refusal, audited, answered 403, before your component runs | `guardedScreen()` wraps it in `decideAll` |
336
+ | the shell — nav, who is acting, search | the same frame every generated screen gets |
130
337
 
131
- ### Serving an admin URL from your own route file
132
-
133
- A host whose router is file-based writes its own `page.tsx` for an admin URL. Take the gate from
134
- the route table — never type it a second time:
338
+ **Testing one.** A page component needs a `CrudCtx`, and `adminTestCtx()` mints it from a grant
339
+ list — no admin instance, no session:
135
340
 
136
341
  ```ts
137
- import { adminRouteFor } from '@ultimat3/admin';
138
- import { admin } from './admin';
139
-
140
- const route = adminRouteFor(admin, `${admin.basePath}/users`);
342
+ import { adminTestCtx } from '@ultimat3/admin';
343
+ import { renderView } from '@ultimat3/testing';
141
344
 
142
- export const config = defineRoute({
143
- render: 'ssr',
144
- hydrate: 'never',
145
- offline: 'network-only',
146
- policy: route.policy, // `defineAdmin()`'s, from `permissionsForOperation('users', 'list')`
147
- meta: () => ({ title: t('admin.users.title') }),
345
+ const view = await renderView(OpsPage, {
346
+ ctx: adminTestCtx({ granted: ['admin:read', 'ops:read'] }),
347
+ params: {},
348
+ url: 'http://localhost/admin/ops',
148
349
  });
149
350
  ```
150
351
 
151
- `route.policy` is the same object `route.config.policy` carries, so the gate has one declaration
152
- and one reader. A `path` the admin does not declare throws `X_ADMIN_PAGE_PATH_INVALID` listing the
153
- paths that would have worked — a page serving an admin URL the admin never built is a screen whose
154
- permissions nothing composed.
155
-
156
- ### A refused page answers 403
157
-
158
- `guardedPage()` renders `AdminPageDenied` in place of the page, and the page's `load` decides the
159
- status: a mount that resolves the body in `load` answers the refusal with `withStatus` from
160
- `@ultimat3/render`, asking the same `decideAll` the guard asked. As of 2026-09.
352
+ `x g admin:page <name>` emits exactly this test beside the page.
161
353
 
162
- ```ts
163
- import { type AdminApp, type AdminPageProps, adminRouteFor, decideAll } from '@ultimat3/admin';
164
- import { withStatus } from '@ultimat3/render';
165
-
166
- declare const admin: AdminApp;
167
- declare function adminCtxForRequest(): AdminPageProps['ctx'];
168
-
169
- const route = adminRouteFor(admin, `${admin.basePath}/ops`);
170
-
171
- // The mount's `load`: the guarded body, and the status the same decision answers.
172
- export const load = async ({ params, url }: Omit<AdminPageProps, 'ctx'>) => {
173
- const ctx = adminCtxForRequest();
174
- const data = { body: route.component === null ? null : await route.component({ ctx, params, url }) };
175
- return decideAll(ctx.authz, route.permissions, ctx.actor).allowed ? data : withStatus(403, data);
176
- };
177
- ```
354
+ **The guard is not yours to remember.** `adminRoutes()` hands a host the *screen*, never the
355
+ component you wrote, and `AdminPageProps.ctx` is required by the type — a page component cannot be
356
+ called without the handle the guard decides on. `permissions: []` throws
357
+ `X_ADMIN_PAGE_UNGUARDED` where it is written, not on the first unauthenticated request. A path
358
+ that shadows a generated screen throws `X_ADMIN_PAGE_PATH_INVALID` the same way.
178
359
 
179
- The same page, inside the app's shell, with a 403 (and `noindex`) on the response. `withStatus(404,
180
- …)` is the answer for a row the page cannot find.
360
+ Custom pages are `render: 'ssr'`, `hydrate: 'never'`: the guard runs on the server, so there is
361
+ nothing to ship and nothing to decide twice. A refused one answers 403.
181
362
 
182
363
  ### Splitting the admin across files
183
364
 
184
- `defineAdmin` takes **plain values** — `entities`, `resources`, `actions`, `jobs`, `pages`, `nav`,
185
- `branding`, `auth`, `audit`. Every one of them can be imported from its own module, so the cut is
365
+ `defineAdmin` takes **plain values** — `entities`, `db`, `resources`, `actions`, `jobs`, `pages`,
366
+ `nav`, `branding`, `auth`, `audit`. Every one of them can be imported from its own module, so the cut is
186
367
  along the input's own keys and there is exactly one layout:
187
368
 
188
369
  ```
@@ -192,7 +373,6 @@ app/admin/
192
373
  nav.ts groups, order, extras
193
374
  <resource>/
194
375
  resource.ts the AdminResourceOptions entry (listFields, sensitive, labelField)
195
- repo.ts the AdminRepo binding
196
376
  actions.ts the AdminAction[] for this entity
197
377
  pages/<name>.tsx a custom page component for this entity, if any
198
378
  pages/<name>.tsx an app-wide custom page (ops, reconciliation, deploy)
@@ -208,9 +388,10 @@ shape already is, written down once so two apps do not invent two layouts. `x g
208
388
  | field type + widget | `$meta.kind`, refined by `values` (select) and `references` (reference) |
209
389
  | one line vs prose | `text({ max })` has a length; `text()` does not |
210
390
  | read-only | a **generated** default (`uuid()`, `defaultNow()`, `onUpdateNow()`) or a key column |
211
- | filters, sort | `$meta.index` / `unique` / `primaryKey` — never an unindexed column |
391
+ | filters, sort | `$meta.index` / `unique` / `primaryKey` — never an unindexed column, except the label (the list's search box) |
212
392
  | row address | `$primaryKey[0]`, composite keys included |
213
393
  | validation | `$schema`, the entity's own Standard Schema |
394
+ | write-only | `.sealed()` — in `secretFields`, and in no list a reader iterates |
214
395
 
215
396
  `sensitive`, a fixed `currency` and `labelField` have no entity source and are never guessed: declare them in `resources: { <entity>: { … } }` or they are absent.
216
397
 
@@ -243,6 +424,10 @@ export const mcp = adminMcp({ app: admin, actor: (session) => actorFor(session.t
243
424
  adminMcpTools(admin, ctx); // admin.post.list · admin.post.read · admin.search · admin.action.post.publish
244
425
  ```
245
426
 
427
+ The list tool takes the list screen's own grammar: `scope` by name and `where` as
428
+ `[{ field, op?, value }]`, through the same validator a URL passes — and the resource's `rows`
429
+ narrows what an agent reads whatever it sends.
430
+
246
431
  Opt-in AI panes, each declaring the scope it needs (`aiPanes({ enable: ['anomaly'] })`):
247
432
 
248
433
  | Pane | Scope |
@@ -255,7 +440,7 @@ Panes are off until enabled, and `runAiPane` refuses (never no-ops) without a ru
255
440
 
256
441
  ## Errors
257
442
 
258
- `X_ADMIN_ENTITY_UNKNOWN` · `X_ADMIN_FIELD_UNSUPPORTED` · `X_ADMIN_POLICY_MISSING` · `X_ADMIN_PAGE_UNGUARDED` · `X_ADMIN_PAGE_PATH_INVALID` · `X_ADMIN_DENIED` · `X_ADMIN_TOOL_FORBIDDEN` · `X_ADMIN_INVALID` · `X_DEV_DASHBOARD_IN_PROD` · `X_NOT_IMPLEMENTED` (an unwired `/_x` source, carrying the wiring line).
443
+ `X_ADMIN_ENTITY_UNKNOWN` · `X_ADMIN_FIELD_UNSUPPORTED` · `X_ADMIN_FILTER_INVALID` · `X_ADMIN_ACTION_NOT_APPLICABLE` · `X_ADMIN_MOUNT_MISSING` · `X_ADMIN_POLICY_MISSING` · `X_ADMIN_REPO_UNBOUND` · `X_ADMIN_PAGE_UNGUARDED` · `X_ADMIN_PAGE_PATH_INVALID` · `X_ADMIN_DENIED` · `X_ADMIN_TOOL_FORBIDDEN` · `X_ADMIN_INVALID` · `X_DEV_DASHBOARD_IN_PROD` · `X_NOT_IMPLEMENTED` (an unwired `/_x` source, carrying the wiring line).
259
444
 
260
445
  ### Error classes
261
446
 
@@ -265,10 +450,14 @@ a job boundary the class is gone and the `code` is what survives — match on th
265
450
  | Class | Code | Declared in |
266
451
  |---|---|---|
267
452
  | `AdminActionDuplicateError` | `X_ADMIN_ACTION_DUPLICATE` | `src/errors.ts` |
453
+ | `AdminActionNotApplicableError` | `X_ADMIN_ACTION_NOT_APPLICABLE` | `src/errors.ts` |
268
454
  | `AdminEntityUnknownError` | `X_ADMIN_ENTITY_UNKNOWN` | `src/errors.ts` |
269
455
  | `AdminFieldUnsupportedError` | `X_ADMIN_FIELD_UNSUPPORTED` | `src/errors.ts` |
456
+ | `AdminFilterInvalidError` | `X_ADMIN_FILTER_INVALID` | `src/errors.ts` |
457
+ | `AdminMountMissingError` | `X_ADMIN_MOUNT_MISSING` | `src/errors.ts` |
270
458
  | `AdminPagePathInvalidError` | `X_ADMIN_PAGE_PATH_INVALID` | `src/errors.ts` |
271
459
  | `AdminPageUnguardedError` | `X_ADMIN_PAGE_UNGUARDED` | `src/errors.ts` |
272
460
  | `AdminPolicyMissingError` | `X_ADMIN_POLICY_MISSING` | `src/errors.ts` |
461
+ | `AdminRepoUnboundError` | `X_ADMIN_REPO_UNBOUND` | `src/errors.ts` |
273
462
  | `DevDashboardInProdError` | `X_DEV_DASHBOARD_IN_PROD` | `src/errors.ts` |
274
463
  | `DevSourceUnavailableError` | `X_NOT_IMPLEMENTED` | `src/errors.ts` |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ultimat3/admin",
3
- "version": "22.14.0",
3
+ "version": "23.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",
@@ -15,11 +15,13 @@
15
15
  },
16
16
  "exports": {
17
17
  ".": "./src/index.ts",
18
- "./dev": "./src/dev/index.ts"
18
+ "./dev": "./src/dev/index.ts",
19
+ "./schema": "./src/audit-schema.ts"
19
20
  },
20
21
  "files": [
21
22
  "src",
22
23
  "!src/**/*.test.ts",
24
+ "!src/**/*-fixture.ts",
23
25
  "CLAUDE.md",
24
26
  "README.md",
25
27
  "LICENSE"
@@ -32,20 +34,20 @@
32
34
  "test": "bun test"
33
35
  },
34
36
  "dependencies": {
35
- "@ultimat3/action": "22.14.0",
36
- "@ultimat3/ai": "22.14.0",
37
- "@ultimat3/cache": "22.14.0",
38
- "@ultimat3/core": "22.14.0",
39
- "@ultimat3/db": "22.14.0",
40
- "@ultimat3/entity": "22.14.0",
41
- "@ultimat3/i18n": "22.14.0",
42
- "@ultimat3/jobs": "22.14.0",
43
- "@ultimat3/mcp": "22.14.0",
44
- "@ultimat3/money": "22.14.0",
45
- "@ultimat3/policy": "22.14.0",
46
- "@ultimat3/query": "22.14.0",
47
- "@ultimat3/render": "22.14.0",
48
- "@ultimat3/schema": "22.14.0",
49
- "@ultimat3/ui": "22.14.0"
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"
50
52
  }
51
53
  }