@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/CLAUDE.md
CHANGED
|
@@ -1,118 +1,110 @@
|
|
|
1
1
|
# @ultimat3/admin — boundary
|
|
2
2
|
|
|
3
|
-
Tier 5. May import tiers 0–4. Exactly one importer: `@ultimat3/cli
|
|
3
|
+
Tier 5. May import tiers 0–4. Exactly one importer: `@ultimat3/cli` — `cli → admin` is a declared sideways edge (`scripts/lib/tiers.ts`). It imports `@ultimat3/admin/dev` to **mount** `/_x`, and the root barrel only DYNAMICALLY and only when an app declared an admin (`packages/cli/src/runtime-admin.ts`).
|
|
4
4
|
|
|
5
5
|
Two products, one package, **two entry points**: `@ultimat3/admin/dev` (`src/dev/index.ts`) = the `/_x` dev dashboard, `@ultimat3/admin` (`src/index.ts`) = the generated app admin (production, authz'd, AI-first). Keep them apart; nothing in `src/dev/` may be imported by an admin view, and the root barrel does not re-export the dev half — a host that mounts `/_x` (the CLI) must not load a Solid component to do it.
|
|
6
6
|
|
|
7
7
|
## Rules
|
|
8
8
|
|
|
9
9
|
- **One authz.** `AdminAuthz` (`authz.ts`) is the only decision path. `action-gate.ts`, `crud.ts` and `page-guard.tsx` call `decideAll`; views render what the gate returned. Never add a second check in a view or an MCP handler.
|
|
10
|
-
- **The subject carries the tenant and the loaded row.** `AdminActor.orgId` reaches `policyActor` and `AdminSubject.row` reaches `evaluate
|
|
11
|
-
- **The MCP caller is the ambient actor for the whole call.** `mcp.ts` wraps `callAdminTool` in a child context (
|
|
12
|
-
-
|
|
13
|
-
- **
|
|
14
|
-
- **
|
|
15
|
-
- **A
|
|
16
|
-
- **
|
|
17
|
-
- **
|
|
10
|
+
- **The subject carries the tenant and the loaded row.** `AdminActor.orgId` reaches `policyActor` and `AdminSubject.row` reaches `evaluate`; `adminDetail`/`adminUpdate`/`adminDestroy` load the row BEFORE the guard. `undefined` = not loaded (left off the subject), `null` = looked and found none (a row rule fails closed). Pinned in `policy-bridge.test.ts`, which asserts the SUBJECT a policy receives.
|
|
11
|
+
- **The MCP caller is the ambient actor for the whole call, tenant included.** `mcp.ts` wraps `callAdminTool` in a child context (a fresh root one over stdio); `resolveToken` and `adminActorOf` both carry `orgId`. Pinned in `mcp-context.test.ts`, `mcp-tenant.test.ts`.
|
|
12
|
+
- **`defineAdmin({ entities, db })` is a served admin; a host writes nothing.** `routes.ts` gives EVERY route a screen (`screens.ts` — a `Record<AdminView, …>`, so a new view cannot mount with nothing behind it) and `AdminRouteConfig.respond(request)` is the only way in: GET or a posted form in, `{ status, body }` or a 303 out. `repo-entity.ts` is the ONE adapter from the typed handle to `AdminRepo`; it adds no predicate (tenancy, soft delete, sealing and invariants are the handle's) and never spreads a row. A resource with neither a table in `db` nor a `repo:` is `X_ADMIN_REPO_UNBOUND` at `defineAdmin`. Pinned in `mounted-screens.test.ts`, `repo-entity.test.ts`, `admin-bind.test.ts`.
|
|
13
|
+
- **A screen's guard is composed, never written.** A resource screen asks `crud.ts` FIRST and renders what it answered — only it holds the row a row-level rule reads. Every other screen (a `pages:` entry, dashboard, jobs, audit, search) is built through `guardedScreen()` (`screen-frame.tsx`): `decideAll` over the route's permissions, the refusal audited (`auditRefusal`), answered 403. `pages.ts` composes `[admin:read, …declared]`; `AdminPageProps.ctx` is required by the type; an empty permission list is `X_ADMIN_PAGE_UNGUARDED` at `defineAdmin`.
|
|
14
|
+
- **No screen ships a script.** Every route is `ssr` + `hydrate: 'never'`, so a control is a link or a native form: `<Pagination hrefFor>`, `<DataTable sortHrefFor>`, scope tabs as links, the filter bar one GET form, a form posted at the URL that rendered it, an action posted at its ROW's URL (`_operation`). `form-decode.ts` is the one place posted strings become typed input, per widget — an empty box of a typed widget (money, number, instant, JSON) is `null`, never `''`.
|
|
15
|
+
- **A list's state is its URL, and a parameter the resource does not derive is REFUSED.** `list-request.ts` is the one parser and builder — `?cursor=`, `?sort=<field>:<asc|desc>`, `?scope=<name>` (`*` = none, not even the default; undeclarable), `?f.<field>[.<op>]=<value>` (repeat for `in`; empty = absent). `list-filters.ts` owns the operators per filter shape and the ONE validator (`checkedFilter`) the URL and the MCP list tool's `where` both pass. Unknown field, operator, sort, scope or parameter, or a value that is not the column's type: `X_ADMIN_FILTER_INVALID`, a 400 naming what the list answers — never the unfiltered list.
|
|
16
|
+
- **One composition of predicates** (`list-scope.ts`): row scope (`rows`), then scope, then filters. Every read of a resource builds its `where` there, and every read of ONE row is `findRow` — never `repo.find` from a screen, `crud.ts` or `mcp.ts` — so a row outside `rows(actor)` is "not found" on list, search, detail, edit, action, lookup, labels and MCP alike (`row-scope.test.ts`). App-declared predicates (`scopes`, `rows`) pass `declaredFilters`: any column, never a sealed one.
|
|
17
|
+
- **A widget never reads a row.** What a page shows of the resources it references is read ONCE per target by `relations.ts` (`relationsFor`: one statement, two for a picked target over `LOOKUP_SELECT_MAX`) and handed down as `WidgetContext.labelFor` / `optionsFor` / `lookupHref`. A target the actor may not list is an id with no name. `list-relations.test.ts` asserts the statement count at the driver. The picker is server-rendered: a `<select>` for a small target, else an id box + the target's lookup screen (`/admin/<resource>/lookup`, `screen-lookup.tsx`, `return` held to the admin's own paths).
|
|
18
|
+
- **A sealed column is never a filter, a scope or row-scope predicate, a label, a lookup key or a computed column's input** — `.sealed({ lookup: true })` included: a filter value rides a URL, and a URL is logged. `computedRow` drops sealed names from the row a column's `value` is handed.
|
|
19
|
+
- **The tenant column is the handle's, never an input** (`crud-input.ts`): not a form field, a list column or a filter; a create is stamped with the ACTOR's tenant and a patch never carries the column.
|
|
20
|
+
- **Computed columns are a closed renderer set** (`renderers.tsx`: `badge`, `relative-time`, `money`, `truncate`, `link`, `json`) or a component; `AdminComputedColumn` is typed over `AdminRow` so a resource stays covariant in `Row`.
|
|
21
|
+
- **A page seeks on the PAIR `(sort, id)`** (`keyset-seek.ts`): the rest of the boundary's class by id, then past it, then the NULLs (last ascending, first descending), so any number of ties and a nullable sort column are positions; the cursor carries `null`, never `''`. An instant's class is its MILLISECOND — a row's `Date` drops Postgres' microseconds — read whole by id. `repo-entity-keyset(.contract).test.ts` walks to the end and back.
|
|
22
|
+
- **A read by a mistyped id is a row that does not exist** — `repo-entity.ts`'s `find` answers `null` (the admin's 404); a write keeps the throw. An instant compared by the handle is a `Date`, never ISO text: the memory driver orders a string against a `Date` by its characters.
|
|
23
|
+
- **Every admin document is `noindex, nofollow`**, a refusal and a 404 included (`route-config.ts`).
|
|
24
|
+
- **A sealed column is written, never read.** `adminColumnsOf` skips `$meta.sealed`, so no field any screen reads by name exists for it; `resource.secretFields` is touched only by the form (a password input, never a `value`) and `crud.ts` (`splitSecrets`: empty = unchanged). The audit diff is derived from that list, not from what a row enumerates. Pinned in `sealed-egress.test.ts`.
|
|
25
|
+
- **The mount registry is on the global symbol registry** (`mounts.ts`, `Symbol.for('ultimate.admin.mounts')`), so the CLI can ask "did this app declare an admin?" without importing this package.
|
|
26
|
+
- **One `AdminAction.name`, one handler** — the MCP tool name, the default label key and `callAdminTool`'s lookup. A duplicate is `X_ADMIN_ACTION_DUPLICATE` at `defineAdmin`; identity, not name, is what "already seen" means.
|
|
27
|
+
- **A `/_x` source reads a registry through its DESCRIPTOR TYPE, never as an untyped bag** — `import type { RouteDescriptor }` beside the dynamic `import()`; `dev/published-keys.test.ts` walks what the registries EMIT. A route fact carries `mount` (`{ by, permissions }` or `null`).
|
|
28
|
+
- **A fact `/_x` cannot know is absent or `null` — never a value that happens to be constant.** `JobDescriptor.idempotent` is published *because* `job()` refuses a definition without a key. `DbPanelData.drift` is `null` when nothing wired the check, because drift is the entities against the DATABASE and `[]` claims a match nobody verified.
|
|
29
|
+
- **The routes are in the framework's ONE route list, as mounted routes** — `route-config.ts` calls `registerMountedRoutes` at `defineAdmin`, so `describeRoutes()` (`x routes`, `/_x`, `sw.js`, the manifest) lists each with `mount: { by: 'defineAdmin', permissions }` and `routeEntries()` holds none. `AdminApp.describe()` (`describe.ts`) is what the manifest's `admin` section records: filters, sorts, scopes, `rowScoped`, routes.
|
|
30
|
+
- **The admin's stylesheets are claimed for `app/`** (`stylesheet-scope.ts`, `claimStylesheets`). `stylesheet-scope.test.ts`.
|
|
31
|
+
- **Importing this package declares NO permission.** `defineAdmin()` calls `declareAdminPermissions`, which registers `ADMIN_PERMISSIONS` plus what the mount derives (`policy-bridge.test.ts`).
|
|
32
|
+
- **One bridge per foreign package.** `policy-bridge.ts` is the only file calling `evaluate`/`definePermissions`; `route-config.ts` the only one calling `defineRoute`/`registerMountedRoutes`; `mcp.ts` the only one calling `defineAppMcp`; `dev/data.ts` the only one importing introspection (dynamically — `/_x` must stay out of the production graph). Source only: a **test** declares the permissions its own `can()` fixtures use, and calls `defineAdmin()` in `beforeAll`, NEVER at module scope — the leak guard keeps what a file's module scope registered for every later file, so one module-scope admin closed the permission set for `bun test packages/admin packages/query`.
|
|
18
33
|
- **One entity surface** (`registry.ts`) — the admin reads what `entity()` actually exposes: `$name`, `$primaryKey`, `$columns[c].$meta`, `$schema`, `$describe()`. It is a structural subset so a new column kind still derives, and `RegisteredEntity` is the `tsc`-checked proof that a real `entity()` result satisfies it — never a comment claiming it does.
|
|
19
34
|
- **One flattener.** `entity-columns.ts` is the only file that reads `$meta` or calls `$describe()`; everything downstream takes `AdminColumnFacts`. Money stays one property (the admin renders rows, not tables), a FK target comes back resolved from `$describe()`, and only a **generated** default (`uuid`, `now`) is read-only — `.default('free')` is a starting value.
|
|
20
|
-
- **A deliberate query loop declares itself.** `search.ts` runs one indexed lookup per text field on purpose — the query IR is a conjunction, so three small indexed reads beat one unindexed `OR` — and says so through `expectedQueryLoop()` from `@ultimat3/db` (
|
|
21
|
-
- **The timeline panel renders the detector's verdicts
|
|
22
|
-
- **This catalog is the framework's one opt-OUT MCP surface,
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
-
|
|
29
|
-
- **
|
|
30
|
-
- **
|
|
31
|
-
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
- **
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
at boot.**
|
|
52
|
-
- **The audit diff is TOTAL over every value a row holds.** `same()` compared with
|
|
53
|
-
`JSON.stringify`, which THROWS on a bigint — and `money()` puts one on the row
|
|
54
|
-
(`widget-value.ts`), so every update of a money-bearing row raised, AFTER `repo.update()` had
|
|
55
|
-
committed: the write landed, the caller got an uncoded `TypeError`, and the log stayed empty.
|
|
56
|
-
`canonicalJson` from `@ultimat3/core`, the same answer `packages/manifest/src/diff-routes.ts`
|
|
57
|
-
gives to the same question. `crud.test.ts`'s fixture entity carries a `money()` column and its
|
|
58
|
-
repo CLONES on read, because two reads of one row are two objects and a shared reference
|
|
59
|
-
short-circuits the comparison the diff exists to make.
|
|
60
|
-
- **A repo that THROWS leaves a `failed` entry.** `AuditOutcome` declared the member and `crud.ts`
|
|
61
|
-
emitted it in exactly one place — `invalid()`, for a validation issue — so a constraint
|
|
62
|
-
violation, a statement that timed out after committing and a dropped connection each left nothing
|
|
63
|
-
at all. `auditedWrite` wraps the three repo writes: try, record, re-throw UNCHANGED. A mutation
|
|
64
|
-
cannot append BEFORE the call the way `search.ts` does for a read; that would record a write
|
|
65
|
-
which may never have happened. The reason is a key (`admin.audit.write-failed`), never anything
|
|
66
|
-
read off the thrown value.
|
|
67
|
-
- **Nothing is read off a caught value in `action-gate.ts`, and the append runs first.** It built an
|
|
68
|
-
`AdminDecision` whose `trace` was `String(error)` — and a `catch` binding is annotated by nobody,
|
|
69
|
-
so `Object.create(null)` raised `TypeError: No default value` from inside the block that owed the
|
|
70
|
-
auditor an entry: measured, ZERO entries and the caller received the TypeError. The decision
|
|
71
|
-
object was DEAD anyway (only its `reason` was ever read; `append` takes no trace), so there is no
|
|
72
|
-
destination for a rendered value and `renderThrowable` is not needed either.
|
|
73
|
-
- **`decideAll`'s and `/_x`'s record indexing is `Object.hasOwn` / a `Map`, never a bare index.**
|
|
74
|
-
`ADMIN_PERMISSION_SPEC[permission]` consulted the PROTOTYPE CHAIN, so a polluted
|
|
75
|
-
`Object.prototype` gave any granted permission an `implies` the table never declared — and
|
|
76
|
-
`expandPermissions` walks it. `panel-cache.ts`, `panel-routes.ts` and `panel-policy.ts` counted
|
|
77
|
-
into plain objects, where `__proto__` reads a prototype (so `?? 0` never fires) and WRITES through
|
|
78
|
-
the setter, dropping the row: the policy matrix reported a permission unreachable while an actor
|
|
79
|
-
held it. `Map` + `Object.fromEntries`, which DEFINES each key.
|
|
80
|
-
- **A `/_x` panel orders by CODE UNIT, never `localeCompare`.** `dev/panel-routes.ts` sorted its
|
|
81
|
-
rows with it, and with no locale argument `localeCompare` answers from the runtime's ICU default
|
|
82
|
-
locale and collation version — so the same route table reads in a different order on a
|
|
83
|
-
developer's machine than in the container the same build runs in, and a screenshot of one does
|
|
84
|
-
not match the other. `(a < b ? -1 : a > b ? 1 : 0)`, inlined: `@ultimat3/render`'s `byCodeUnit`
|
|
85
|
-
is package-internal, and a comparator is not worth widening a barrel for.
|
|
86
|
-
- **The locale picker reads `registeredLocales()`, not a bundled list.** It was
|
|
87
|
-
`['en','es','de','fr','pt','ja']`, one line under a comment forbidding exactly that for IANA
|
|
88
|
-
zones: an app registering `it` could not pick it, and an app with only `en` was offered five
|
|
89
|
-
locales it renders `⟦key⟧` for. No fallback — an app with no catalog has no locale to offer, and
|
|
90
|
-
inventing one is the admin declaring what the app did not.
|
|
91
|
-
- **One read-only SQL guard, and it is `@ultimat3/mcp`'s — the whole verdict, with nothing held back.** `dev/panel-db.ts` calls `assertReadOnlyQuery` (tier 5 → tier 4, already a dependency) rather than keeping a second keyword scan: that guard also refuses a batch, a call into the `pg_read_*`/`pg_advisory_*`/`pg_sleep`/`set_config` families, `FOR UPDATE`, and a delimiter that never closes in all five forms (`'`, `E'`, `"`, `$tag$`, slash-star). A local unterminated-delimiter refusal lived here for one revision and was **deleted**: it tested for a surviving `'`/`"`, so it covered three of the five and called a dollar-quoted body "a quote" — one failure mode, two explanations, and a second detector for a property the guard below already tests. What stays local is the emptiness test and the **way out**: `@ultimat3/mcp` tells its caller to expose an action, which a developer at `/_x` cannot act on. The panel says "Fix the statement, or — if it is meant to write — run it with `x db psql --write`", conditional on purpose: `--write` grants writes, it does not close a delimiter, and it used to be printed as *the* fix for a syntax error.
|
|
92
|
-
- **Every admin operation is audited, reads included.** `adminList` was the one call that logged nothing in either direction; `ListResult` now carries its `AuditEntry` on both branches, keyed on the table (`entityId: null`), because the subject of a listing is not a row. `adminSearch` was the second, and it read rows out of EVERY readable entity: `AdminSearchResult.audit` now carries one entry per resource it decided about — `allowed` per searched resource, a `deniedDraft` per refused one. A resource skipped for having no text field or no repo is not an authz event and writes none.
|
|
35
|
+
- **A deliberate query loop declares itself.** `search.ts` runs one indexed lookup per text field on purpose — the query IR is a conjunction, so three small indexed reads beat one unindexed `OR` — and says so through `expectedQueryLoop()` from `@ultimat3/db` — as do `batch.ts` (one row at a time) and `relations.ts` (label reads chunked at 200 ids, never cut). That is the one suppression mechanism: never a comment pragma and never a list of exempt call sites. A new loop here either argues for itself in a `reason` or it is an N+1.
|
|
36
|
+
- **The timeline panel renders the detector's verdicts** (`DevSources.statementLoops()`), never a second count; none wired is `null`, never `[]`.
|
|
37
|
+
- **This catalog is the framework's one opt-OUT MCP surface, deliberately**: `mcp-tools.ts` does not call `isMcpExposed` — every tool is already gated on an admin permission. `mcp: { expose: false }` withdraws one. A *second* exception anywhere is the bug.
|
|
38
|
+
- **`dev/panel-db.ts`'s `sanitize` decides "did they type a statement", not "is it safe"** — never cite it as a security property.
|
|
39
|
+
- **A dev panel catches only the error that means "not wired".** `DevSourceUnavailableError` and nothing wider — in `panel-live`, `panel-db`, `panel-timeline` and `panel-cache` alike (`dev/panel-degrade.test.ts`). Everything else reaches `panelPayload`, which renders its code and its fix.
|
|
40
|
+
- **What an entity does not declare, the admin does not invent.** `sensitive`, a fixed `currency` and `labelField` come from `AdminResourceOptions` or they are absent. Same rule for a URL: the reference widget links through `WidgetContext.hrefFor` or renders plain text — the route table is `AdminApp`'s.
|
|
41
|
+
- **`panel-db.ts` executes the string `assertReadOnlyQuery` RETURNS**, never the textarea's bytes (`ReadOnlyVerdict`).
|
|
42
|
+
- **`decideAll([])` DENIES** (`admin.policy.none-declared`): an empty gate is a refusal for everyone, never a grant.
|
|
43
|
+
- **Two resources may not claim one `path:`** — `X_ADMIN_PAGE_PATH_INVALID` (`subject: 'resource'`) at `defineAdmin`, every generated path included.
|
|
44
|
+
- **The audit diff is TOTAL over every value a row holds** — compared with core's `canonicalJson`, never `JSON.stringify` (a `money()` bigint throws there, after the write committed).
|
|
45
|
+
- **A write and its entry commit together** (`atomicallyAudited`, `crud-outcome.ts`): a CRUD write, an action's handler, `matching` and a queued batch's enqueues each run inside `audit.atomic` with their `allowed` entry, so an entry that cannot be written rolls the work back; ONE `failed` entry is appended after, outside, and the thrown value is re-thrown UNCHANGED with nothing read off it. A schema refusal and a missing row are `failed` too. `audit-atomic(.contract).test.ts`.
|
|
46
|
+
- **An edit is optimistic** (`row-version.ts`): the form posts `_version` = `rowVersion(row)` (keyed), `adminUpdate(…, { version })` answers `stale` — 409, `admin.error.row-changed`, the form re-drawn over the row as it is now — when the row moved on; a post with no version is stale. An instant posted back as it was drawn (`datetimeInputValue`) is LEFT OUT of the patch (`decodeForm(…, before)`): `datetime-local` holds minutes. A create or update is decided again on the validated values as `subject.input` (update: beside `row`).
|
|
47
|
+
- **The audit trail is the actor's tenant's**: `/admin/audit` and the history card pass `orgId` to `entries()` when the actor has one (`audit-tenancy.test.ts`).
|
|
48
|
+
- **A table read by a runtime key is `Object.hasOwn` / a `Map`, never a bare index** — `impliedBy`, the `/_x` counters, `list-filters.ts`' operator table, `renderers.tsx` (`bun run proto-index`).
|
|
49
|
+
- **The `/_x` SQL runs in `readOnlyQuery`** (`dev/sql-runner.ts`, `readOnlySql(client)` — what a host wires as `runSql`): read-only transaction, timeout, `DEV_SQL_MAX_ROWS`.
|
|
50
|
+
- **One read-only SQL guard, and it is `@ultimat3/mcp`'s** — no local keyword scan; the panel keeps only the emptiness test and the way out (`psql "$DATABASE_URL"`, the developer's own client — `x db` has no `psql`).
|
|
51
|
+
- **Every admin operation is audited, reads included.** `ListResult` carries its `AuditEntry` on both branches, keyed on the table (`entityId: null`). `AdminSearchResult.audit` carries one entry per resource it decided about — `allowed` per searched resource, a `deniedDraft` per refused one; a resource skipped for no text field, no repo or a repo that THREW (`admin.search.skipped.failed`) is listed in `skipped`, never a 500 for the rest.
|
|
52
|
+
- **A write's RESULT satisfies the actor's row scope** (`row-scope-write.ts`): a create, and an update over the fields it touches, outside `rows(actor)` is refused before the repo — `admin.error.row-out-of-scope`, audited as a denial. The one predicate evaluator in this package; an ordering over text is refused, never guessed. `row-scope-write.test.ts`.
|
|
53
|
+
- **An action's `when(row)` is asked by the button AND by the call** (`actionApplies`, `action-gate.ts`): a row it excludes is `X_ADMIN_ACTION_NOT_APPLICABLE` (409 on a screen, the code over MCP), audited `admin.error.action-not-applicable`. The row it reads carries no sealed column. Every action on one row runs through `invokeRowAction` (`findRow` first).
|
|
54
|
+
- **An action with an `input` schema is a form, never a bare post**: its button links to `<row>?action=<name>`, the form posts `input.<field>` (`action-input.ts`, through `tryIntrospect`), the gate validates against the schema and a refusal is a 422 with issues on their fields. No schema: a confirm only.
|
|
55
|
+
- **A batch is the button's gate once per row** (`batch.ts`): policy, `when`, handler; counts `done`/`refused`/`failed`/`queued`/`remaining`; one audit entry per row. Selection is checked ids or "all matching" — the list URL's own `listWhere`, keyset by id, `MAX_BATCH_ROWS` inline / `MAX_BATCH_QUEUED_ROWS` queued per request. `batch: { threshold }` queues one `admin.batch` job per chunk (`batch-queue.ts`, `batch-job.ts`) under a DERIVED `batchIdOf` (actor, action, rows, input — not the request id), so a retry dedupes; the worker runs it AS the operator. Zero JS: the bar is one form (`batch-bar.tsx`), row checkboxes join it by `form=`; a destructive or input-taking batch is a server round trip. ONE MCP tool per action — a batch action's tool takes `ids`.
|
|
56
|
+
- **Sections and form groups never hide a field** (`resource-layout.ts`): undeclared fields fall into a default group drawn last; a name that is not a drawn field, or claimed twice, is refused at `defineAdmin`. `on: 'create' | 'update'` is enforced on the write too (`onlyFor`, `crud-input.ts`).
|
|
57
|
+
- **A related list IS the related resource's list** (`related.ts`): a `hasMany` of the entity, read through `adminList` (its gate, its `rows`), filtered to this row; a resource the actor may not list is left out. One label read per target for the whole detail page.
|
|
58
|
+
- **The audit log is the store, a sink is a copy.** `AuditLog.entries()` is async, keyset (`before`), `changes: true` drops allowed reads; `atomic(run)` puts a write and its entry in one transaction (`auditedWrite`). `postgresAuditLog()` writes `x_admin_audit` (`audit-pg.ts`), skips allowed reads unless `reads: true`. `audit-pg.contract.test.ts` boots PGlite.
|
|
59
|
+
- **The jobs dashboard is every admin's, built from these pieces only** (`jobs/`): four resources — `x_jobs`, `x_job_queues`, `x_job_tasks`, `x_job_workers` — each with a `repo:` over `JobIntrospection` (`job-repo.ts`, `fleet-repo.ts`), scopes, a row scope and `job-actions.ts`'s `AdminAction`s, plus ONE screen (`overview.tsx`, the view `jobs`). `job:read` lists them (the resources' `permission` noun is `job`), `job:manage` runs every action. An actor with an `orgId` sees that org's job rows and none of the fleet. `jobs/boundary.test.ts` refuses a `fetch(`, a socket, an event stream or a hand-written table there; `/_x/jobs` draws `JobsOverview` over the dev queue (`dev/panel-jobs.ts` → `jobs/overview-tab.tsx`, dynamically).
|
|
60
|
+
- **A job-store predicate the store cannot answer is refused by name** (`job-where.ts`, `X_ADMIN_FILTER_INVALID`): one order (`createdAt desc`), equality on state/queue/name/tenant, an id PREFIX for the id box. `listScreen` answers a repo's refusal 400, as the URL's. "All matching" over jobs is `AdminAction.matching` onto the store's bulk verbs, never a walk.
|
|
61
|
+
- **A resource's permissions are named after its `permission` noun** (`resource.permission`, default the entity name) — every `permissionsForOperation` site reads it, never `resource.name`.
|
|
62
|
+
- **`matching` is "all matching" as ONE call** (`batch-matching.ts`): the list's `where`, gated by the batch's grant, confirmed as `<entity>:all matching`, audited once (`admin.batch.matching`). A checked selection never reaches it.
|
|
63
|
+
- **An action on a row the actor cannot see is refused, `when` or none** (`actionApplies`: a `null` row is no row).
|
|
64
|
+
- **An action's input field is labelled `admin.input.<action>.<field>`** — never under `admin.action.<action>`, which is a leaf: a JSON catalog cannot hold both.
|
|
65
|
+
- **`x_admin_audit`'s DDL is the leaf `audit-schema.ts`** (`@ultimat3/admin/schema`): the boot applies it in every role; `serve-graph.test.ts` exempts exactly that file.
|
|
93
66
|
- Money through `assertMoney`, timestamps through `assertZone`, pagination through `pagination.ts`. No `offset`, ever.
|
|
94
67
|
- **One cursor codec.** `pagination.ts` is the only file calling core's `encodeCursor`/`decodeCursor`; it wraps them as `encodeAdminCursor`/`decodeAdminCursor`, scoped `admin:<resource>`. An invalid cursor is page one here, not an error page — but the signature is checked first, so a forged one cannot seek.
|
|
95
|
-
- Labels are i18n keys derived in `resource.ts` (`admin.<entity>.field.<name>`); only `.tsx` calls `t()`. MCP tool descriptions are literal English (protocol payload, not UI copy).
|
|
68
|
+
- Labels are i18n keys derived in `resource.ts` (`admin.<entity>.field.<name>`); only `.tsx` calls `t()`. An action's is `actionLabelKey` (`action-label.ts`), never respelled. `AdminApp.catalogKeys()` (`catalog-keys.ts`) is every derived key — the CLI's `i18n` step audits exactly that list. MCP tool descriptions are literal English (protocol payload, not UI copy).
|
|
96
69
|
- Colours only via `ThemeTokenRef` (`--x-*`). Raw hex does not typecheck.
|
|
97
|
-
- **One owner of `globalThis.React
|
|
98
|
-
-
|
|
70
|
+
- **One owner of `globalThis.React` is `@ultimat3/ui`'s** (`probe`/`unprobe`); `inert-jsx.ts` keeps only the walkers. `inert-jsx.test.ts` drives both install orders.
|
|
71
|
+
- **A `/_x` panel orders by CODE UNIT, never `localeCompare`**; **the locale picker reads `registeredLocales()`**, never a bundled list.
|
|
72
|
+
- Views are pure functions of props — no `createSignal`, no local state, no event handler.
|
|
73
|
+
- **No test reaches a `.tsx` statically.** `routes.ts` reaches the screens, so a test does `await import('@ultimat3/render/server')` and then `await import('./routes')`: a static import compiles JSX before the loader exists and every later render dies with `React is not defined`.
|
|
99
74
|
|
|
100
75
|
## Layout
|
|
101
76
|
|
|
102
77
|
| File | Owns |
|
|
103
78
|
|---|---|
|
|
104
|
-
| `admin.ts` | `defineAdmin` → resources, nav, route table, audit, authz |
|
|
105
|
-
| `pages.ts` / `page-guard.tsx` | a `pages:` entry → route + nav item (pure) / the
|
|
106
|
-
| `routes.ts` | the
|
|
79
|
+
| `admin.ts` / `mounts.ts` / `actor.ts` | `defineAdmin` → resources, repos, nav, route table, audit, authz / the registry a host mounts from / the default actor |
|
|
80
|
+
| `pages.ts` / `page-guard.tsx` | a `pages:` entry → route + nav item (pure) / the refusal a screen renders and audits |
|
|
81
|
+
| `route-config.ts` / `routes.ts` / `screens.ts` | the render bridge: `defineRoute`, `registerMountedRoutes` / route → screen, `adminRouteMatch` / view → screen |
|
|
82
|
+
| `screen-frame.tsx` / `screen-resource.tsx` / `screen-lookup.tsx` / `screen-home.tsx` / `screen-system.tsx` | request/response shape, shell, `guardedScreen` (a body's refused URL is a 400), `widgetContextOf` / list·detail·create·edit and their writes / the picker / dashboard / audit·search |
|
|
83
|
+
| `list-request.ts` / `list-filters.ts` / `list-scope.ts` | URL ⇄ list request / operators per shape + the validator / row scope → scope → filters, `findRow`, `scopeCounts` |
|
|
84
|
+
| `list.tsx` / `list-filter-bar.tsx` / `list-scope-tabs.tsx` / `renderers.tsx` | the list view / one GET form / tabs as links / computed cells |
|
|
85
|
+
| `resource-list.ts` / `relations.ts` / `describe.ts` | `scopes`·`rows`·`columns` normalised / labels + lookup, one read per target / the admin as manifest data |
|
|
86
|
+
| `repo-entity.ts` / `keyset-seek.ts` / `form-decode.ts` / `row-version.ts` | typed handle → `AdminRepo` / the `(sort, id)` seek / posted form → typed input / an edit's version |
|
|
107
87
|
| `registry.ts` / `entity-columns.ts` | the entity surface the admin reads / one entity → column facts |
|
|
108
88
|
| `resource.ts` / `fields.ts` / `widget-value.ts` | derivation, widget table, value guards |
|
|
109
|
-
| `crud.ts` / `action-gate.ts` | policy → confirmation → validation → repo → audit |
|
|
89
|
+
| `crud.ts` / `crud-input.ts` / `crud-outcome.ts` / `action-gate.ts` / `row-scope-write.ts` | policy → confirmation → validation → repo → audit / secrets, tenant, sides, diff / the entry of a write that did not happen / a write's result vs `rows` |
|
|
90
|
+
| `action-row.ts` / `action-input.ts` / `action-form.tsx` / `screen-action.tsx` | one action on one row / input schema ⇄ form fields / the action's form / row-action and batch writes |
|
|
91
|
+
| `batch.ts` / `batch-queue.ts` / `batch-bar.tsx` / `batch-job.ts` | a batch through the gate / its queued half / the bar and row checkboxes / the `admin.batch` job |
|
|
92
|
+
| `resource-fields.ts` / `resource-layout.ts` / `related.ts` / `list-columns.tsx` | one column → one field / sections and form groups / related lists / a resource's table columns |
|
|
93
|
+
| `audit.ts` / `audit-pg.ts` / `json-text.ts` | the log interface + memory log / the Postgres log / readable JSON for a cell or a diff |
|
|
110
94
|
| `mcp-tools.ts` / `mcp.ts` | tool derivation (pure) / transport wiring |
|
|
95
|
+
| `test-ctx.ts` | `adminTestCtx({ actor?, granted? })` — the `CrudCtx` a test hands a page or a CRUD call; `staticAuthz` over a grant list, its own audit log. What `x g admin:page`'s emitted render test calls |
|
|
96
|
+
| `jobs/` | the jobs dashboard: entities, repos, the where translation, actions, labels, resources, the overview and its `/_x` tab |
|
|
97
|
+
| `batch-matching.ts` / `audit-schema.ts` | a set-based "all matching" / the audit table's DDL, a leaf |
|
|
111
98
|
| `dev/server.ts` + `dev/panel-*.ts` | `/_x` mount guard and one panel per file |
|
|
112
99
|
|
|
100
|
+
Why each rule is shaped the way it is: [`docs/history/admin.md`](../../docs/history/admin.md).
|
|
101
|
+
|
|
113
102
|
## Commands
|
|
114
103
|
|
|
104
|
+
From the repo root — the seal-key preload is the root's, so `bun test --filter` from this
|
|
105
|
+
directory fails on the first sealed column:
|
|
106
|
+
|
|
115
107
|
```bash
|
|
116
|
-
bun test
|
|
117
|
-
|
|
108
|
+
bun test packages/admin
|
|
109
|
+
bunx tsc --noEmit -p packages/admin/tsconfig.json
|
|
118
110
|
```
|