@ultimat3/admin 24.0.0 → 25.1.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 +14 -11
- package/README.md +39 -8
- package/package.json +17 -17
- package/src/action-gate.ts +18 -1
- package/src/admin.module.scss +118 -4
- package/src/audit-pg.ts +3 -3
- package/src/audit.ts +1 -3
- package/src/authz.ts +8 -8
- package/src/batch.ts +3 -8
- package/src/crud-input.ts +2 -1
- package/src/describe.ts +6 -0
- package/src/dev/data.ts +2 -2
- package/src/dev/dev-t.ts +9 -0
- package/src/dev/jobs-state-chart.tsx +38 -0
- package/src/dev/panel-jobs.ts +10 -5
- package/src/dev/panel-timeline.ts +9 -1
- package/src/dev/panel.ts +6 -3
- package/src/dev/server.ts +42 -13
- package/src/dev/timeline-style.ts +47 -0
- package/src/dev/timeline-waterfall.ts +96 -0
- package/src/form-decode.ts +13 -4
- package/src/index.ts +8 -7
- package/src/jobs/operator.ts +2 -2
- package/src/jobs/overview-tab.tsx +2 -2
- package/src/layout.tsx +34 -8
- package/src/list.tsx +1 -1
- package/src/mcp-scopes.ts +36 -0
- package/src/mcp-tools.ts +16 -2
- package/src/mcp.ts +23 -11
- package/src/pagination.ts +22 -18
- package/src/policy-bridge.ts +6 -6
- package/src/registry.ts +7 -0
- package/src/related.ts +2 -2
- package/src/relations.ts +1 -1
- package/src/resource.ts +10 -0
- package/src/screen-home-access.tsx +117 -0
- package/src/screen-home-counts.ts +42 -0
- package/src/screen-home-kpis.tsx +64 -0
- package/src/screen-home.module.scss +169 -0
- package/src/screen-home.tsx +21 -82
- package/src/screen-lookup.tsx +1 -1
- package/src/screen-resource.tsx +4 -4
- package/src/search.ts +2 -2
- package/src/inert-jsx.ts +0 -182
package/CLAUDE.md
CHANGED
|
@@ -14,7 +14,7 @@ Two products, one package, **two entry points**: `@ultimat3/admin/dev` (`src/dev
|
|
|
14
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
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
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` (`
|
|
17
|
+
- **A widget never reads a row.** What a page shows of the resources it references is read ONCE per target by `relations.ts` (`relationDataFor`: 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
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
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
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`.
|
|
@@ -26,13 +26,13 @@ Two products, one package, **two entry points**: `@ultimat3/admin/dev` (`src/dev
|
|
|
26
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
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
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 `
|
|
29
|
+
- **The routes are in the framework's ONE route list, as mounted routes** — `route-config.ts` calls `registerMountedRoutes` at `defineAdmin`, so `describePages()` (`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
30
|
- **The admin's stylesheets are claimed for `app/`** (`stylesheet-scope.ts`, `claimStylesheets`). `stylesheet-scope.test.ts`.
|
|
31
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`
|
|
32
|
+
- **One bridge per foreign package.** `policy-bridge.ts` → `evaluate`/`definePermissions`; `route-config.ts` → `defineRoute`/`registerMountedRoutes`; `mcp.ts` → `defineAppMcp`; `dev/data.ts` → introspection, dynamically. A test declares its own fixtures' permissions and calls `defineAdmin()` in `beforeAll`, never at module scope.
|
|
33
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.
|
|
34
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.
|
|
35
|
-
- **A deliberate query loop declares itself
|
|
35
|
+
- **A deliberate query loop declares itself** — `expectedQueryLoop()` from `@ultimat3/db`, with a `reason` (`search.ts`, `batch.ts`, `relations.ts`). Never a comment pragma, never an exempt list; an undeclared loop is an N+1.
|
|
36
36
|
- **The timeline panel renders the detector's verdicts** (`DevSources.statementLoops()`), never a second count; none wired is `null`, never `[]`.
|
|
37
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
38
|
- **`dev/panel-db.ts`'s `sanitize` decides "did they type a statement", not "is it safe"** — never cite it as a security property.
|
|
@@ -48,29 +48,32 @@ Two products, one package, **two entry points**: `@ultimat3/admin/dev` (`src/dev
|
|
|
48
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
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
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
|
|
51
|
+
- **Every admin operation is audited, reads included**, not `/admin`'s counts (a count reads no row). `ListResult` carries its `AuditEntry` on both branches (`entityId: null`); `AdminSearchResult.audit` one entry per resource decided, and a skipped resource (no text field, no repo, a repo that threw) is in `skipped`, never a 500.
|
|
52
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
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
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
|
|
55
|
+
- **A batch is the button's gate once per row** (`batch.ts`): policy, `when`, handler, one audit entry each. Checked ids or "all matching" (`listWhere`, keyset by id, `MAX_BATCH_ROWS` / `MAX_BATCH_QUEUED_ROWS`). `batch: { threshold }` queues `admin.batch` jobs under a derived `batchIdOf`, run AS the operator. Zero JS (`batch-bar.tsx`). ONE MCP tool per action — a batch action's tool takes `ids`.
|
|
56
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
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
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/`):
|
|
59
|
+
- **The jobs dashboard is every admin's, built from these pieces only** (`jobs/`): resources `x_jobs`, `x_job_queues`, `x_job_tasks`, `x_job_workers` with `repo:`s over `JobIntrospection`, scopes, a row scope and `job-actions.ts`, plus ONE screen (`overview.tsx`). `job:read` lists, `job:manage` acts; an `orgId` actor sees its org's jobs and none of the fleet. `jobs/boundary.test.ts`.
|
|
60
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
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
|
+
- **An action's admin gate is ONE function** (`adminPermissionForAction`, `action-gate.ts`): `destructive` → `admin:destroy`; `matching` → `admin:write`, `readonly` beside it or not — it changes every row the list matches; `readonly` → `admin:read`; else `admin:write`. A contradiction fails closed. The same value is its MCP tool's `scope`. `action-gate.test.ts`.
|
|
63
|
+
- **Every admin MCP tool carries ONE scope** — `admin:read`, `admin:write` or `admin:destroy`, by what it does (`mcp-tools.ts`); `adminToolScopes` hands that cut to `defineAppMcp({ scopes })`. A token's own scopes are `AdminMcpActor.tokenScopes` (not `scopes`: core's `Actor.scopes` is another thing), expanded `destroy ⇒ write ⇒ read` by `adminTokenScopes` (`mcp-scopes.ts`). **Unstated = full strength** (the actor's); **`[]` = granted nothing**, every tool refused `X_MCP_SCOPE_DENIED`; a non-admin scope is dropped. The scope gate runs before the actor's policy, which still decides. `mcp-scopes.test.ts`.
|
|
64
|
+
- **The `/admin` home counts only what opted in** — `count: true` on the resource (`screen-home-counts.ts`): one `count()` over `rowWhere(actor)` per visit, so it is never a default. Only resources the actor may list; a repo with no `count()`, or one that throws, has no tile — never a zero.
|
|
62
65
|
- **`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
66
|
- **An action on a row the actor cannot see is refused, `when` or none** (`actionApplies`: a `null` row is no row).
|
|
64
67
|
- **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
68
|
- **`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.
|
|
66
|
-
- Money
|
|
69
|
+
- Money via `assertMoney`, timestamps via `assertZone`, pagination via `pagination.ts`. No `offset`, ever.
|
|
67
70
|
- **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.
|
|
68
71
|
- 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).
|
|
69
72
|
- Colours only via `ThemeTokenRef` (`--x-*`). Raw hex does not typecheck.
|
|
70
73
|
- **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
74
|
- **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
|
|
73
|
-
- **No test reaches a `.tsx` statically
|
|
75
|
+
- Views are pure functions of props: no `createSignal`, local state or event handler.
|
|
76
|
+
- **No test reaches a `.tsx` statically**: `await import('@ultimat3/render/server')`, then `await import('./routes')`.
|
|
74
77
|
|
|
75
78
|
## Layout
|
|
76
79
|
|
|
@@ -91,7 +94,7 @@ Two products, one package, **two entry points**: `@ultimat3/admin/dev` (`src/dev
|
|
|
91
94
|
| `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
95
|
| `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
96
|
| `audit.ts` / `audit-pg.ts` / `json-text.ts` | the log interface + memory log / the Postgres log / readable JSON for a cell or a diff |
|
|
94
|
-
| `mcp-tools.ts` / `mcp.ts` | tool derivation (pure) / transport wiring |
|
|
97
|
+
| `mcp-tools.ts` / `mcp-scopes.ts` / `mcp.ts` | tool derivation, each tool's scope (pure) / scope → tools for `defineAppMcp`, a token's expanded scopes / transport wiring |
|
|
95
98
|
| `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
99
|
| `jobs/` | the jobs dashboard: entities, repos, the where translation, actions, labels, resources, the overview and its `/_x` tab |
|
|
97
100
|
| `batch-matching.ts` / `audit-schema.ts` | a set-based "all matching" / the audit table's DDL, a leaf |
|
package/README.md
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
| Audience | you, debugging the framework | your operators, and their agents |
|
|
9
9
|
| Environment | development **only** — mounting it with `env=production` or `role=production` throws `X_DEV_DASHBOARD_IN_PROD` | production |
|
|
10
10
|
| Authz | none: it is your own machine | the app's policies, one decision per surface |
|
|
11
|
-
| Data | introspection calls (`
|
|
11
|
+
| Data | introspection calls (`describePages`, `inspect`, `dependentsOf`, …) | the entity registry + repos |
|
|
12
12
|
| Shipped in the app image | never mounted | mounted at `/admin` |
|
|
13
13
|
|
|
14
14
|
## `/_x` panels
|
|
@@ -18,9 +18,9 @@ One panel per file. Each kills one question, and each is available as `--json`
|
|
|
18
18
|
| Panel | Kills |
|
|
19
19
|
|---|---|
|
|
20
20
|
| `routes` | which handler serves this? — render mode, offline strategy, revalidate tags, budget |
|
|
21
|
-
| `timeline` | where did the time go? —
|
|
21
|
+
| `timeline` | where did the time go? — a server-rendered waterfall of SQL, cache, action, policy spans (one row per span, bar placed by the payload's own `offset`/`width`), time per kind, and the N+1 detector's verdicts with their fix lines; the payload folded under it |
|
|
22
22
|
| `live` | what does each subscriber receive, and **why** — the matcher's decision trace |
|
|
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 |
|
|
23
|
+
| `jobs` | a ring of the recent runs by state (`DonutChart`), then 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 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 |
|
|
@@ -36,6 +36,8 @@ const authz = staticAuthz(['admin:read']);
|
|
|
36
36
|
const actors = [{ id: 'dev-admin', roles: ['admin'] }];
|
|
37
37
|
|
|
38
38
|
const dev = devDashboard({ sources: defaultDevSources({ authz, actors }) }); // throws in prod
|
|
39
|
+
// A drawn tab renders ui's components; their rules are in the app surface's stylesheet, so a host
|
|
40
|
+
// names its URL: devDashboard({ …, stylesheetHref: () => styleBundle().hrefFor('app') })
|
|
39
41
|
const response = await dev.handle(request); // null when the path is not /_x
|
|
40
42
|
await dev.json('jobs'); // the same payload /_x/jobs renders from
|
|
41
43
|
```
|
|
@@ -62,12 +64,22 @@ export const admin = defineAdmin({ entities: [posts, users], db });
|
|
|
62
64
|
`/admin/posts/<id>`, `/admin/posts/<id>/edit`, plus `/admin/search`, `/admin/audit` and the jobs
|
|
63
65
|
dashboard (`/admin/jobs`, below).
|
|
64
66
|
|
|
67
|
+
`/admin` itself opens on a KPI row — one `StatTile` per resource the actor may list AND that
|
|
68
|
+
declared `resources.<entity>.count: true`, its figure that resource's `AdminRepo.count()` over its
|
|
69
|
+
`rows` scope, each tile a link to the list — and a `DonutChart` of the same counts, then the
|
|
70
|
+
permission matrix per resource. Opt-in, as a scope tab's `count` is: a tile is one full `count()`
|
|
71
|
+
per visit, so an operator reloading the front page over a big table is a load generator. A resource
|
|
72
|
+
the actor may not list has no tile, no segment and no matrix; one that did not opt in, a repo with no
|
|
73
|
+
`count()`, or one whose count the store refuses (`X_TENANCY_UNSCOPED` for an actor with no org), has
|
|
74
|
+
no tile and never fails the page. A count reads no row, so it is not audited. Each matrix is a
|
|
75
|
+
closed `<details>` whose summary reads "N of M allowed".
|
|
76
|
+
|
|
65
77
|
| Input | What it is | Omitted |
|
|
66
78
|
|---|---|---|
|
|
67
79
|
| `entities` | the objects `entity()` returned — `adminEntitiesOf(db)` is every entity the handle serves | required |
|
|
68
80
|
| `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 |
|
|
81
|
+
| `resources` | per-entity overrides — `listFields`, `labelField`, `columns`, `scopes`, `count` (a tile on `/admin`), `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 |
|
|
82
|
+
| `actions` | `AdminAction[]` — a `permission` (never optional) plus a handler; optional `input` schema, `when(row)`, `batch`, `readonly`, `destructive` | none |
|
|
71
83
|
| `pages` | custom screens, see below | none |
|
|
72
84
|
| `auth.actor` | who is acting | the actor the HTTP pipeline resolved for the request |
|
|
73
85
|
| `auth.authz` | the one decision path | `roleAuthz()` — the role map decides each permission by name |
|
|
@@ -211,7 +223,8 @@ export const suspendUser: AdminAction = {
|
|
|
211
223
|
| `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
224
|
| `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
225
|
| `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
|
-
|
|
|
226
|
+
| `readonly` | the action changes nothing (an export, a verification): its admin-level gate is `admin:read`, not `admin:write`, so a read-only staff role and an `admin:read` MCP token may run it. `destructive` (`admin:destroy`) and `matching` (`admin:write`) win over it |
|
|
227
|
+
| destructive | gated on `admin:destroy`; one row: type `<entity>:<id>`; a batch: type `<entity>:<n> rows` |
|
|
215
228
|
| MCP | still ONE tool, `admin.action.<name>`; a batch action's takes `ids: [...]` |
|
|
216
229
|
| `labelKey` | the button's, the form's and the batch bar's label; absent: `admin.action.<name>` |
|
|
217
230
|
|
|
@@ -254,10 +267,11 @@ transaction as the write (`AuditLog.atomic`) — a CRUD write, an action's handl
|
|
|
254
267
|
set and a queued batch's enqueues alike, so an entry that cannot be written rolls the work back
|
|
255
268
|
and the log holds one `failed` entry — and reads it back by keyset. An allowed READ is not
|
|
256
269
|
written unless `reads: true` — a refused one always is. Sealed and `sensitive` values reach the
|
|
257
|
-
table only as `[redacted]
|
|
270
|
+
table only as `[redacted]` — `@ultimat3/core`'s `REDACTED`, the logger's marker too. `memoryAuditLog()` is a ring that forgets at every restart. The
|
|
258
271
|
table is applied at boot with every other framework table (`@ultimat3/cli`'s `FRAMEWORK_SCHEMA`),
|
|
259
272
|
from the leaf module `@ultimat3/admin/schema` (`SQL_ADMIN_AUDIT_TABLE`, `ADMIN_AUDIT_TABLE`) —
|
|
260
|
-
no migration, no screen loaded to install it.
|
|
273
|
+
no migration, no screen loaded to install it. That subpath is their one import path: 25.0.0 took
|
|
274
|
+
them off the barrel.
|
|
261
275
|
|
|
262
276
|
### A sealed column
|
|
263
277
|
|
|
@@ -428,6 +442,23 @@ export const mcp = adminMcp({ app: admin, actor: (session) => actorFor(session.t
|
|
|
428
442
|
adminMcpTools(admin, ctx); // admin.post.list · admin.post.read · admin.search · admin.action.post.publish
|
|
429
443
|
```
|
|
430
444
|
|
|
445
|
+
Every tool carries a **scope**, the admin-level permission it needs. The MCP registry asks, in
|
|
446
|
+
order: visibility — the actor's own admin authz (`adminMcpTools`); a tool it denies is ABSENT and
|
|
447
|
+
answers tool-not-found, whatever the token holds — then the token's scope (`X_MCP_SCOPE_DENIED`
|
|
448
|
+
naming it, before any argument is validated or a handler runs), then the call's own row-level
|
|
449
|
+
decision inside the tool:
|
|
450
|
+
|
|
451
|
+
| Scope | Tools |
|
|
452
|
+
|---|---|
|
|
453
|
+
| `admin:read` | `admin.<entity>.list`, `admin.<entity>.read`, `admin.search`, a `readonly` action |
|
|
454
|
+
| `admin:write` | `admin.<entity>.create`, `admin.<entity>.update`, any other action |
|
|
455
|
+
| `admin:destroy` | `admin.<entity>.delete`, a `destructive` action |
|
|
456
|
+
|
|
457
|
+
The token's scopes are what `actor(session)` returns as `tokenScopes` (`AdminMcpActor`), expanded by
|
|
458
|
+
`adminTokenScopes` (`admin:destroy` ⇒ `admin:write` ⇒ `admin:read`): `tokenScopes: ['admin:read']`
|
|
459
|
+
is a read-only agent token. Absent, the token is as strong as its actor. A stdio host builds its
|
|
460
|
+
caller's `scopes` with `adminTokenScopes(granted)` too — an empty set is refused every tool.
|
|
461
|
+
|
|
431
462
|
The list tool takes the list screen's own grammar: `scope` by name and `where` as
|
|
432
463
|
`[{ field, op?, value }]`, through the same validator a URL passes — and the resource's `rows`
|
|
433
464
|
narrows what an agent reads whatever it sends.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ultimat3/admin",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "25.1.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",
|
|
@@ -27,27 +27,27 @@
|
|
|
27
27
|
"LICENSE"
|
|
28
28
|
],
|
|
29
29
|
"engines": {
|
|
30
|
-
"bun": ">=1.4.
|
|
30
|
+
"bun": ">=1.4.2"
|
|
31
31
|
},
|
|
32
32
|
"scripts": {
|
|
33
33
|
"typecheck": "tsc --noEmit -p tsconfig.json",
|
|
34
34
|
"test": "bun test"
|
|
35
35
|
},
|
|
36
36
|
"dependencies": {
|
|
37
|
-
"@ultimat3/action": "
|
|
38
|
-
"@ultimat3/ai": "
|
|
39
|
-
"@ultimat3/cache": "
|
|
40
|
-
"@ultimat3/core": "
|
|
41
|
-
"@ultimat3/db": "
|
|
42
|
-
"@ultimat3/entity": "
|
|
43
|
-
"@ultimat3/i18n": "
|
|
44
|
-
"@ultimat3/jobs": "
|
|
45
|
-
"@ultimat3/mcp": "
|
|
46
|
-
"@ultimat3/money": "
|
|
47
|
-
"@ultimat3/policy": "
|
|
48
|
-
"@ultimat3/query": "
|
|
49
|
-
"@ultimat3/render": "
|
|
50
|
-
"@ultimat3/schema": "
|
|
51
|
-
"@ultimat3/ui": "
|
|
37
|
+
"@ultimat3/action": "25.1.0",
|
|
38
|
+
"@ultimat3/ai": "25.1.0",
|
|
39
|
+
"@ultimat3/cache": "25.1.0",
|
|
40
|
+
"@ultimat3/core": "25.1.0",
|
|
41
|
+
"@ultimat3/db": "25.1.0",
|
|
42
|
+
"@ultimat3/entity": "25.1.0",
|
|
43
|
+
"@ultimat3/i18n": "25.1.0",
|
|
44
|
+
"@ultimat3/jobs": "25.1.0",
|
|
45
|
+
"@ultimat3/mcp": "25.1.0",
|
|
46
|
+
"@ultimat3/money": "25.1.0",
|
|
47
|
+
"@ultimat3/policy": "25.1.0",
|
|
48
|
+
"@ultimat3/query": "25.1.0",
|
|
49
|
+
"@ultimat3/render": "25.1.0",
|
|
50
|
+
"@ultimat3/schema": "25.1.0",
|
|
51
|
+
"@ultimat3/ui": "25.1.0"
|
|
52
52
|
}
|
|
53
53
|
}
|
package/src/action-gate.ts
CHANGED
|
@@ -16,7 +16,9 @@ import { atomicallyAudited } from './crud-outcome';
|
|
|
16
16
|
import { AdminActionNotApplicableError } from './errors';
|
|
17
17
|
import {
|
|
18
18
|
ADMIN_DESTROY,
|
|
19
|
+
ADMIN_READ,
|
|
19
20
|
ADMIN_WRITE,
|
|
21
|
+
type AdminPermission,
|
|
20
22
|
CONFIRMATION_REQUIRED_REASON,
|
|
21
23
|
confirmationToken,
|
|
22
24
|
} from './permissions';
|
|
@@ -47,11 +49,26 @@ export const ACTION_NOT_APPLICABLE_REASON = 'admin.error.action-not-applicable';
|
|
|
47
49
|
/** The reason an action whose input its own schema refused is audited with. */
|
|
48
50
|
const ACTION_INVALID_REASON = 'admin.error.invalid-input';
|
|
49
51
|
|
|
52
|
+
/**
|
|
53
|
+
* The admin-level gate an action needs, and the MCP scope its tool carries. `destructive` is
|
|
54
|
+
* checked first so a declaration marked both `readonly` and `destructive` gets the stricter gate —
|
|
55
|
+
* a contradiction that fails closed rather than open.
|
|
56
|
+
*/
|
|
57
|
+
export function adminPermissionForAction<Input, Output>(
|
|
58
|
+
action: AdminAction<Input, Output>,
|
|
59
|
+
): AdminPermission {
|
|
60
|
+
if (action.destructive === true) return ADMIN_DESTROY;
|
|
61
|
+
// `matching` CHANGES every row the list matches (its contract answers how many it changed), so a
|
|
62
|
+
// `readonly` beside it is the same contradiction as beside `destructive`: the write gate holds.
|
|
63
|
+
if (action.matching !== undefined) return ADMIN_WRITE;
|
|
64
|
+
return action.readonly === true ? ADMIN_READ : ADMIN_WRITE;
|
|
65
|
+
}
|
|
66
|
+
|
|
50
67
|
/** The permissions an action needs: the admin-level gate, then the action's own policy. */
|
|
51
68
|
export function permissionsForAction<Input, Output>(
|
|
52
69
|
action: AdminAction<Input, Output>,
|
|
53
70
|
): readonly string[] {
|
|
54
|
-
return [action
|
|
71
|
+
return [adminPermissionForAction(action), action.permission];
|
|
55
72
|
}
|
|
56
73
|
|
|
57
74
|
export function decideAction<Input, Output>(
|
package/src/admin.module.scss
CHANGED
|
@@ -12,27 +12,125 @@
|
|
|
12
12
|
background: t.role('bg');
|
|
13
13
|
color: t.role('fg');
|
|
14
14
|
|
|
15
|
-
// One column under the `md` rung: the
|
|
15
|
+
// One column under the `md` rung: the menu bar, then the screen; the sidebar is the panel the
|
|
16
|
+
// bar opens — or, in an engine with no popovers, a block above the screen it navigates.
|
|
16
17
|
@include t.respond-down('md') {
|
|
17
18
|
grid-template-columns: 1fr;
|
|
19
|
+
gap: 0;
|
|
18
20
|
}
|
|
19
21
|
}
|
|
20
22
|
|
|
23
|
+
// Off-screen until focused, then pinned at the inline-start corner. It used to stay visually
|
|
24
|
+
// hidden even WITH focus — the focus ring drew around a 1px box nobody could see.
|
|
21
25
|
.skip {
|
|
22
|
-
@include t.
|
|
26
|
+
@include t.focus-ring;
|
|
27
|
+
|
|
28
|
+
position: fixed;
|
|
29
|
+
z-index: t.z(skip-nav);
|
|
30
|
+
inset-block-start: t.space(2);
|
|
31
|
+
inset-inline-start: t.space(2);
|
|
32
|
+
padding-block: t.space(2);
|
|
33
|
+
padding-inline: t.space(4);
|
|
34
|
+
border-radius: t.radius('md');
|
|
35
|
+
background: t.role('surface-raised');
|
|
36
|
+
color: t.role('fg-strong');
|
|
37
|
+
box-shadow: t.shadow(md);
|
|
38
|
+
text-decoration: none;
|
|
23
39
|
|
|
24
|
-
&:focus-visible {
|
|
25
|
-
@include t.
|
|
40
|
+
&:not(:focus-visible) {
|
|
41
|
+
@include t.visually-hidden;
|
|
26
42
|
}
|
|
27
43
|
}
|
|
28
44
|
|
|
45
|
+
// Hidden unless the engine can open the panel: a menu button that does nothing is worse than the
|
|
46
|
+
// block an engine without popovers still shows.
|
|
47
|
+
.bar {
|
|
48
|
+
display: none;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// A touch target: this bar exists for a phone.
|
|
52
|
+
.menuButton,
|
|
53
|
+
.close {
|
|
54
|
+
@include t.focus-ring;
|
|
55
|
+
@include t.touch-target;
|
|
56
|
+
|
|
57
|
+
display: inline-flex;
|
|
58
|
+
align-items: center;
|
|
59
|
+
gap: t.space(2);
|
|
60
|
+
padding-inline: t.space(3);
|
|
61
|
+
border: 0;
|
|
62
|
+
border-radius: t.radius('md');
|
|
63
|
+
background: none;
|
|
64
|
+
color: t.role('fg-strong');
|
|
65
|
+
font: inherit;
|
|
66
|
+
cursor: pointer;
|
|
67
|
+
|
|
68
|
+
&:hover {
|
|
69
|
+
background: t.role('bg-soft');
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
.close {
|
|
74
|
+
display: none;
|
|
75
|
+
justify-content: center;
|
|
76
|
+
align-self: flex-end;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// Every `[popover]` gets a UA style — fixed, centred, hidden while closed — so the column undoes
|
|
80
|
+
// each; this box is the sidebar at `md` and up, and at every width where there is no popover.
|
|
29
81
|
.side {
|
|
30
82
|
@include t.surface('surface', 'sm');
|
|
31
83
|
@include t.padding-block(t.space(6));
|
|
32
84
|
@include t.padding-inline(t.space(4));
|
|
33
85
|
@include t.column(t.space(4));
|
|
34
86
|
|
|
87
|
+
position: static;
|
|
88
|
+
inset: auto;
|
|
89
|
+
inline-size: auto;
|
|
90
|
+
block-size: auto;
|
|
91
|
+
margin: 0;
|
|
92
|
+
color: t.role('fg');
|
|
35
93
|
border-inline-end: t.stroke(hairline) solid t.role('line');
|
|
94
|
+
overflow: auto;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
@include t.respond-down('md') {
|
|
98
|
+
@supports selector(:popover-open) {
|
|
99
|
+
.bar {
|
|
100
|
+
display: flex;
|
|
101
|
+
align-items: center;
|
|
102
|
+
padding-block: t.space(1);
|
|
103
|
+
padding-inline: t.space(2);
|
|
104
|
+
border-block-end: t.stroke(hairline) solid t.role('line');
|
|
105
|
+
background: t.role('surface');
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
.side:not(:popover-open) {
|
|
109
|
+
display: none;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// The same edge sheet as `Drawer`'s inline-start side: raised surface, line edge, scrim.
|
|
113
|
+
.side:popover-open {
|
|
114
|
+
@include t.scrim-backdrop;
|
|
115
|
+
|
|
116
|
+
position: fixed;
|
|
117
|
+
inset-block: 0;
|
|
118
|
+
inset-inline-start: 0;
|
|
119
|
+
inset-inline-end: auto;
|
|
120
|
+
inline-size: min(t.rem(320), calc(100vw - #{t.space(12)}));
|
|
121
|
+
block-size: 100dvh;
|
|
122
|
+
max-block-size: 100dvh;
|
|
123
|
+
border: 0;
|
|
124
|
+
border-inline-end: t.stroke(hairline) solid t.role('line');
|
|
125
|
+
border-radius: 0;
|
|
126
|
+
background: t.role('surface-raised');
|
|
127
|
+
box-shadow: t.shadow(xl);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
.close {
|
|
131
|
+
display: inline-flex;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
36
134
|
}
|
|
37
135
|
|
|
38
136
|
.brand {
|
|
@@ -50,8 +148,17 @@
|
|
|
50
148
|
color: t.role('fg-muted');
|
|
51
149
|
}
|
|
52
150
|
|
|
151
|
+
// One track that may shrink to the column: an `auto` track is at least its content's min-content
|
|
152
|
+
// width, and a text input's is its `size` — wider than a 256px sidebar's content box, so the box
|
|
153
|
+
// stuck out past the sidebar's edge at every width.
|
|
53
154
|
.search {
|
|
54
155
|
display: grid;
|
|
156
|
+
grid-template-columns: minmax(0, 1fr);
|
|
157
|
+
min-inline-size: 0;
|
|
158
|
+
|
|
159
|
+
> form {
|
|
160
|
+
min-inline-size: 0;
|
|
161
|
+
}
|
|
55
162
|
}
|
|
56
163
|
|
|
57
164
|
.hidden {
|
|
@@ -96,6 +203,13 @@
|
|
|
96
203
|
&:focus-visible {
|
|
97
204
|
@include t.focus-ring;
|
|
98
205
|
}
|
|
206
|
+
|
|
207
|
+
// A touch-target row on a narrow screen, where the link is tapped rather than clicked.
|
|
208
|
+
@include t.respond-down('md') {
|
|
209
|
+
display: flex;
|
|
210
|
+
align-items: center;
|
|
211
|
+
min-block-size: t.touch(target);
|
|
212
|
+
}
|
|
99
213
|
}
|
|
100
214
|
|
|
101
215
|
.main {
|
package/src/audit-pg.ts
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
// `defineAdmin({ audit: postgresAuditLog() })` names — the memory log is a ring that forgets at
|
|
3
3
|
// every restart, and a row's history that ends at the last deploy is not a history.
|
|
4
4
|
|
|
5
|
-
import { isUltimateError,
|
|
5
|
+
import { isUltimateError, uuidV7 } from '@ultimat3/core';
|
|
6
6
|
import { type DbClient, db, withTransaction } from '@ultimat3/db';
|
|
7
7
|
import {
|
|
8
8
|
AUDIT_READ_OPERATIONS,
|
|
@@ -149,7 +149,7 @@ export interface PostgresAuditLogOptions {
|
|
|
149
149
|
*/
|
|
150
150
|
export function postgresAuditLog(options: PostgresAuditLogOptions = {}): AuditLog {
|
|
151
151
|
const now = options.now ?? ((): Date => new Date());
|
|
152
|
-
const nextId = options.nextId ??
|
|
152
|
+
const nextId = options.nextId ?? uuidV7;
|
|
153
153
|
const sinks = options.sinks ?? [];
|
|
154
154
|
const reads = options.reads === true;
|
|
155
155
|
// Resolved per statement, never captured: inside `atomic` it is the open transaction.
|
|
@@ -160,7 +160,7 @@ export function postgresAuditLog(options: PostgresAuditLogOptions = {}): AuditLo
|
|
|
160
160
|
async append(draft: AuditDraft): Promise<AuditEntry> {
|
|
161
161
|
const entry = auditEntry(draft, nextId(), now());
|
|
162
162
|
if (reads || !isAllowedRead(entry)) {
|
|
163
|
-
// A plain `{ text, values }`, the shape `
|
|
163
|
+
// A plain `{ text, values }`, the shape `dbExecutor` hands the same client: the text
|
|
164
164
|
// is this file's constant and every value is bound.
|
|
165
165
|
await client().query({
|
|
166
166
|
text: SQL_ADMIN_AUDIT_INSERT,
|
package/src/audit.ts
CHANGED
|
@@ -2,12 +2,10 @@
|
|
|
2
2
|
// If it isn't logged, it didn't happen — so denied and failed attempts are logged too, and
|
|
3
3
|
// there is deliberately no update or delete on this interface.
|
|
4
4
|
|
|
5
|
-
import { canonicalJson, finiteCount } from '@ultimat3/core';
|
|
5
|
+
import { canonicalJson, finiteCount, REDACTED } from '@ultimat3/core';
|
|
6
6
|
import type { AdminActor, AdminDecision } from './authz';
|
|
7
7
|
import type { AdminRow } from './registry';
|
|
8
8
|
|
|
9
|
-
export const REDACTED = '[redacted]';
|
|
10
|
-
|
|
11
9
|
/** Named once, so both refusals below say the same thing about the same call. */
|
|
12
10
|
const SUBJECT = 'memoryAuditLog';
|
|
13
11
|
|
package/src/authz.ts
CHANGED
|
@@ -62,7 +62,7 @@ export interface AdminAuthz {
|
|
|
62
62
|
decide(query: AdminAuthzQuery): AdminDecision;
|
|
63
63
|
}
|
|
64
64
|
|
|
65
|
-
export function
|
|
65
|
+
export function adminAllowed(
|
|
66
66
|
permission: string,
|
|
67
67
|
reason: string,
|
|
68
68
|
trace: readonly string[] = [],
|
|
@@ -70,7 +70,7 @@ export function allowed(
|
|
|
70
70
|
return { allowed: true, permission, reason, trace };
|
|
71
71
|
}
|
|
72
72
|
|
|
73
|
-
export function
|
|
73
|
+
export function adminDenied(
|
|
74
74
|
permission: string,
|
|
75
75
|
reason: string,
|
|
76
76
|
trace: readonly string[] = [],
|
|
@@ -82,7 +82,7 @@ export function denied(
|
|
|
82
82
|
* Every permission must hold. The first denial wins, and carries its own reason.
|
|
83
83
|
*
|
|
84
84
|
* An EMPTY list is refused, never granted. `permissions[length - 1] ?? ''` used to fall through to
|
|
85
|
-
* `
|
|
85
|
+
* `adminAllowed('')`, so a declared-but-empty gate opened for every actor, anonymous included — and the
|
|
86
86
|
* decision it returned named no permission at all. `visibleNav` hands an author's
|
|
87
87
|
* `item.permissions` straight here, so `permissions: []` on a nav item was that gate. `pages.ts`
|
|
88
88
|
* already refuses an empty page list at declaration time (`X_ADMIN_PAGE_UNGUARDED`); this is the
|
|
@@ -96,7 +96,7 @@ export function decideAll(
|
|
|
96
96
|
subject?: AdminSubject,
|
|
97
97
|
): AdminDecision {
|
|
98
98
|
if (permissions.length === 0) {
|
|
99
|
-
return
|
|
99
|
+
return adminDenied('', 'admin.policy.none-declared', [
|
|
100
100
|
'no permission was declared for this surface, so there is nothing to satisfy',
|
|
101
101
|
]);
|
|
102
102
|
}
|
|
@@ -107,10 +107,10 @@ export function decideAll(
|
|
|
107
107
|
);
|
|
108
108
|
trace.push(`${permission}: ${decision.allowed ? 'allow' : 'deny'} (${decision.reason})`);
|
|
109
109
|
if (!decision.allowed)
|
|
110
|
-
return
|
|
110
|
+
return adminDenied(permission, decision.reason, [...trace, ...decision.trace]);
|
|
111
111
|
}
|
|
112
112
|
const last = permissions[permissions.length - 1] ?? '';
|
|
113
|
-
return
|
|
113
|
+
return adminAllowed(last, 'admin.policy.all-granted', trace);
|
|
114
114
|
}
|
|
115
115
|
|
|
116
116
|
export function isAllowed(
|
|
@@ -167,8 +167,8 @@ export function staticAuthz(granted: readonly string[]): AdminAuthz {
|
|
|
167
167
|
return {
|
|
168
168
|
decide({ permission }): AdminDecision {
|
|
169
169
|
return set.has(permission)
|
|
170
|
-
?
|
|
171
|
-
:
|
|
170
|
+
? adminAllowed(permission, 'admin.policy.granted', [`static: ${permission} in grant list`])
|
|
171
|
+
: adminDenied(permission, 'admin.policy.not-granted', [
|
|
172
172
|
`static: ${permission} not in [${[...set].join(', ')}]`,
|
|
173
173
|
]);
|
|
174
174
|
},
|
package/src/batch.ts
CHANGED
|
@@ -4,19 +4,14 @@
|
|
|
4
4
|
|
|
5
5
|
import { finiteCount } from '@ultimat3/core';
|
|
6
6
|
import { expectedQueryLoop } from '@ultimat3/db';
|
|
7
|
-
import { decideAction } from './action-gate';
|
|
7
|
+
import { adminPermissionForAction, decideAction } from './action-gate';
|
|
8
8
|
import { invokeRowAction } from './action-row';
|
|
9
9
|
import type { AdminDecision } from './authz';
|
|
10
10
|
import { runMatching } from './batch-matching';
|
|
11
11
|
import { queueBatch } from './batch-queue';
|
|
12
12
|
import { type CrudCtx, decideOperation } from './crud';
|
|
13
13
|
import { type AdminListRequest, listWhere } from './list-scope';
|
|
14
|
-
import {
|
|
15
|
-
ADMIN_DESTROY,
|
|
16
|
-
ADMIN_WRITE,
|
|
17
|
-
CONFIRMATION_REQUIRED_REASON,
|
|
18
|
-
confirmationToken,
|
|
19
|
-
} from './permissions';
|
|
14
|
+
import { ADMIN_DESTROY, CONFIRMATION_REQUIRED_REASON, confirmationToken } from './permissions';
|
|
20
15
|
import type { AdminAction, AdminFilter } from './registry';
|
|
21
16
|
import { rowId } from './registry';
|
|
22
17
|
import { type AdminResource, repoOf } from './resource';
|
|
@@ -202,7 +197,7 @@ export async function runAdminBatch(input: AdminBatchInput): Promise<AdminBatchA
|
|
|
202
197
|
if (action.batch === undefined) {
|
|
203
198
|
return denied({
|
|
204
199
|
allowed: false,
|
|
205
|
-
permission: action
|
|
200
|
+
permission: adminPermissionForAction(action),
|
|
206
201
|
reason: BATCH_NOT_OFFERED_REASON,
|
|
207
202
|
trace: [`batch: ${action.name} does not declare batch`],
|
|
208
203
|
});
|
package/src/crud-input.ts
CHANGED
|
@@ -2,7 +2,8 @@
|
|
|
2
2
|
// tenant column taken out of the caller's hands, and the audit diff of what the write changed.
|
|
3
3
|
// Pure over the resource — `crud.ts` decides and writes, this file only shapes the input.
|
|
4
4
|
|
|
5
|
-
import {
|
|
5
|
+
import { REDACTED } from '@ultimat3/core';
|
|
6
|
+
import { type AuditFieldDiff, diffRows } from './audit';
|
|
6
7
|
import type { CrudCtx } from './crud';
|
|
7
8
|
import type { AdminRow } from './registry';
|
|
8
9
|
import type { AdminResource } from './resource';
|
package/src/describe.ts
CHANGED
|
@@ -36,6 +36,10 @@ export interface AdminActionDescription {
|
|
|
36
36
|
readonly batch: boolean;
|
|
37
37
|
/** Rows a batch runs inline before it is queued as `admin.batch` jobs. `null`: always inline. */
|
|
38
38
|
readonly threshold: number | null;
|
|
39
|
+
/** Declared `readonly`: its admin gate is `admin:read` — unless `destructive` or `matching` holds it. */
|
|
40
|
+
readonly readonly: boolean;
|
|
41
|
+
/** It declares `matching`: "all matching" is ONE set-based write, which keeps the write gate. */
|
|
42
|
+
readonly matching: boolean;
|
|
39
43
|
}
|
|
40
44
|
|
|
41
45
|
export interface AdminResourceDescription {
|
|
@@ -88,6 +92,8 @@ const actionOf = (action: AdminAction): AdminActionDescription => ({
|
|
|
88
92
|
when: action.when !== undefined,
|
|
89
93
|
batch: action.batch !== undefined,
|
|
90
94
|
threshold: batchPlan(action)?.threshold ?? null,
|
|
95
|
+
readonly: action.readonly === true,
|
|
96
|
+
matching: action.matching !== undefined,
|
|
91
97
|
});
|
|
92
98
|
|
|
93
99
|
/** Sorted by name and by URL, so two boots of one app describe it in the same bytes. */
|
package/src/dev/data.ts
CHANGED
|
@@ -144,8 +144,8 @@ export function defaultDevSources(opts: DevSourceOptions = {}): DevSources {
|
|
|
144
144
|
|
|
145
145
|
const sources: DevSources = {
|
|
146
146
|
async routes(): Promise<readonly RouteFact[]> {
|
|
147
|
-
const {
|
|
148
|
-
return
|
|
147
|
+
const { describePages } = await import('@ultimat3/render');
|
|
148
|
+
return describePages().map((route) => ({
|
|
149
149
|
path: route.path,
|
|
150
150
|
// `RouteDescriptor` calls the render mode `mode`; the panel's own word is `render`, and
|
|
151
151
|
// the two are bridged here, once. Reading `route['render']` answered `undefined` for
|