@ultimat3/admin 27.0.0 → 27.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 +3 -3
- package/README.md +10 -1
- package/package.json +17 -16
- package/src/claim-route.ts +146 -0
- package/src/claims.ts +18 -0
- package/src/index.ts +6 -0
- 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
|
@@ -91,7 +91,7 @@ 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.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",
|
|
@@ -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.1.0",
|
|
38
|
+
"@ultimat3/ai": "27.1.0",
|
|
39
|
+
"@ultimat3/cache": "27.1.0",
|
|
40
|
+
"@ultimat3/core": "27.1.0",
|
|
41
|
+
"@ultimat3/db": "27.1.0",
|
|
42
|
+
"@ultimat3/entity": "27.1.0",
|
|
43
|
+
"@ultimat3/http": "27.1.0",
|
|
44
|
+
"@ultimat3/i18n": "27.1.0",
|
|
45
|
+
"@ultimat3/jobs": "27.1.0",
|
|
46
|
+
"@ultimat3/mcp": "27.1.0",
|
|
47
|
+
"@ultimat3/money": "27.1.0",
|
|
48
|
+
"@ultimat3/policy": "27.1.0",
|
|
49
|
+
"@ultimat3/query": "27.1.0",
|
|
50
|
+
"@ultimat3/render": "27.1.0",
|
|
51
|
+
"@ultimat3/schema": "27.1.0",
|
|
52
|
+
"@ultimat3/ui": "27.1.0"
|
|
52
53
|
}
|
|
53
54
|
}
|
|
@@ -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/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,
|