@ultimat3/admin 27.0.0 → 27.2.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 +3 -3
- package/README.md +11 -2
- package/package.json +17 -16
- package/src/actor.ts +1 -0
- package/src/authz.ts +6 -0
- package/src/batch-job.ts +6 -0
- package/src/claim-route.ts +146 -0
- package/src/claims.ts +18 -0
- package/src/index.ts +6 -0
- package/src/mcp.ts +8 -1
- package/src/policy-bridge.ts +55 -39
- package/src/route-config.ts +31 -12
- package/src/screen-frame.tsx +7 -0
package/CLAUDE.md
CHANGED
|
@@ -11,7 +11,7 @@ Two products, one package, **two entry points**: `@ultimat3/admin/dev` (`src/dev
|
|
|
11
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
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
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
|
|
14
|
+
- **No screen ships a script; an app file that CLAIMED its route may** (`claimAdminRoute`: a `pages:` entry or the dashboard; `load` = `screenFor`, `frame: 'none'`). Only a config `claims.ts` issued for that path passes `claimable`, else `X_ROUTE_DUPLICATE`. Every admin 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
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).
|
|
@@ -29,13 +29,12 @@ Two products, one package, **two entry points**: `@ultimat3/admin/dev` (`src/dev
|
|
|
29
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` → `evaluate`/`definePermissions`; `route-config.ts` → `defineRoute`/`registerMountedRoutes
|
|
32
|
+
- **One bridge per foreign package.** `policy-bridge.ts` → `evaluate`/`definePermissions`; `route-config.ts` → `defineRoute`/`registerMountedRoutes` (+ `claim-route.ts`, the `@ultimat3/http` bridge); `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
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
|
-
- **`dev/panel-db.ts`'s `sanitize` decides "did they type a statement", not "is it safe"** — never cite it as a security property.
|
|
39
38
|
- **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
39
|
- **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
40
|
- **`panel-db.ts` executes the string `assertReadOnlyQuery` RETURNS**, never the textarea's bytes (`ReadOnlyVerdict`).
|
|
@@ -82,6 +81,7 @@ Two products, one package, **two entry points**: `@ultimat3/admin/dev` (`src/dev
|
|
|
82
81
|
| `admin.ts` / `mounts.ts` / `actor.ts` | `defineAdmin` → resources, repos, nav, route table, audit, authz / the registry a host mounts from / the default actor |
|
|
83
82
|
| `pages.ts` / `page-guard.tsx` | a `pages:` entry → route + nav item (pure) / the refusal a screen renders and audits |
|
|
84
83
|
| `route-config.ts` / `routes.ts` / `screens.ts` | the render bridge: `defineRoute`, `registerMountedRoutes` / route → screen, `adminRouteMatch` / view → screen |
|
|
84
|
+
| `claim-route.ts` / `claims.ts` | an app file serving a route in the catch-all's place / the configs issued, by identity |
|
|
85
85
|
| `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 |
|
|
86
86
|
| `list-request.ts` / `list-filters.ts` / `list-scope.ts` | URL ⇄ list request / operators per shape + the validator / row scope → scope → filters, `findRow`, `scopeCounts` |
|
|
87
87
|
| `list.tsx` / `list-filter-bar.tsx` / `list-scope-tabs.tsx` / `renderers.tsx` | the list view / one GET form / tabs as links / computed cells |
|
package/README.md
CHANGED
|
@@ -86,12 +86,12 @@ closed `<details>` whose summary reads "N of M allowed".
|
|
|
86
86
|
| `audit` | `postgresAuditLog()` (the table `x_admin_audit`) or `memoryAuditLog({ sinks })` | a memory ring |
|
|
87
87
|
|
|
88
88
|
Three authz constructors, one interface: `roleAuthz()` (the default), `policyAuthz({ policies })`
|
|
89
|
-
when a rule reads the row or the tenant, `singlePolicyAuthz(policy)` when one grant runs the whole
|
|
89
|
+
when a rule reads the row or the tenant (a permission the map omits falls back to the role map), `singlePolicyAuthz(policy)` when one grant runs the whole
|
|
90
90
|
admin.
|
|
91
91
|
|
|
92
92
|
### No script
|
|
93
93
|
|
|
94
|
-
Every route is `ssr`, `hydrate: 'never'
|
|
94
|
+
Every route is `ssr`, `hydrate: 'never'` (unless an app file claimed it — Custom pages, below), and every control is a link or a native form: the pager
|
|
95
95
|
is `<Pagination hrefFor>`, a sort header is a link, scope tabs are links, the filter bar is one
|
|
96
96
|
GET form, a form posts at the URL that rendered it and a refused one comes back 422 with what was
|
|
97
97
|
typed. A write that worked is a 303. Every admin document is `noindex, nofollow`, and carries no
|
|
@@ -377,6 +377,15 @@ that shadows a generated screen throws `X_ADMIN_PAGE_PATH_INVALID` the same way.
|
|
|
377
377
|
Custom pages are `render: 'ssr'`, `hydrate: 'never'`: the guard runs on the server, so there is
|
|
378
378
|
nothing to ship and nothing to decide twice. A refused one answers 403.
|
|
379
379
|
|
|
380
|
+
**In your own shell, with an island: claim the route.** A `page.tsx` at the page's path whose
|
|
381
|
+
config is `claimAdminRoute(admin, path, { load?, hydrate?, budget?, navigation? })` serves it in
|
|
382
|
+
the catch-all's place — a file route, so its islands hydrate. The claim keeps the gate, `meta` and
|
|
383
|
+
a `load` that answers through the same screen (`frame: 'none'`, no `AdminLayout`); the page
|
|
384
|
+
component renders `data.body` (decided: the page or the refusal) inside the app's shell, beside
|
|
385
|
+
`data.nav` and `data.app` (what `load` returned). Only a `pages:` entry or the dashboard; only the
|
|
386
|
+
config issued for that path (else `X_ROUTE_DUPLICATE`). [Admin Dashboard → An admin page in your
|
|
387
|
+
own shell](https://github.com/developerz-ai/ultimate/wiki/Admin-Dashboard#an-admin-page-in-your-own-shell).
|
|
388
|
+
|
|
380
389
|
### Splitting the admin across files
|
|
381
390
|
|
|
382
391
|
`defineAdmin` takes **plain values** — `entities`, `db`, `resources`, `actions`, `jobs`, `pages`,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ultimat3/admin",
|
|
3
|
-
"version": "27.
|
|
3
|
+
"version": "27.2.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",
|
|
@@ -34,20 +34,21 @@
|
|
|
34
34
|
"test": "bun test"
|
|
35
35
|
},
|
|
36
36
|
"dependencies": {
|
|
37
|
-
"@ultimat3/action": "27.
|
|
38
|
-
"@ultimat3/ai": "27.
|
|
39
|
-
"@ultimat3/cache": "27.
|
|
40
|
-
"@ultimat3/core": "27.
|
|
41
|
-
"@ultimat3/db": "27.
|
|
42
|
-
"@ultimat3/entity": "27.
|
|
43
|
-
"@ultimat3/
|
|
44
|
-
"@ultimat3/
|
|
45
|
-
"@ultimat3/
|
|
46
|
-
"@ultimat3/
|
|
47
|
-
"@ultimat3/
|
|
48
|
-
"@ultimat3/
|
|
49
|
-
"@ultimat3/
|
|
50
|
-
"@ultimat3/
|
|
51
|
-
"@ultimat3/
|
|
37
|
+
"@ultimat3/action": "27.2.0",
|
|
38
|
+
"@ultimat3/ai": "27.2.0",
|
|
39
|
+
"@ultimat3/cache": "27.2.0",
|
|
40
|
+
"@ultimat3/core": "27.2.0",
|
|
41
|
+
"@ultimat3/db": "27.2.0",
|
|
42
|
+
"@ultimat3/entity": "27.2.0",
|
|
43
|
+
"@ultimat3/http": "27.2.0",
|
|
44
|
+
"@ultimat3/i18n": "27.2.0",
|
|
45
|
+
"@ultimat3/jobs": "27.2.0",
|
|
46
|
+
"@ultimat3/mcp": "27.2.0",
|
|
47
|
+
"@ultimat3/money": "27.2.0",
|
|
48
|
+
"@ultimat3/policy": "27.2.0",
|
|
49
|
+
"@ultimat3/query": "27.2.0",
|
|
50
|
+
"@ultimat3/render": "27.2.0",
|
|
51
|
+
"@ultimat3/schema": "27.2.0",
|
|
52
|
+
"@ultimat3/ui": "27.2.0"
|
|
52
53
|
}
|
|
53
54
|
}
|
package/src/actor.ts
CHANGED
package/src/authz.ts
CHANGED
|
@@ -20,6 +20,12 @@ export interface AdminActor {
|
|
|
20
20
|
*/
|
|
21
21
|
readonly orgId?: string;
|
|
22
22
|
readonly roles?: readonly string[];
|
|
23
|
+
/**
|
|
24
|
+
* Direct grants — `Actor.permissions`, held with or without a role (a break-glass account, a
|
|
25
|
+
* staff session not yet enrolled in MFA). The request pipeline decides on roles AND these; the
|
|
26
|
+
* admin read roles only, so a route the pipeline opened answered a 403 screen.
|
|
27
|
+
*/
|
|
28
|
+
readonly permissions?: readonly string[];
|
|
23
29
|
/** BCP-47. Drives every `t()` call and every `Intl` format in the admin. */
|
|
24
30
|
readonly locale?: string;
|
|
25
31
|
/** IANA zone. The admin refuses to render a timestamp without one. */
|
package/src/batch-job.ts
CHANGED
|
@@ -39,6 +39,7 @@ const chunkInput = t.object({
|
|
|
39
39
|
actor: t.object({
|
|
40
40
|
id: t.string,
|
|
41
41
|
roles: t.array(t.string),
|
|
42
|
+
permissions: t.optional(t.array(t.string)),
|
|
42
43
|
orgId: t.optional(t.string),
|
|
43
44
|
locale: t.optional(t.string),
|
|
44
45
|
timeZone: t.optional(t.string),
|
|
@@ -58,6 +59,8 @@ export interface AdminBatchChunk {
|
|
|
58
59
|
readonly actor: {
|
|
59
60
|
readonly id: string;
|
|
60
61
|
readonly roles: readonly string[];
|
|
62
|
+
/** Direct grants: an operator who holds the permission with no role runs the batch too. */
|
|
63
|
+
readonly permissions?: readonly string[] | undefined;
|
|
61
64
|
readonly orgId?: string | undefined;
|
|
62
65
|
readonly locale?: string | undefined;
|
|
63
66
|
readonly timeZone?: string | undefined;
|
|
@@ -87,6 +90,7 @@ export async function runBatchChunk(chunk: AdminBatchChunk): Promise<readonly Ba
|
|
|
87
90
|
const actor: AdminActor = {
|
|
88
91
|
id: chunk.actor.id,
|
|
89
92
|
roles: chunk.actor.roles,
|
|
93
|
+
...(chunk.actor.permissions === undefined ? {} : { permissions: chunk.actor.permissions }),
|
|
90
94
|
...(chunk.actor.orgId === undefined ? {} : { orgId: chunk.actor.orgId }),
|
|
91
95
|
...(chunk.actor.locale === undefined ? {} : { locale: chunk.actor.locale }),
|
|
92
96
|
...(chunk.actor.timeZone === undefined ? {} : { timeZone: chunk.actor.timeZone }),
|
|
@@ -99,6 +103,7 @@ export async function runBatchChunk(chunk: AdminBatchChunk): Promise<readonly Ba
|
|
|
99
103
|
actor: userActor({
|
|
100
104
|
id: actor.id,
|
|
101
105
|
roles: [...(actor.roles ?? [])],
|
|
106
|
+
permissions: [...(actor.permissions ?? [])],
|
|
102
107
|
...(actor.orgId === undefined ? {} : { orgId: actor.orgId }),
|
|
103
108
|
}),
|
|
104
109
|
},
|
|
@@ -143,6 +148,7 @@ export const batchEnqueue =
|
|
|
143
148
|
actor: {
|
|
144
149
|
id: actor.id,
|
|
145
150
|
roles: [...(actor.roles ?? [])],
|
|
151
|
+
...(actor.permissions === undefined ? {} : { permissions: [...actor.permissions] }),
|
|
146
152
|
...(actor.orgId === undefined ? {} : { orgId: actor.orgId }),
|
|
147
153
|
...(actor.locale === undefined ? {} : { locale: actor.locale }),
|
|
148
154
|
...(actor.timeZone === undefined ? {} : { timeZone: actor.timeZone }),
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
// `claimAdminRoute()` — an app FILE serves one admin path in the catch-all's place, so the page
|
|
2
|
+
// keeps the app's own shell and its islands (`hydrate`, budget and island bundle are a file
|
|
3
|
+
// route's). The guard is not the app's: the route's gate, its `meta`, and a `load` that answers
|
|
4
|
+
// through the SAME screen the catch-all serves (`screenFor`), unframed. `claims.ts` records the
|
|
5
|
+
// config issued, and the route table accepts no other on that path.
|
|
6
|
+
|
|
7
|
+
import { type HydrateStrategy, hasContext, isUltimateError } from '@ultimat3/core';
|
|
8
|
+
import { setRedirect, useRequestHeaders } from '@ultimat3/http';
|
|
9
|
+
import {
|
|
10
|
+
defineRoute,
|
|
11
|
+
type RouteBudget,
|
|
12
|
+
type RouteConfig,
|
|
13
|
+
type RouteNavigationMode,
|
|
14
|
+
routeStatusOf,
|
|
15
|
+
withStatus,
|
|
16
|
+
} from '@ultimat3/render';
|
|
17
|
+
import type { JSX } from 'solid-js';
|
|
18
|
+
import type { AdminApp, AdminView } from './admin';
|
|
19
|
+
import { issueClaim } from './claims';
|
|
20
|
+
import type { CrudCtx } from './crud';
|
|
21
|
+
import { AdminPagePathInvalidError } from './errors';
|
|
22
|
+
import type { NavGroup } from './nav';
|
|
23
|
+
import { adminRouteGuard, adminRouteMeta } from './route-config';
|
|
24
|
+
import { screenFor } from './screens';
|
|
25
|
+
|
|
26
|
+
/** What the app's own `load` is handed — called for every visitor the screen answered. */
|
|
27
|
+
export interface AdminClaimLoadArgs {
|
|
28
|
+
readonly ctx: CrudCtx;
|
|
29
|
+
readonly params: Readonly<Record<string, string>>;
|
|
30
|
+
readonly url: string;
|
|
31
|
+
/** The body is the refusal (403), which carries its own `<h1>`. */
|
|
32
|
+
readonly denied: boolean;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface ClaimAdminRouteOptions<TApp> {
|
|
36
|
+
/** Absent: derived from the islands declared above the call, as for any route. */
|
|
37
|
+
readonly hydrate?: HydrateStrategy;
|
|
38
|
+
readonly budget?: RouteBudget;
|
|
39
|
+
readonly navigation?: RouteNavigationMode;
|
|
40
|
+
/**
|
|
41
|
+
* The app's half of the page data — its shell's role, locale and enrolment flag; a claimed
|
|
42
|
+
* dashboard's own figures. Runs AFTER the screen decided, the refused included: when `denied`
|
|
43
|
+
* it may read the visitor, never what the page guards. Answering `withStatus(404, …)` sets the
|
|
44
|
+
* page's status when the screen answered 200.
|
|
45
|
+
*/
|
|
46
|
+
readonly load?: (args: AdminClaimLoadArgs) => TApp | Promise<TApp>;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** What the claimed route's `load` hands the app's page component. */
|
|
50
|
+
export interface AdminClaimData<TApp = undefined> {
|
|
51
|
+
/** The screen's body, decided and unframed: the page, or the refusal naming the permission. */
|
|
52
|
+
readonly body: JSX.Element;
|
|
53
|
+
readonly status: number;
|
|
54
|
+
readonly denied: boolean;
|
|
55
|
+
/** The route's title key; the body of a refusal carries its own heading. */
|
|
56
|
+
readonly titleKey: string;
|
|
57
|
+
/** The nav this actor may see — the admin's `navFor`. */
|
|
58
|
+
readonly nav: readonly NavGroup[];
|
|
59
|
+
/** What `options.load` returned; `undefined` without one. */
|
|
60
|
+
readonly app: TApp;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* A `pages:` entry and the dashboard only. A generated screen posts its forms back to its own URL
|
|
65
|
+
* and re-renders a refused write there — in the admin's layout, from the catch-all — so a claim
|
|
66
|
+
* would split one screen across two shells.
|
|
67
|
+
*/
|
|
68
|
+
const CLAIMABLE: ReadonlySet<AdminView> = new Set<AdminView>(['page', 'dashboard']);
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* The route config for the app file at `path` (the table's full path, base included). Call it
|
|
72
|
+
* BELOW the module's `island()` declarations, as `export const config = claimAdminRoute(…)`: its
|
|
73
|
+
* `defineRoute` drains them, which is how the page hydrates.
|
|
74
|
+
*/
|
|
75
|
+
export function claimAdminRoute<TApp = undefined>(
|
|
76
|
+
admin: AdminApp,
|
|
77
|
+
path: string,
|
|
78
|
+
options: ClaimAdminRouteOptions<TApp> = {},
|
|
79
|
+
): RouteConfig<AdminClaimData<TApp>> {
|
|
80
|
+
const route = admin.routes.find((candidate) => candidate.path === path);
|
|
81
|
+
if (route === undefined) {
|
|
82
|
+
throw new AdminPagePathInvalidError({
|
|
83
|
+
path,
|
|
84
|
+
cause: 'is not a route this admin declares',
|
|
85
|
+
fix: `claimAdminRoute(admin, '<path>') with one of ${claimablePaths(admin)} — or add { path, titleKey, permissions, component } to pages: on defineAdmin()`,
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
if (!CLAIMABLE.has(route.view)) {
|
|
89
|
+
throw new AdminPagePathInvalidError({
|
|
90
|
+
path,
|
|
91
|
+
cause: `is the generated ${route.view} screen, which only the admin serves`,
|
|
92
|
+
fix: `claimAdminRoute(admin, '<path>') with one of ${claimablePaths(admin)}: a pages: entry or the dashboard`,
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
const screen = screenFor(admin, route);
|
|
96
|
+
const config = defineRoute<AdminClaimData<TApp>>({
|
|
97
|
+
render: 'ssr',
|
|
98
|
+
offline: 'network-only',
|
|
99
|
+
policy: adminRouteGuard(route),
|
|
100
|
+
meta: adminRouteMeta(route),
|
|
101
|
+
...(options.hydrate === undefined ? {} : { hydrate: options.hydrate }),
|
|
102
|
+
...(options.budget === undefined ? {} : { budget: options.budget }),
|
|
103
|
+
...(options.navigation === undefined ? {} : { navigation: options.navigation }),
|
|
104
|
+
load: async ({ params, url }) => {
|
|
105
|
+
// The actor exactly as the catch-all resolves it: the admin's `auth.actor` over this request.
|
|
106
|
+
const ctx = await admin.requestCtx(new Request(url, { headers: requestHeaders() }));
|
|
107
|
+
const response = await screen({ ctx, params, url, method: 'GET', form: null, frame: 'none' });
|
|
108
|
+
if (response.kind === 'redirect') setRedirect(response.location, 303);
|
|
109
|
+
const body = response.kind === 'document' ? response.body : null;
|
|
110
|
+
const answered = response.kind === 'document' ? response.status : 200;
|
|
111
|
+
const denied = answered === 403;
|
|
112
|
+
const own = (await options.load?.({ ctx, params, url, denied })) as TApp;
|
|
113
|
+
const status = answered === 200 ? routeStatusOf(own) : answered;
|
|
114
|
+
const data: AdminClaimData<TApp> = {
|
|
115
|
+
body,
|
|
116
|
+
status,
|
|
117
|
+
denied,
|
|
118
|
+
titleKey: route.titleKey,
|
|
119
|
+
nav: admin.navFor(ctx),
|
|
120
|
+
app: own,
|
|
121
|
+
};
|
|
122
|
+
return status === 200 ? data : withStatus(status, data);
|
|
123
|
+
},
|
|
124
|
+
});
|
|
125
|
+
return issueClaim(route.path, config);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* The headers of the request being rendered — the session cookie `auth.actor` reads. Outside a
|
|
130
|
+
* request (a test rendering the route) there are none, and the actor resolves as nobody: refused.
|
|
131
|
+
*/
|
|
132
|
+
function requestHeaders(): Headers {
|
|
133
|
+
if (!hasContext()) return new Headers();
|
|
134
|
+
try {
|
|
135
|
+
return useRequestHeaders();
|
|
136
|
+
} catch (error) {
|
|
137
|
+
if (isUltimateError(error) && error.code === 'X_NO_REQUEST') return new Headers();
|
|
138
|
+
throw error;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const claimablePaths = (admin: AdminApp): string =>
|
|
143
|
+
admin.routes
|
|
144
|
+
.filter((route) => CLAIMABLE.has(route.view))
|
|
145
|
+
.map((route) => route.path)
|
|
146
|
+
.join(', ');
|
package/src/claims.ts
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
// The configs `claimAdminRoute` issued, each for the one path it was issued for. A leaf, read by
|
|
2
|
+
// `route-config.ts` (the mount's `claimable`) and written by `claim-route.ts` alone — never
|
|
3
|
+
// exported from the package: an app that could add a config here could put any route on an
|
|
4
|
+
// admin path, guard or none.
|
|
5
|
+
|
|
6
|
+
import type { RouteConfig } from '@ultimat3/render';
|
|
7
|
+
|
|
8
|
+
/** By IDENTITY: a spread copy of an issued config — a swapped `load` — is not issued. */
|
|
9
|
+
const issued = new WeakMap<object, string>();
|
|
10
|
+
|
|
11
|
+
export function issueClaim<TConfig extends object>(path: string, config: TConfig): TConfig {
|
|
12
|
+
issued.set(config, path);
|
|
13
|
+
return config;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** May `config` serve `path` in the admin's place? Only if it was issued for exactly that path. */
|
|
17
|
+
export const claimable = (path: string, config: RouteConfig): boolean =>
|
|
18
|
+
issued.get(config) === path;
|
package/src/index.ts
CHANGED
|
@@ -123,6 +123,12 @@ export {
|
|
|
123
123
|
} from './batch-job';
|
|
124
124
|
export { BATCH_MATCHING_REASON, matchingConfirmationToken } from './batch-matching';
|
|
125
125
|
export { BATCH_QUEUED_REASON } from './batch-queue';
|
|
126
|
+
export {
|
|
127
|
+
type AdminClaimData,
|
|
128
|
+
type AdminClaimLoadArgs,
|
|
129
|
+
type ClaimAdminRouteOptions,
|
|
130
|
+
claimAdminRoute,
|
|
131
|
+
} from './claim-route';
|
|
126
132
|
export {
|
|
127
133
|
type AdminUpdateOptions,
|
|
128
134
|
adminCreate,
|
package/src/mcp.ts
CHANGED
|
@@ -311,6 +311,8 @@ const inputSchema = (tool: AdminMcpTool): JsonSchema => ({
|
|
|
311
311
|
const adminActorOf = (caller: McpCaller): AdminActor => ({
|
|
312
312
|
id: caller.actor.id,
|
|
313
313
|
roles: caller.actor.roles,
|
|
314
|
+
// Direct grants too: the pipeline decides on roles and `permissions` as one set.
|
|
315
|
+
...(caller.actor.permissions.length === 0 ? {} : { permissions: caller.actor.permissions }),
|
|
314
316
|
...(caller.actor.orgId === undefined ? {} : { orgId: caller.actor.orgId }),
|
|
315
317
|
});
|
|
316
318
|
|
|
@@ -453,7 +455,12 @@ export function adminMcp(opts: AdminMcpOptions): AppMcp {
|
|
|
453
455
|
return actor === null
|
|
454
456
|
? null
|
|
455
457
|
: {
|
|
456
|
-
actor: agentActor({
|
|
458
|
+
actor: agentActor({
|
|
459
|
+
id: actor.id,
|
|
460
|
+
roles: actor.roles ?? [],
|
|
461
|
+
permissions: actor.permissions ?? [],
|
|
462
|
+
orgId: actor.orgId,
|
|
463
|
+
}),
|
|
457
464
|
scopes: adminTokenScopes(actor.tokenScopes),
|
|
458
465
|
};
|
|
459
466
|
},
|
package/src/policy-bridge.ts
CHANGED
|
@@ -15,6 +15,7 @@ import {
|
|
|
15
15
|
import {
|
|
16
16
|
type AdminActor,
|
|
17
17
|
type AdminAuthz,
|
|
18
|
+
type AdminAuthzQuery,
|
|
18
19
|
type AdminDecision,
|
|
19
20
|
type AdminSubject,
|
|
20
21
|
adminAllowed,
|
|
@@ -31,7 +32,14 @@ const policyActor = (actor: AdminActor): Actor =>
|
|
|
31
32
|
// `orgId` rides along, and its absence used to be silent: every admin decision was evaluated
|
|
32
33
|
// with `actor.orgId === undefined`, so an org-scoped rule could not fire and a role-only rule
|
|
33
34
|
// allowed a row from another tenant.
|
|
34
|
-
|
|
35
|
+
// And the direct grants: `can()` reads roles and `permissions` as one set, exactly as the request
|
|
36
|
+
// pipeline does, so a permission held with no role opens the screen it opens the route for.
|
|
37
|
+
userActor({
|
|
38
|
+
id: actor.id,
|
|
39
|
+
roles: actor.roles ?? [],
|
|
40
|
+
permissions: actor.permissions ?? [],
|
|
41
|
+
orgId: actor.orgId,
|
|
42
|
+
});
|
|
35
43
|
|
|
36
44
|
/**
|
|
37
45
|
* `evaluate()`'s result is read structurally: the policy layer owns its own decision type,
|
|
@@ -99,34 +107,34 @@ const evaluated = (
|
|
|
99
107
|
* whose rules read the row or the tenant passes `policyAuthz({ policies })` instead.
|
|
100
108
|
*/
|
|
101
109
|
export function roleAuthz(): AdminAuthz {
|
|
102
|
-
return {
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
}
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
110
|
+
return { decide: decideByRoleMap };
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function decideByRoleMap({ permission, actor, subject }: AdminAuthzQuery): AdminDecision {
|
|
114
|
+
// Asked BEFORE `can()`, which throws on a name the registry lacks: a decision is an answer,
|
|
115
|
+
// and an undeclared permission is a refusal with its fix, never a 500 out of a nav render.
|
|
116
|
+
if (!isPermission(permission) || !isKnownPermission(permission)) {
|
|
117
|
+
return adminDenied(permission, 'admin.policy.missing', [
|
|
118
|
+
`"${permission}" is not a declared resource:verb permission`,
|
|
119
|
+
`fix: definePermissions(['${permission}']), then grant it to a role in defineRoles()`,
|
|
120
|
+
]);
|
|
121
|
+
}
|
|
122
|
+
// `can()` asserts the name against the registry `declareAdminPermissions` filled. The cast
|
|
123
|
+
// is the same one a route guard's bare string takes: the registry, not the type, is the check.
|
|
124
|
+
const own = evaluated(permission, can(permission as KnownPermission), actor, subject);
|
|
125
|
+
if (own.allowed) return own;
|
|
126
|
+
// The admin's implications, as `staticAuthz` applies them: a role granting `admin:destroy`
|
|
127
|
+
// holds `admin:write` and `admin:read` too. Without this the same grants answered two ways.
|
|
128
|
+
for (const implier of impliersOf(permission)) {
|
|
129
|
+
const carried = evaluated(implier, can(implier as KnownPermission), actor, subject);
|
|
130
|
+
if (carried.allowed) {
|
|
131
|
+
return adminAllowed(permission, carried.reason, [
|
|
132
|
+
...carried.trace,
|
|
133
|
+
`${permission} is implied by ${implier}`,
|
|
134
|
+
]);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
return own;
|
|
130
138
|
}
|
|
131
139
|
|
|
132
140
|
/**
|
|
@@ -149,20 +157,28 @@ export interface PolicyAuthzInput {
|
|
|
149
157
|
}
|
|
150
158
|
|
|
151
159
|
/**
|
|
152
|
-
*
|
|
153
|
-
*
|
|
160
|
+
* The map REFINES, the role map GRANTS. A permission with a policy here is decided by it (a row
|
|
161
|
+
* or tenant rule, or a `deny` that closes what a role grants); one the map omits is decided by
|
|
162
|
+
* the role map and the actor's direct grants, exactly as `roleAuthz` and the request pipeline
|
|
163
|
+
* decide it. Still closed: a permission nobody declared is denied with the fix in the trace, and
|
|
164
|
+
* a declared one is denied by `can()` when neither a role nor the actor's direct grants hold it.
|
|
165
|
+
*
|
|
166
|
+
* Until 27.2.0 an omitted permission was denied outright, so the framework's own screens needed
|
|
167
|
+
* the same grant twice — `job:read` in `defineRoles` AND `'job:read': can('job:read')` here — and
|
|
168
|
+
* an app that wrote only the first had a jobs dashboard every operator was refused.
|
|
154
169
|
*/
|
|
155
170
|
export function policyAuthz(input: PolicyAuthzInput): AdminAuthz {
|
|
156
171
|
return {
|
|
157
|
-
decide(
|
|
158
|
-
const policy = input.policies
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
]);
|
|
172
|
+
decide(query): AdminDecision {
|
|
173
|
+
const policy = Object.hasOwn(input.policies, query.permission)
|
|
174
|
+
? input.policies[query.permission]
|
|
175
|
+
: undefined;
|
|
176
|
+
if (policy !== undefined) {
|
|
177
|
+
return evaluated(query.permission, policy, query.actor, query.subject);
|
|
164
178
|
}
|
|
165
|
-
|
|
179
|
+
const decision = decideByRoleMap(query);
|
|
180
|
+
const by = `no policy mapped for "${query.permission}": decided by the role map`;
|
|
181
|
+
return { ...decision, trace: [by, ...decision.trace] };
|
|
166
182
|
},
|
|
167
183
|
};
|
|
168
184
|
}
|
package/src/route-config.ts
CHANGED
|
@@ -11,6 +11,7 @@ import {
|
|
|
11
11
|
registerMountedRoutes,
|
|
12
12
|
} from '@ultimat3/render';
|
|
13
13
|
import type { AdminRoute } from './admin';
|
|
14
|
+
import { claimable } from './claims';
|
|
14
15
|
import { AdminPageUnguardedError } from './errors';
|
|
15
16
|
|
|
16
17
|
/** What `RouteDescriptor.file` reads for an admin route: the package, since no app file is one. */
|
|
@@ -23,16 +24,33 @@ export interface AdminRouteDefinition {
|
|
|
23
24
|
}
|
|
24
25
|
|
|
25
26
|
/**
|
|
26
|
-
* The
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
* An empty list is refused here too: `render: 'ssr'` with no policy is a public dashboard.
|
|
27
|
+
* The coarse gate of `route`: `permissions[0]` (always `admin:read`, put there by
|
|
28
|
+
* `permissionsForOperation`/`pagePermissions`); the rest of the pair is decided per request, in
|
|
29
|
+
* `crud.ts` for a resource screen and in `guardedScreen()` for every other one. An empty list is
|
|
30
|
+
* refused: `render: 'ssr'` with no policy is a public dashboard.
|
|
31
31
|
*/
|
|
32
|
-
export function
|
|
32
|
+
export function adminRouteGuard(route: AdminRoute): RouteGuard {
|
|
33
33
|
const permission = route.permissions[0];
|
|
34
34
|
if (permission === undefined) throw new AdminPageUnguardedError({ path: route.path });
|
|
35
|
-
|
|
35
|
+
return { permission };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Never indexed and never followed, whatever the screen answers — a list, a refusal, a 404: an
|
|
40
|
+
* admin URL in a search result is a row id handed to a crawler. The claimed route
|
|
41
|
+
* (`claim-route.ts`) is served under the same function.
|
|
42
|
+
*/
|
|
43
|
+
export const adminRouteMeta = (route: AdminRoute) => () => ({
|
|
44
|
+
title: t(route.titleKey),
|
|
45
|
+
robots: { index: false, follow: false },
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* The author never writes this `defineRoute` call, so the author cannot omit its `policy` —
|
|
50
|
+
* which is the whole mechanism.
|
|
51
|
+
*/
|
|
52
|
+
export function adminRouteDefinition(route: AdminRoute): AdminRouteDefinition {
|
|
53
|
+
const policy = adminRouteGuard(route);
|
|
36
54
|
return {
|
|
37
55
|
policy,
|
|
38
56
|
config: defineRoute({
|
|
@@ -41,14 +59,13 @@ export function adminRouteDefinition(route: AdminRoute): AdminRouteDefinition {
|
|
|
41
59
|
// every control on an admin screen is a link or a native form, so there is nothing to
|
|
42
60
|
// hydrate. `never` is a REFUSAL and not just a default — an `island()` rendered on this
|
|
43
61
|
// route throws `X_ISLAND_NOT_HYDRATED` (`@ultimat3/render`'s `island-collector.ts`), and no
|
|
44
|
-
// admin module declares one.
|
|
62
|
+
// admin module declares one. An app that needs one serves the path from its own file:
|
|
63
|
+
// `claimAdminRoute` (`claim-route.ts`).
|
|
45
64
|
render: 'ssr',
|
|
46
65
|
offline: 'network-only',
|
|
47
66
|
hydrate: 'never',
|
|
48
67
|
policy,
|
|
49
|
-
|
|
50
|
-
// an admin URL in a search result is a row id handed to a crawler.
|
|
51
|
-
meta: () => ({ title: t(route.titleKey), robots: { index: false, follow: false } }),
|
|
68
|
+
meta: adminRouteMeta(route),
|
|
52
69
|
}),
|
|
53
70
|
};
|
|
54
71
|
}
|
|
@@ -61,7 +78,9 @@ export function adminRouteDefinition(route: AdminRoute): AdminRouteDefinition {
|
|
|
61
78
|
*/
|
|
62
79
|
export function mountAdminRoutes(basePath: string, routes: readonly AdminRoute[]): void {
|
|
63
80
|
registerMountedRoutes(
|
|
64
|
-
|
|
81
|
+
// `claimable`: the one way an app file serves a path of this table — a config
|
|
82
|
+
// `claimAdminRoute` issued for exactly that path. Anything else on it is X_ROUTE_DUPLICATE.
|
|
83
|
+
{ key: basePath, by: 'defineAdmin', file: ADMIN_ROUTE_FILE, surface: 'app', claimable },
|
|
65
84
|
routes.map((route) => ({
|
|
66
85
|
path: route.path,
|
|
67
86
|
config: adminRouteDefinition(route).config,
|
package/src/screen-frame.tsx
CHANGED
|
@@ -23,6 +23,12 @@ export interface AdminRouteRequest extends AdminPageProps {
|
|
|
23
23
|
readonly method: 'GET' | 'POST';
|
|
24
24
|
/** The parsed form. `null` on a GET; a POST with no fields is `{}`. */
|
|
25
25
|
readonly form: Readonly<Record<string, unknown>> | null;
|
|
26
|
+
/**
|
|
27
|
+
* Who frames the body. Absent or `'admin'`: the admin's own layout. `'none'`: the body alone,
|
|
28
|
+
* for an app file that claimed the route (`claimAdminRoute`) and frames it in its own shell.
|
|
29
|
+
* The decision, the audit and the status are the screen's either way — only the wrapper differs.
|
|
30
|
+
*/
|
|
31
|
+
readonly frame?: 'admin' | 'none';
|
|
26
32
|
}
|
|
27
33
|
|
|
28
34
|
export type AdminRouteResponse =
|
|
@@ -83,6 +89,7 @@ export function framed(
|
|
|
83
89
|
body: JSX.Element,
|
|
84
90
|
status = 200,
|
|
85
91
|
): AdminRouteResponse {
|
|
92
|
+
if (request.frame === 'none') return { kind: 'document', status, body };
|
|
86
93
|
return {
|
|
87
94
|
kind: 'document',
|
|
88
95
|
status,
|