@ultimat3/admin 22.15.0 → 24.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 (97) hide show
  1. package/CLAUDE.md +83 -91
  2. package/README.md +282 -89
  3. package/package.json +19 -17
  4. package/src/action-form.tsx +139 -0
  5. package/src/action-gate.ts +141 -68
  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 +87 -0
  20. package/src/batch-queue.ts +90 -0
  21. package/src/batch.ts +396 -0
  22. package/src/catalog-keys.ts +55 -0
  23. package/src/crud-input.ts +104 -0
  24. package/src/crud-outcome.ts +224 -0
  25. package/src/crud.ts +172 -176
  26. package/src/describe.ts +131 -0
  27. package/src/detail.tsx +115 -43
  28. package/src/dev/data.ts +27 -12
  29. package/src/dev/facts.ts +28 -1
  30. package/src/dev/index.ts +1 -0
  31. package/src/dev/panel-cache.ts +8 -3
  32. package/src/dev/panel-db.ts +37 -5
  33. package/src/dev/panel-jobs.ts +8 -0
  34. package/src/dev/panel-routes.ts +10 -0
  35. package/src/dev/panel-timeline.ts +7 -2
  36. package/src/dev/panel.ts +10 -2
  37. package/src/dev/server.ts +16 -11
  38. package/src/dev/sql-runner.ts +31 -0
  39. package/src/entity-columns.ts +24 -5
  40. package/src/errors.ts +92 -5
  41. package/src/fields.ts +63 -4
  42. package/src/form-decode.ts +132 -0
  43. package/src/form.tsx +96 -49
  44. package/src/index.ts +199 -9
  45. package/src/jobs/fleet-repo.ts +155 -0
  46. package/src/jobs/job-actions.ts +172 -0
  47. package/src/jobs/job-entities.ts +118 -0
  48. package/src/jobs/job-labels.ts +108 -0
  49. package/src/jobs/job-repo.ts +137 -0
  50. package/src/jobs/job-resources.ts +211 -0
  51. package/src/jobs/job-where.ts +170 -0
  52. package/src/jobs/operator.ts +34 -0
  53. package/src/jobs/overview-data.ts +129 -0
  54. package/src/jobs/overview-tab.tsx +61 -0
  55. package/src/jobs/overview.tsx +189 -0
  56. package/src/json-text.ts +52 -0
  57. package/src/keyset-seek.ts +189 -0
  58. package/src/layout.tsx +55 -43
  59. package/src/list-columns.tsx +53 -0
  60. package/src/list-filter-bar.tsx +207 -0
  61. package/src/list-filters.ts +242 -0
  62. package/src/list-request.ts +129 -0
  63. package/src/list-scope-tabs.tsx +60 -0
  64. package/src/list-scope.ts +113 -0
  65. package/src/list.tsx +111 -72
  66. package/src/mcp-tools.ts +57 -13
  67. package/src/mcp.ts +112 -38
  68. package/src/mounts.ts +44 -0
  69. package/src/page-guard.tsx +26 -29
  70. package/src/pagination.ts +10 -4
  71. package/src/policy-bridge.ts +115 -18
  72. package/src/registry.ts +140 -9
  73. package/src/related.ts +105 -0
  74. package/src/relations.ts +240 -0
  75. package/src/renderers.tsx +89 -0
  76. package/src/repo-entity.ts +195 -0
  77. package/src/resource-fields.ts +116 -0
  78. package/src/resource-layout.ts +89 -0
  79. package/src/resource-list.ts +194 -0
  80. package/src/resource.ts +129 -79
  81. package/src/route-config.ts +71 -0
  82. package/src/routes.ts +55 -54
  83. package/src/row-scope-write.ts +130 -0
  84. package/src/row-version.ts +20 -0
  85. package/src/screen-action.tsx +333 -0
  86. package/src/screen-frame.tsx +167 -0
  87. package/src/screen-home.tsx +201 -0
  88. package/src/screen-lookup.tsx +157 -0
  89. package/src/screen-resource.tsx +441 -0
  90. package/src/screen-system.tsx +75 -0
  91. package/src/screens.ts +56 -0
  92. package/src/search.ts +22 -10
  93. package/src/stylesheet-scope.ts +8 -0
  94. package/src/test-ctx.ts +31 -0
  95. package/src/validate.ts +3 -0
  96. package/src/widget-value.ts +36 -8
  97. package/src/widgets.tsx +84 -16
