@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.
- package/CLAUDE.md +73 -80
- package/README.md +276 -87
- package/package.json +19 -17
- package/src/action-form.tsx +139 -0
- package/src/action-gate.ts +127 -28
- package/src/action-input.ts +119 -0
- package/src/action-label.ts +9 -0
- package/src/action-row.ts +37 -0
- package/src/actions.tsx +89 -66
- package/src/actor.ts +53 -0
- package/src/admin.module.scss +298 -0
- package/src/admin.ts +187 -23
- package/src/audit-pg.ts +207 -0
- package/src/audit-schema.ts +47 -0
- package/src/audit.ts +109 -22
- package/src/authz.ts +11 -0
- package/src/batch-bar.tsx +66 -0
- package/src/batch-job.ts +152 -0
- package/src/batch-matching.ts +92 -0
- package/src/batch.ts +431 -0
- package/src/catalog-keys.ts +55 -0
- package/src/crud-input.ts +104 -0
- package/src/crud-outcome.ts +166 -0
- package/src/crud.ts +129 -177
- package/src/describe.ts +131 -0
- package/src/detail.tsx +115 -43
- package/src/dev/data.ts +24 -6
- package/src/dev/facts.ts +27 -0
- package/src/dev/panel-db.ts +37 -5
- package/src/dev/panel-jobs.ts +8 -0
- package/src/dev/panel-routes.ts +10 -0
- package/src/dev/panel.ts +6 -0
- package/src/dev/server.ts +15 -7
- package/src/entity-columns.ts +24 -5
- package/src/errors.ts +83 -0
- package/src/fields.ts +63 -4
- package/src/form-decode.ts +91 -0
- package/src/form.tsx +78 -45
- package/src/index.ts +198 -9
- package/src/jobs/fleet-repo.ts +143 -0
- package/src/jobs/job-actions.ts +172 -0
- package/src/jobs/job-entities.ts +118 -0
- package/src/jobs/job-labels.ts +108 -0
- package/src/jobs/job-repo.ts +103 -0
- package/src/jobs/job-resources.ts +211 -0
- package/src/jobs/job-where.ts +170 -0
- package/src/jobs/operator.ts +34 -0
- package/src/jobs/overview-data.ts +129 -0
- package/src/jobs/overview-tab.tsx +61 -0
- package/src/jobs/overview.tsx +189 -0
- package/src/json-text.ts +52 -0
- package/src/layout.tsx +55 -43
- package/src/list-columns.tsx +53 -0
- package/src/list-filter-bar.tsx +207 -0
- package/src/list-filters.ts +217 -0
- package/src/list-request.ts +127 -0
- package/src/list-scope-tabs.tsx +60 -0
- package/src/list-scope.ts +113 -0
- package/src/list.tsx +111 -72
- package/src/mcp-tools.ts +57 -13
- package/src/mcp.ts +108 -38
- package/src/mounts.ts +44 -0
- package/src/page-guard.tsx +26 -29
- package/src/policy-bridge.ts +110 -15
- package/src/registry.ts +138 -8
- package/src/related.ts +105 -0
- package/src/relations.ts +224 -0
- package/src/renderers.tsx +89 -0
- package/src/repo-entity.ts +204 -0
- package/src/resource-fields.ts +116 -0
- package/src/resource-layout.ts +89 -0
- package/src/resource-list.ts +185 -0
- package/src/resource.ts +129 -79
- package/src/route-config.ts +71 -0
- package/src/routes.ts +55 -54
- package/src/row-scope-write.ts +130 -0
- package/src/screen-action.tsx +333 -0
- package/src/screen-frame.tsx +167 -0
- package/src/screen-home.tsx +201 -0
- package/src/screen-lookup.tsx +157 -0
- package/src/screen-resource.tsx +393 -0
- package/src/screen-system.tsx +69 -0
- package/src/screens.ts +56 -0
- package/src/search.ts +13 -9
- package/src/stylesheet-scope.ts +8 -0
- package/src/test-ctx.ts +31 -0
- package/src/validate.ts +3 -0
- package/src/widget-value.ts +36 -8
- 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 {
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
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
|
-
|
|
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
|
-
|
|
67
|
-
|
|
68
|
-
|
|
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,
|
|
273
|
+
import { AdminList, listHref, Widget } from '@ultimat3/admin';
|
|
73
274
|
|
|
74
|
-
<AdminList resource={
|
|
75
|
-
|
|
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.
|
|
81
|
-
|
|
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
|
-
|
|
84
|
-
|
|
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 | `
|
|
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
|
-
|
|
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 {
|
|
138
|
-
import {
|
|
139
|
-
|
|
140
|
-
const route = adminRouteFor(admin, `${admin.basePath}/users`);
|
|
342
|
+
import { adminTestCtx } from '@ultimat3/admin';
|
|
343
|
+
import { renderView } from '@ultimat3/testing';
|
|
141
344
|
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
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
|
-
`
|
|
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
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
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
|
-
|
|
180
|
-
|
|
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`,
|
|
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": "
|
|
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": "
|
|
36
|
-
"@ultimat3/ai": "
|
|
37
|
-
"@ultimat3/cache": "
|
|
38
|
-
"@ultimat3/core": "
|
|
39
|
-
"@ultimat3/db": "
|
|
40
|
-
"@ultimat3/entity": "
|
|
41
|
-
"@ultimat3/i18n": "
|
|
42
|
-
"@ultimat3/jobs": "
|
|
43
|
-
"@ultimat3/mcp": "
|
|
44
|
-
"@ultimat3/money": "
|
|
45
|
-
"@ultimat3/policy": "
|
|
46
|
-
"@ultimat3/query": "
|
|
47
|
-
"@ultimat3/render": "
|
|
48
|
-
"@ultimat3/schema": "
|
|
49
|
-
"@ultimat3/ui": "
|
|
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
|
}
|