@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.
- package/CLAUDE.md +83 -91
- package/README.md +282 -89
- package/package.json +19 -17
- package/src/action-form.tsx +139 -0
- package/src/action-gate.ts +141 -68
- 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 +87 -0
- package/src/batch-queue.ts +90 -0
- package/src/batch.ts +396 -0
- package/src/catalog-keys.ts +55 -0
- package/src/crud-input.ts +104 -0
- package/src/crud-outcome.ts +224 -0
- package/src/crud.ts +172 -176
- package/src/describe.ts +131 -0
- package/src/detail.tsx +115 -43
- package/src/dev/data.ts +27 -12
- package/src/dev/facts.ts +28 -1
- package/src/dev/index.ts +1 -0
- package/src/dev/panel-cache.ts +8 -3
- 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-timeline.ts +7 -2
- package/src/dev/panel.ts +10 -2
- package/src/dev/server.ts +16 -11
- package/src/dev/sql-runner.ts +31 -0
- package/src/entity-columns.ts +24 -5
- package/src/errors.ts +92 -5
- package/src/fields.ts +63 -4
- package/src/form-decode.ts +132 -0
- package/src/form.tsx +96 -49
- package/src/index.ts +199 -9
- package/src/jobs/fleet-repo.ts +155 -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 +137 -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/keyset-seek.ts +189 -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 +242 -0
- package/src/list-request.ts +129 -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 +112 -38
- package/src/mounts.ts +44 -0
- package/src/page-guard.tsx +26 -29
- package/src/pagination.ts +10 -4
- package/src/policy-bridge.ts +115 -18
- package/src/registry.ts +140 -9
- package/src/related.ts +105 -0
- package/src/relations.ts +240 -0
- package/src/renderers.tsx +89 -0
- package/src/repo-entity.ts +195 -0
- package/src/resource-fields.ts +116 -0
- package/src/resource-layout.ts +89 -0
- package/src/resource-list.ts +194 -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/row-version.ts +20 -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 +441 -0
- package/src/screen-system.tsx +75 -0
- package/src/screens.ts +56 -0
- package/src/search.ts +22 -10
- 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 -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
|
|
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 {
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
```
|
|
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
|
-
|
|
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
|
-
|
|
67
|
-
|
|
68
|
-
|
|
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,
|
|
276
|
+
import { AdminList, listHref, Widget } from '@ultimat3/admin';
|
|
73
277
|
|
|
74
|
-
<AdminList resource={
|
|
75
|
-
|
|
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.
|
|
81
|
-
|
|
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
|
-
|
|
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.
|
|
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 | `
|
|
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
|
-
**
|
|
123
|
-
|
|
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 {
|
|
138
|
-
import {
|
|
345
|
+
import { adminTestCtx } from '@ultimat3/admin';
|
|
346
|
+
import { renderView } from '@ultimat3/testing';
|
|
139
347
|
|
|
140
|
-
const
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
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
|
-
`
|
|
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
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
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
|
-
|
|
180
|
-
|
|
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`,
|
|
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": "
|
|
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": "
|
|
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": "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
|
}
|