package/README.md CHANGED
@@ -20,15 +20,20 @@ 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 |
24
- | `db` | psql in a tab (read-only; `assertReadOnly` refuses DML), schema + drift (`null` unless a host wires the check) |
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 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 |
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,263 @@ 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 |
44
75
 
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.
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.
79
+
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 on `(sort, id)` — any number of tied values, and NULLs (last ascending, first descending), are reached | 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; `*` is NO scope, not even the default (a related card's "all" link) | no scope has that name; `*` cannot be declared |
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 — a number is decimal digits (no blank, hex or exponent), a date is one the calendar has |
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
+ ```
138
+
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.
60
154
 
61
- export const routes = adminRoutes(admin); // every view `ssr`, gated — an admin screen is per-actor
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 })` — and
183
+ `orgId` when the actor has one, as on `/admin/audit` — newest first, 20 per page, older pages by
184
+ keyset (`?history=`). Statements a detail page issues: 1 for
185
+ the row (2 with `rows`), 1 per related list, 1 per referenced target for the whole page, 1 for
186
+ the history with `postgresAuditLog`.
187
+
188
+ ### Actions: a form, a row state, a batch
189
+
190
+ ```ts
191
+ import type { AdminAction } from '@ultimat3/admin';
192
+ import { t } from '@ultimat3/schema';
193
+
194
+ declare function suspend(id: unknown, reason: unknown): Promise<void>;
195
+
196
+ export const suspendUser: AdminAction = {
197
+ name: 'user.suspend',
198
+ permission: 'users:suspend',
199
+ entity: 'users',
200
+ input: t.object({ reason: t.string.min(3) }), // the action's own form; absent: a confirm only
201
+ when: (row) => row['suspended'] !== true, // the button, each list row, AND the server
202
+ batch: { threshold: 200 }, // or `true`: always inline
203
+ handle: async ({ input }) => suspend(input['id'], input['reason']),
204
+ };
62
205
  ```
63
206
 
207
+ | Piece | Behaviour |
208
+ |---|---|
209
+ | `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 |
210
+ | `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 |
211
+ | `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
+ | `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
+ | `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` |
215
+ | MCP | still ONE tool, `admin.action.<name>`; a batch action's takes `ids: [...]` |
216
+ | `labelKey` | the button's, the form's and the batch bar's label; absent: `admin.action.<name>` |
217
+
218
+ Every key a declaration derives — branding, nav, titles, fields, sections, scopes, columns, action
219
+ labels and their input fields — is `admin.catalogKeys()`, sorted. `x verify`'s `i18n` step asks
220
+ each of the app's catalogs for exactly that list (`X_CATALOG_MISSING_KEYS`).
221
+
222
+ No script, anywhere: row checkboxes join the bar's form by their `form` attribute, and a
223
+ confirmation is a server round trip. An action on a row the actor cannot see — gone, or outside
224
+ its `rows` — is refused whether or not it declares `when`.
225
+
226
+ ### The jobs dashboard
227
+
228
+ Every `defineAdmin()` carries it; the app declares nothing. Four resources over the queue's
229
+ operator surface (`JobIntrospection`), each an ordinary resource with a `repo:`, and one screen:
230
+
231
+ | Path | What | Actions (`job:manage`) |
232
+ |---|---|---|
233
+ | `/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">` | — |
234
+ | `/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` |
235
+ | `/admin/jobs/queues` (`x_job_queues`) | depth per state, paused | pause, resume |
236
+ | `/admin/jobs/tasks` (`x_job_tasks`) | schedule, zone, last fire (`taskFires()`), next fire (`nextTaskRun`), paused | pause, resume, run now |
237
+ | `/admin/jobs/workers` (`x_job_workers`) | identity, host, queues, in flight, heartbeat | — |
238
+
239
+ | Permission | Grants |
240
+ |---|---|
241
+ | `job:read` (`JOB_READ`) | every jobs screen and MCP read; no control renders |
242
+ | `job:manage` (`JOB_MANAGE`) | every action, behind `admin:write` (or `admin:destroy` for remove) — refused on the server when forged |
243
+
244
+ An actor with an `orgId` sees that org's job rows only (`jobRowScope`) and none of the fleet —
245
+ queues, tasks, workers and the overview's figures are every tenant's. Override either with
246
+ `resources: { x_jobs: { rows } }`. MCP: `admin.x_jobs.list`, `admin.x_jobs.read`,
247
+ `admin.x_job_tasks.list`, `admin.action.job.retry`, `admin.action.job.queue.pause`, … — the
248
+ admin's own projection. `/_x/jobs` draws the same overview component over the dev queue.
249
+
250
+ ### The audit log
251
+
252
+ `postgresAuditLog()` writes one row per audited write to `x_admin_audit`, in the same
253
+ transaction as the write (`AuditLog.atomic`) — a CRUD write, an action's handler, a `matching`
254
+ set and a queued batch's enqueues alike, so an entry that cannot be written rolls the work back
255
+ and the log holds one `failed` entry — and reads it back by keyset. An allowed READ is not
256
+ 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
258
+ table is applied at boot with every other framework table (`@ultimat3/cli`'s `FRAMEWORK_SCHEMA`),
259
+ from the leaf module `@ultimat3/admin/schema` (`SQL_ADMIN_AUDIT_TABLE`, `ADMIN_AUDIT_TABLE`) —
260
+ no migration, no screen loaded to install it.
261
+
262
+ ### A sealed column
263
+
264
+ `.sealed()` on the entity is read by nothing here: no list column, no detail row, no filter, no
265
+ search field, no MCP argument. It is in `resource.secretFields` — a list of its own — and the form
266
+ renders it as a password input with no value. Empty on an edit means unchanged; a required one is
267
+ required to create. The audit diff records *that* it changed, as `[redacted]`.
268
+
64
269
  ### The views are TSX, and the pieces come apart
65
270
 
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:
271
+ Every renderer is an ordinary component in an ordinary `.tsx` file importing `@ultimat3/ui` —
272
+ `AdminList`, `AdminForm`, `AdminDetail`, `AdminActions`, `Widget`, `AdminLayout` are each exported,
273
+ so a table can be lifted into a screen the generator never wrote:
70
274
 
71
275
  ```tsx
72
- import { AdminList, AdminForm, Widget } from '@ultimat3/admin';
276
+ import { AdminList, listHref, Widget } from '@ultimat3/admin';
73
277
 
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
278
+ <AdminList resource={resource} page={page} error={null} ctx={ctx} actor={actor} authz={authz}
279
+ basePath={admin.basePath} request={request} scope={scope} counts={counts}
280
+ hrefFor={(location) => listHref(admin.basePath, resource, location)} />;
281
+ <Widget field={field} value={row.total} ctx={ctx} mode="read" />;
76
282
  ```
77
283
 
78
284
  `Widget` takes the field and the raw row value, not pre-derived props: `widgetProps()` is the
79
285
  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`.
286
+ no way to render a cell that skipped it.
287
+
288
+ ### Mounting it yourself
289
+
290
+ `x dev` and the container already do. A host that is neither asks the route table per request:
291
+
292
+ ```ts
293
+ import { adminRouteMatch } from '@ultimat3/admin';
294
+
295
+ const matched = adminRouteMatch(admin, url.pathname); // null → 404
296
+ const answer = await matched.route.respond({
297
+ ctx: await admin.requestCtx(request), params: matched.params, url: url.href,
298
+ method: 'GET', form: null, // or 'POST' + the parsed form
299
+ });
300
+ // { kind: 'document', status, body } — the framed screen — or { kind: 'redirect', location }
301
+ ```
82
302
 
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.
303
+ `respond` is the only way in: the screen decides before it renders, so a host serialises and
304
+ nothing else.
87
305
 
88
306
  ### Custom pages
89
307
 
@@ -114,75 +332,41 @@ defineAdmin({
114
332
 
115
333
  | What you get | How |
116
334
  |---|---|
117
- | a row in `app.routes`, `adminRoutes()`, `x manifest` | `pageRoutes()` folds it in beside the generated screens |
335
+ | 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
336
  | a nav item that disappears for an actor who cannot open it | `NavItem.permissions` → `visibleNav` |
119
337
  | 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` |
338
+ | a per-request refusal, audited, answered 403, before your component runs | `guardedScreen()` wraps it in `decideAll` |
339
+ | the shell — nav, who is acting, search | the same frame every generated screen gets |
121
340
 
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.
130
-
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:
341
+ **Testing one.** A page component needs a `CrudCtx`, and `adminTestCtx()` mints it from a grant
342
+ list — no admin instance, no session:
135
343
 
136
344
  ```ts
137
- import { adminRouteFor } from '@ultimat3/admin';
138
- import { admin } from './admin';
345
+ import { adminTestCtx } from '@ultimat3/admin';
346
+ import { renderView } from '@ultimat3/testing';
139
347
 
140
- const route = adminRouteFor(admin, `${admin.basePath}/users`);
141
-
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') }),
348
+ const view = await renderView(OpsPage, {
349
+ ctx: adminTestCtx({ granted: ['admin:read', 'ops:read'] }),
350
+ params: {},
351
+ url: 'http://localhost/admin/ops',
148
352
  });
149
353
  ```
150
354
 
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
355
+ `x g admin:page <name>` emits exactly this test beside the page.
157
356
 
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.
161
-
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
- ```
357
+ **The guard is not yours to remember.** `adminRoutes()` hands a host the *screen*, never the
358
+ component you wrote, and `AdminPageProps.ctx` is required by the type — a page component cannot be
359
+ called without the handle the guard decides on. `permissions: []` throws
360
+ `X_ADMIN_PAGE_UNGUARDED` where it is written, not on the first unauthenticated request. A path
361
+ that shadows a generated screen throws `X_ADMIN_PAGE_PATH_INVALID` the same way.
178
362
 
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.
363
+ Custom pages are `render: 'ssr'`, `hydrate: 'never'`: the guard runs on the server, so there is
364
+ nothing to ship and nothing to decide twice. A refused one answers 403.
181
365
 
182
366
  ### Splitting the admin across files
183
367
 
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
368
+ `defineAdmin` takes **plain values** — `entities`, `db`, `resources`, `actions`, `jobs`, `pages`,
369
+ `nav`, `branding`, `auth`, `audit`. Every one of them can be imported from its own module, so the cut is
186
370
  along the input's own keys and there is exactly one layout:
187
371
 
188
372
  ```
@@ -192,7 +376,6 @@ app/admin/
192
376
  nav.ts groups, order, extras
193
377
  <resource>/
194
378
  resource.ts the AdminResourceOptions entry (listFields, sensitive, labelField)
195
- repo.ts the AdminRepo binding
196
379
  actions.ts the AdminAction[] for this entity
197
380
  pages/<name>.tsx a custom page component for this entity, if any
198
381
  pages/<name>.tsx an app-wide custom page (ops, reconciliation, deploy)
@@ -208,9 +391,10 @@ shape already is, written down once so two apps do not invent two layouts. `x g
208
391
  | field type + widget | `$meta.kind`, refined by `values` (select) and `references` (reference) |
209
392
  | one line vs prose | `text({ max })` has a length; `text()` does not |
210
393
  | read-only | a **generated** default (`uuid()`, `defaultNow()`, `onUpdateNow()`) or a key column |
211
- | filters, sort | `$meta.index` / `unique` / `primaryKey` — never an unindexed column |
394
+ | filters, sort | `$meta.index` / `unique` / `primaryKey` — never an unindexed column, except the label (the list's search box) |
212
395
  | row address | `$primaryKey[0]`, composite keys included |
213
396
  | validation | `$schema`, the entity's own Standard Schema |
397
+ | write-only | `.sealed()` — in `secretFields`, and in no list a reader iterates |
214
398
 
215
399
  `sensitive`, a fixed `currency` and `labelField` have no entity source and are never guessed: declare them in `resources: { <entity>: { … } }` or they are absent.
216
400
 
@@ -225,6 +409,7 @@ shape already is, written down once so two apps do not invent two layouts. `x g
225
409
  | A button an actor cannot press is never rendered, and the call is refused by the same decision | `action-gate.ts` |
226
410
  | Destructive operations re-confirm (`<entity>:<id>`) and are always audited | `permissions.ts`, `crud.ts` |
227
411
  | Every mutation and every denial is on the audit log, with a before/after diff | `audit.ts` |
412
+ | 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` |
228
413
  | Branding aliases tokens only — `accent: '#7c3aed'` is a compile error | `theme.ts` |
229
414
 
230
415
  Reads are audited too, and on both branches: `adminDetail` keys its entry on the row,
@@ -243,6 +428,10 @@ export const mcp = adminMcp({ app: admin, actor: (session) => actorFor(session.t
243
428
  adminMcpTools(admin, ctx); // admin.post.list · admin.post.read · admin.search · admin.action.post.publish
244
429
  ```
245
430
 
431
+ The list tool takes the list screen's own grammar: `scope` by name and `where` as
432
+ `[{ field, op?, value }]`, through the same validator a URL passes — and the resource's `rows`
433
+ narrows what an agent reads whatever it sends.
434
+
246
435
  Opt-in AI panes, each declaring the scope it needs (`aiPanes({ enable: ['anomaly'] })`):
247
436
 
248
437
  | Pane | Scope |
@@ -255,7 +444,7 @@ Panes are off until enabled, and `runAiPane` refuses (never no-ops) without a ru
255
444
 
256
445
  ## Errors
257
446
 
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).
447
+ `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
448
 
260
449
  ### Error classes
261
450
 
@@ -265,10 +454,14 @@ a job boundary the class is gone and the `code` is what survives — match on th
265
454
  | Class | Code | Declared in |
266
455
  |---|---|---|
267
456
  | `AdminActionDuplicateError` | `X_ADMIN_ACTION_DUPLICATE` | `src/errors.ts` |
457
+ | `AdminActionNotApplicableError` | `X_ADMIN_ACTION_NOT_APPLICABLE` | `src/errors.ts` |
268
458
  | `AdminEntityUnknownError` | `X_ADMIN_ENTITY_UNKNOWN` | `src/errors.ts` |
269
459
  | `AdminFieldUnsupportedError` | `X_ADMIN_FIELD_UNSUPPORTED` | `src/errors.ts` |
460
+ | `AdminFilterInvalidError` | `X_ADMIN_FILTER_INVALID` | `src/errors.ts` |
461
+ | `AdminMountMissingError` | `X_ADMIN_MOUNT_MISSING` | `src/errors.ts` |
270
462
  | `AdminPagePathInvalidError` | `X_ADMIN_PAGE_PATH_INVALID` | `src/errors.ts` |
271
463
  | `AdminPageUnguardedError` | `X_ADMIN_PAGE_UNGUARDED` | `src/errors.ts` |
272
464
  | `AdminPolicyMissingError` | `X_ADMIN_POLICY_MISSING` | `src/errors.ts` |
465
+ | `AdminRepoUnboundError` | `X_ADMIN_REPO_UNBOUND` | `src/errors.ts` |
273
466
  | `DevDashboardInProdError` | `X_DEV_DASHBOARD_IN_PROD` | `src/errors.ts` |
274
- | `DevSourceUnavailableError` | `X_NOT_IMPLEMENTED` | `src/errors.ts` |
467
+ | `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": "22.15.0",
3
+ "version": "24.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.15.0",
36
- "@ultimat3/ai": "22.15.0",
37
- "@ultimat3/cache": "22.15.0",
38
- "@ultimat3/core": "22.15.0",
39
- "@ultimat3/db": "22.15.0",
40
- "@ultimat3/entity": "22.15.0",
41
- "@ultimat3/i18n": "22.15.0",
42
- "@ultimat3/jobs": "22.15.0",
43
- "@ultimat3/mcp": "22.15.0",
44
- "@ultimat3/money": "22.15.0",
45
- "@ultimat3/policy": "22.15.0",
46
- "@ultimat3/query": "22.15.0",
47
- "@ultimat3/render": "22.15.0",
48
- "@ultimat3/schema": "22.15.0",
49
- "@ultimat3/ui": "22.15.0"
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"
50
52
  }
51
53
  }