@ultimat3/admin 1.2.0 → 2.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CLAUDE.md +52 -0
- package/README.md +117 -3
- package/package.json +17 -14
- package/src/admin.ts +35 -6
- package/src/crud.ts +33 -4
- package/src/detail.tsx +4 -1
- package/src/dev/data.ts +69 -7
- package/src/dev/facts.ts +48 -1
- package/src/dev/index.ts +3 -0
- package/src/dev/panel-cache.ts +2 -2
- package/src/dev/panel-db.ts +67 -12
- package/src/dev/panel-jobs.ts +15 -4
- package/src/dev/panel-live.ts +20 -5
- package/src/dev/panel-mail.ts +2 -2
- package/src/dev/panel-manifest.ts +2 -2
- package/src/dev/panel-policy.ts +2 -2
- package/src/dev/panel-routes.ts +2 -2
- package/src/dev/panel-timeline.ts +26 -5
- package/src/dev/panel.ts +7 -3
- package/src/dev/server.ts +21 -10
- package/src/errors.ts +45 -2
- package/src/index.ts +17 -1
- package/src/mcp-tools.ts +25 -2
- package/src/mcp.ts +16 -11
- package/src/nav.ts +10 -0
- package/src/page-guard.tsx +58 -0
- package/src/pages.ts +117 -0
- package/src/pagination.ts +29 -5
- package/src/registry.ts +6 -0
- package/src/routes.ts +64 -6
- package/src/search.ts +29 -18
- package/src/widget-value.ts +19 -3
- package/src/widgets.tsx +11 -9
package/src/mcp.ts
CHANGED
|
@@ -27,12 +27,7 @@ import {
|
|
|
27
27
|
type CrudResult,
|
|
28
28
|
} from './crud';
|
|
29
29
|
import type { AdminFieldType } from './fields';
|
|
30
|
-
import {
|
|
31
|
-
type AdminMcpTool,
|
|
32
|
-
type AdminToolField,
|
|
33
|
-
adminMcpTools,
|
|
34
|
-
adminToolCatalog,
|
|
35
|
-
} from './mcp-tools';
|
|
30
|
+
import { type AdminMcpTool, adminMcpTools, adminToolCatalog } from './mcp-tools';
|
|
36
31
|
import { confirmationToken } from './permissions';
|
|
37
32
|
import type { AdminAction, AdminRow } from './registry';
|
|
38
33
|
import { adminSearch } from './search';
|
|
@@ -187,13 +182,23 @@ const JSON_TYPE: Readonly<Record<AdminFieldType, NonNullable<JsonSchema['type']>
|
|
|
187
182
|
file: 'string',
|
|
188
183
|
};
|
|
189
184
|
|
|
190
|
-
|
|
185
|
+
/**
|
|
186
|
+
* `additionalProperties` is closed for every derived CRUD tool — the admin knows every field an
|
|
187
|
+
* entity has, so an argument outside that set is a mistake worth refusing at the client.
|
|
188
|
+
*
|
|
189
|
+
* It is OPEN for an action tool, and that is the whole point: the fields `mcp-tools.ts` declares
|
|
190
|
+
* there are the admin's own envelope (`id`, `confirmation`), while the action's real input is its
|
|
191
|
+
* own Standard Schema, which this package does not own and does not project. Closed would refuse
|
|
192
|
+
* exactly the arguments the action needs; a made-up field list would describe a contract nobody
|
|
193
|
+
* validates against. Open lets the action's own validation be the one that decides.
|
|
194
|
+
*/
|
|
195
|
+
const inputSchema = (tool: AdminMcpTool): JsonSchema => ({
|
|
191
196
|
type: 'object',
|
|
192
197
|
properties: Object.fromEntries(
|
|
193
|
-
|
|
198
|
+
tool.input.map((field) => [field.name, { type: JSON_TYPE[field.type] }]),
|
|
194
199
|
),
|
|
195
|
-
required:
|
|
196
|
-
additionalProperties:
|
|
200
|
+
required: tool.input.filter((field) => field.required).map((field) => field.name),
|
|
201
|
+
additionalProperties: tool.kind === 'action',
|
|
197
202
|
});
|
|
198
203
|
|
|
199
204
|
/**
|
|
@@ -256,7 +261,7 @@ function toMcpTool(opts: AdminMcpOptions, requestId: () => string, tool: AdminMc
|
|
|
256
261
|
return {
|
|
257
262
|
name: tool.name,
|
|
258
263
|
description: tool.description,
|
|
259
|
-
inputSchema: inputSchema(tool
|
|
264
|
+
inputSchema: inputSchema(tool),
|
|
260
265
|
destructive: tool.destructive,
|
|
261
266
|
// Visibility IS the gate: a tool this actor may not call is absent from `tools/list` and
|
|
262
267
|
// answers ToolNotFound on call, never Forbidden — Forbidden would confirm the tool exists
|
package/src/nav.ts
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
// `<entity>:read` pair that gates the page itself. A nav item an actor cannot open is not a
|
|
3
3
|
// nav item — the alternative is a sidebar full of 403s.
|
|
4
4
|
|
|
5
|
+
import { isAllowed } from './authz';
|
|
5
6
|
import type { CrudCtx } from './crud';
|
|
6
7
|
import { canOperate } from './crud';
|
|
7
8
|
import type { AdminResource } from './resource';
|
|
@@ -12,6 +13,12 @@ export interface NavItem {
|
|
|
12
13
|
readonly href: string;
|
|
13
14
|
/** The entity this item lists, or `null` for a built-in page. */
|
|
14
15
|
readonly entity: string | null;
|
|
16
|
+
/**
|
|
17
|
+
* What opening it needs. Set for a custom page (`pages.ts`), absent for a resource item —
|
|
18
|
+
* whose gate is its own `list` operation. Without it, an item with no entity was visible to
|
|
19
|
+
* everyone, which put a link to an ops screen in the sidebar of an actor the page refuses.
|
|
20
|
+
*/
|
|
21
|
+
readonly permissions?: readonly string[];
|
|
15
22
|
}
|
|
16
23
|
|
|
17
24
|
export interface NavGroup {
|
|
@@ -86,6 +93,9 @@ export function visibleNav(
|
|
|
86
93
|
ctx: CrudCtx,
|
|
87
94
|
): readonly NavGroup[] {
|
|
88
95
|
const visible = (item: NavItem): boolean => {
|
|
96
|
+
if (item.permissions !== undefined && !isAllowed(ctx.authz, item.permissions, ctx.actor)) {
|
|
97
|
+
return false;
|
|
98
|
+
}
|
|
89
99
|
if (item.entity === null) return true;
|
|
90
100
|
const resource = resources.find((candidate) => candidate.name === item.entity);
|
|
91
101
|
return resource !== undefined && canOperate(resource, 'list', ctx);
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
// The wrapper that makes a custom page's authz unskippable. `routes.ts` never hands the router
|
|
2
|
+
// the author's component — it hands this one, which asks the SAME `decideAll` every CRUD call
|
|
3
|
+
// and every nav item asks, audits the refusal, and only then calls the author's code.
|
|
4
|
+
|
|
5
|
+
import { t } from '@ultimat3/i18n';
|
|
6
|
+
import type { JSX } from 'solid-js';
|
|
7
|
+
import type { AdminRoute } from './admin';
|
|
8
|
+
import { deniedDraft } from './audit';
|
|
9
|
+
import { type AdminDecision, decideAll } from './authz';
|
|
10
|
+
import type { AdminPageComponent, AdminPageProps } from './pages';
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* The refusal, rendered as the page. Not a redirect and not a 404: an operator who is missing a
|
|
14
|
+
* grant needs to read WHICH permission refused them, which is the same string the audit row and
|
|
15
|
+
* the `/_x` policy panel carry.
|
|
16
|
+
*/
|
|
17
|
+
export function AdminPageDenied(props: {
|
|
18
|
+
readonly titleKey: string;
|
|
19
|
+
readonly decision: AdminDecision;
|
|
20
|
+
}): JSX.Element {
|
|
21
|
+
return (
|
|
22
|
+
<section class="x-admin-denied" role="alert">
|
|
23
|
+
<h1>{t(props.titleKey)}</h1>
|
|
24
|
+
<p>
|
|
25
|
+
{t('admin.denied.body', {
|
|
26
|
+
permission: props.decision.permission,
|
|
27
|
+
reason: props.decision.reason,
|
|
28
|
+
})}
|
|
29
|
+
</p>
|
|
30
|
+
</section>
|
|
31
|
+
);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Wrap once, at route-table build time. The author's component is never reachable from
|
|
36
|
+
* `adminRoutes()`, so "the page that forgot its policy line" has nowhere left to exist.
|
|
37
|
+
*/
|
|
38
|
+
export function guardedPage(route: AdminRoute, component: AdminPageComponent): AdminPageComponent {
|
|
39
|
+
return async (props: AdminPageProps): Promise<JSX.Element> => {
|
|
40
|
+
const decision = decideAll(props.ctx.authz, route.permissions, props.ctx.actor);
|
|
41
|
+
if (!decision.allowed) {
|
|
42
|
+
// The page IS the subject here, so its path is what the audit row names — there is no
|
|
43
|
+
// entity and no row id to key a refused screen by.
|
|
44
|
+
await props.ctx.audit.append(
|
|
45
|
+
deniedDraft({
|
|
46
|
+
requestId: props.ctx.requestId,
|
|
47
|
+
actor: props.ctx.actor,
|
|
48
|
+
operation: 'page',
|
|
49
|
+
kind: 'operation',
|
|
50
|
+
entity: route.path,
|
|
51
|
+
decision,
|
|
52
|
+
}),
|
|
53
|
+
);
|
|
54
|
+
return <AdminPageDenied titleKey={route.titleKey} decision={decision} />;
|
|
55
|
+
}
|
|
56
|
+
return component(props);
|
|
57
|
+
};
|
|
58
|
+
}
|
package/src/pages.ts
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
// The `pages:` seam — a screen no generator would write (a reconciliation fixer, a proxy health
|
|
2
|
+
// board, a deploy button), declared as data so it lands in the SAME route table, nav and
|
|
3
|
+
// permission pair the generated screens use. Nothing renders here: this file decides a page's
|
|
4
|
+
// path and its permissions, so `routes.ts` has exactly one thing to guard.
|
|
5
|
+
|
|
6
|
+
import type { JSX } from 'solid-js';
|
|
7
|
+
import type { AdminRoute } from './admin';
|
|
8
|
+
import type { CrudCtx } from './crud';
|
|
9
|
+
import { AdminPagePathInvalidError, AdminPageUnguardedError } from './errors';
|
|
10
|
+
import type { NavItem } from './nav';
|
|
11
|
+
import { ADMIN_READ } from './permissions';
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* What a page component is handed. `ctx` is the same per-request handle every CRUD call takes,
|
|
15
|
+
* and it is REQUIRED BY THE TYPE: a component that cannot be called without a ctx cannot be
|
|
16
|
+
* mounted without the guard that reads one.
|
|
17
|
+
*/
|
|
18
|
+
export interface AdminPageProps {
|
|
19
|
+
readonly ctx: CrudCtx;
|
|
20
|
+
readonly params: Readonly<Record<string, string>>;
|
|
21
|
+
readonly url: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** An ordinary SolidJS component. Async, because a page with data has no `load` seam. */
|
|
25
|
+
export type AdminPageComponent = (props: AdminPageProps) => JSX.Element | Promise<JSX.Element>;
|
|
26
|
+
|
|
27
|
+
export interface AdminCustomPage {
|
|
28
|
+
/** Rooted at the admin's `basePath`: `/ops`, never `/admin/ops` and never `ops`. */
|
|
29
|
+
readonly path: string;
|
|
30
|
+
readonly titleKey: string;
|
|
31
|
+
readonly component: AdminPageComponent;
|
|
32
|
+
/** The nav group key. Omitted means reachable by URL but not linked. */
|
|
33
|
+
readonly navGroup?: string;
|
|
34
|
+
/** At least one. `admin:read` is composed in front of it; empty is X_ADMIN_PAGE_UNGUARDED. */
|
|
35
|
+
readonly permissions: readonly string[];
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* The frame's gate, then the page's own — the exact pair `permissionsForOperation()` builds for a
|
|
40
|
+
* resource, so a custom page is decided by the same `decideAll` and cannot invent a third shape.
|
|
41
|
+
*/
|
|
42
|
+
export function pagePermissions(page: AdminCustomPage): readonly string[] {
|
|
43
|
+
const out: string[] = [ADMIN_READ];
|
|
44
|
+
for (const permission of page.permissions) {
|
|
45
|
+
const trimmed = permission.trim();
|
|
46
|
+
if (trimmed !== '' && !out.includes(trimmed)) out.push(trimmed);
|
|
47
|
+
}
|
|
48
|
+
return out;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function assertGuarded(page: AdminCustomPage): void {
|
|
52
|
+
const declared = page.permissions.filter((permission) => permission.trim() !== '');
|
|
53
|
+
if (declared.length === 0) throw new AdminPageUnguardedError({ path: page.path });
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function assertPath(page: AdminCustomPage, fullPath: string, taken: ReadonlySet<string>): void {
|
|
57
|
+
const refuse = (cause: string, fix: string): never => {
|
|
58
|
+
throw new AdminPagePathInvalidError({ path: page.path, cause, fix });
|
|
59
|
+
};
|
|
60
|
+
if (!page.path.startsWith('/')) {
|
|
61
|
+
refuse('is not rooted', `write path: '/${page.path}' — every page path starts with a slash`);
|
|
62
|
+
}
|
|
63
|
+
if (page.path.length < 2 || page.path.endsWith('/') || page.path.includes('//')) {
|
|
64
|
+
refuse('is not a usable path', "write path: '/ops' — one leading slash, no trailing slash");
|
|
65
|
+
}
|
|
66
|
+
if (taken.has(fullPath)) {
|
|
67
|
+
refuse(
|
|
68
|
+
`is already served by another admin route (${fullPath})`,
|
|
69
|
+
`rename the page, e.g. path: '${page.path}-ops'`,
|
|
70
|
+
);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Pages as routes. `taken` is every path the generated screens already claimed, so a page that
|
|
76
|
+
* would shadow `/admin/posts` fails at declaration rather than silently winning or losing a
|
|
77
|
+
* router race.
|
|
78
|
+
*/
|
|
79
|
+
export function pageRoutes(
|
|
80
|
+
basePath: string,
|
|
81
|
+
pages: readonly AdminCustomPage[],
|
|
82
|
+
taken: readonly string[],
|
|
83
|
+
): readonly AdminRoute[] {
|
|
84
|
+
const claimed = new Set(taken);
|
|
85
|
+
const routes: AdminRoute[] = [];
|
|
86
|
+
for (const page of pages) {
|
|
87
|
+
assertGuarded(page);
|
|
88
|
+
const fullPath = `${basePath}${page.path}`;
|
|
89
|
+
assertPath(page, fullPath, claimed);
|
|
90
|
+
claimed.add(fullPath);
|
|
91
|
+
routes.push({
|
|
92
|
+
path: fullPath,
|
|
93
|
+
view: 'page',
|
|
94
|
+
entity: null,
|
|
95
|
+
titleKey: page.titleKey,
|
|
96
|
+
permissions: pagePermissions(page),
|
|
97
|
+
component: page.component,
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
return routes;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** Nav entries for the pages that asked for one, carrying the permissions that hide them. */
|
|
104
|
+
export function pageNavItems(
|
|
105
|
+
pages: readonly AdminCustomPage[],
|
|
106
|
+
): readonly (NavItem & { readonly group: string })[] {
|
|
107
|
+
return pages
|
|
108
|
+
.filter((page) => page.navGroup !== undefined)
|
|
109
|
+
.map((page) => ({
|
|
110
|
+
key: page.path,
|
|
111
|
+
labelKey: page.titleKey,
|
|
112
|
+
href: page.path,
|
|
113
|
+
entity: null,
|
|
114
|
+
permissions: pagePermissions(page),
|
|
115
|
+
group: page.navGroup ?? '',
|
|
116
|
+
}));
|
|
117
|
+
}
|
package/src/pagination.ts
CHANGED
|
@@ -71,6 +71,7 @@ export interface AdminPage<Row extends AdminRow> {
|
|
|
71
71
|
readonly pageSize: number;
|
|
72
72
|
readonly nextCursor: string | null;
|
|
73
73
|
readonly prevCursor: string | null;
|
|
74
|
+
/** A page exists AFTER this one — what the Next control is enabled by, in both directions. */
|
|
74
75
|
readonly hasMore: boolean;
|
|
75
76
|
}
|
|
76
77
|
|
|
@@ -103,7 +104,21 @@ const cursorValue = (row: AdminRow, field: string): string => {
|
|
|
103
104
|
return value === null || value === undefined ? '' : String(value);
|
|
104
105
|
};
|
|
105
106
|
|
|
106
|
-
/**
|
|
107
|
+
/**
|
|
108
|
+
* Turn `limit + 1` rows into a page plus the cursors that walk off either end.
|
|
109
|
+
*
|
|
110
|
+
* The extra row is on the side the keyset walked TOWARD, so the direction decides everything:
|
|
111
|
+
* - `after` (and the first page, which has no cursor): the overflow row is the tail, and it means
|
|
112
|
+
* a next page exists. A previous page exists iff a cursor got us here.
|
|
113
|
+
* - `before`: the overflow row is the HEAD — the repo returns the page in the query's sort order,
|
|
114
|
+
* so the row furthest back is index 0 — and it means a *previous* page exists. A next page
|
|
115
|
+
* always exists, because paging backwards is only reachable from a later page.
|
|
116
|
+
*
|
|
117
|
+
* Reading `hasMore` as "the fetch overflowed" regardless of direction disabled Next on every
|
|
118
|
+
* backward page (the operator could not walk back to where they came from) and left Previous
|
|
119
|
+
* enabled past the first row; trimming the tail on a backward page silently skipped the row next
|
|
120
|
+
* to the cursor. One flag, both bugs.
|
|
121
|
+
*/
|
|
107
122
|
export function pageFrom<Row extends AdminRow>(
|
|
108
123
|
resource: AdminResource<Row>,
|
|
109
124
|
req: PageRequest,
|
|
@@ -111,11 +126,20 @@ export function pageFrom<Row extends AdminRow>(
|
|
|
111
126
|
): AdminPage<Row> {
|
|
112
127
|
const sort = req.sort ?? resource.defaultSort;
|
|
113
128
|
const pageSize = Math.max(1, Math.min(req.limit ?? resource.pageSize, 200));
|
|
114
|
-
const
|
|
115
|
-
const
|
|
129
|
+
const incoming = decodeAdminCursor(resource, req.cursor);
|
|
130
|
+
const backwards = incoming?.direction === 'before';
|
|
131
|
+
const overflow = fetched.length > pageSize;
|
|
132
|
+
|
|
133
|
+
const rows = !overflow
|
|
134
|
+
? fetched
|
|
135
|
+
: backwards
|
|
136
|
+
? fetched.slice(fetched.length - pageSize)
|
|
137
|
+
: fetched.slice(0, pageSize);
|
|
116
138
|
const last = rows[rows.length - 1];
|
|
117
139
|
const first = rows[0];
|
|
118
|
-
|
|
140
|
+
|
|
141
|
+
const hasMore = backwards ? true : overflow;
|
|
142
|
+
const hasPrevious = backwards ? overflow : incoming !== null;
|
|
119
143
|
|
|
120
144
|
return {
|
|
121
145
|
rows,
|
|
@@ -132,7 +156,7 @@ export function pageFrom<Row extends AdminRow>(
|
|
|
132
156
|
})
|
|
133
157
|
: null,
|
|
134
158
|
prevCursor:
|
|
135
|
-
|
|
159
|
+
hasPrevious && first !== undefined
|
|
136
160
|
? encodeAdminCursor(resource, {
|
|
137
161
|
direction: 'before',
|
|
138
162
|
field: sort.field,
|
package/src/registry.ts
CHANGED
|
@@ -120,6 +120,12 @@ export interface AdminListQuery {
|
|
|
120
120
|
* row's id as the tie-break so pages stay stable when the sort column has duplicates.
|
|
121
121
|
*/
|
|
122
122
|
readonly after?: KeysetBound;
|
|
123
|
+
/**
|
|
124
|
+
* The mirror of `after`. Rows come back in `sort`'s own order either way — the admin renders
|
|
125
|
+
* what the repo returns and never re-sorts — so on a `before` query the `limit + 1`st row is the
|
|
126
|
+
* one FURTHEST back, at index 0, and `pageFrom` trims the head rather than the tail. A repo that
|
|
127
|
+
* answered nearest-first would hand the operator a reversed page.
|
|
128
|
+
*/
|
|
123
129
|
readonly before?: KeysetBound;
|
|
124
130
|
}
|
|
125
131
|
|
package/src/routes.ts
CHANGED
|
@@ -1,29 +1,58 @@
|
|
|
1
|
-
// The one bridge from the admin's route table to @ultimat3/render
|
|
2
|
-
// `spa`: it is behind auth, so there is
|
|
3
|
-
// and `network-only` keeps a stale org's rows
|
|
1
|
+
// The one bridge from the admin's route table to @ultimat3/render, and the one place a policy
|
|
2
|
+
// is composed onto an admin page. A generated view is `spa`: it is behind auth, so there is
|
|
3
|
+
// nothing to prerender and nothing a CDN may hold — and `network-only` keeps a stale org's rows
|
|
4
|
+
// out of a service worker cache. A custom page is `ssr`, because its guard runs on the server.
|
|
4
5
|
|
|
5
6
|
import { t } from '@ultimat3/i18n';
|
|
6
|
-
import { defineRoute } from '@ultimat3/render';
|
|
7
|
+
import { defineRoute, type RouteGuard } from '@ultimat3/render';
|
|
7
8
|
import type { AdminApp, AdminRoute } from './admin';
|
|
9
|
+
import { AdminPagePathInvalidError, AdminPageUnguardedError } from './errors';
|
|
10
|
+
import { guardedPage } from './page-guard';
|
|
11
|
+
import type { AdminPageComponent } from './pages';
|
|
8
12
|
|
|
9
13
|
export interface AdminRouteConfig {
|
|
10
14
|
readonly path: string;
|
|
11
15
|
readonly view: AdminRoute['view'];
|
|
12
16
|
readonly entity: string | null;
|
|
13
17
|
readonly permissions: readonly string[];
|
|
18
|
+
/** The GUARDED component of a custom page; `null` for a generated view. */
|
|
19
|
+
readonly component: AdminPageComponent | null;
|
|
20
|
+
/**
|
|
21
|
+
* The coarse gate, already composed — the SAME object `config.policy` carries. It is here, and
|
|
22
|
+
* not read back off `config`, because `RouteConfig.policy` is optional: a host that serves an
|
|
23
|
+
* admin URL from its own file has to be able to take the gate without proving it exists.
|
|
24
|
+
*/
|
|
25
|
+
readonly policy: RouteGuard;
|
|
14
26
|
readonly config: ReturnType<typeof defineRoute>;
|
|
15
27
|
}
|
|
16
28
|
|
|
29
|
+
/**
|
|
30
|
+
* The author never writes this `defineRoute` call, so the author cannot omit its `policy` —
|
|
31
|
+
* which is the whole mechanism. The coarse gate is `permissions[0]` (always `admin:read`, put
|
|
32
|
+
* there by `permissionsForOperation`/`pagePermissions`); the rest of the pair is decided per
|
|
33
|
+
* request by `decideAll`, in `crud.ts` for a generated view and in `page-guard.tsx` for a page.
|
|
34
|
+
* An empty list is refused here too: `render: 'spa'` and `'ssr'` would both ship a public shell.
|
|
35
|
+
*/
|
|
17
36
|
export function adminRouteConfig(route: AdminRoute): AdminRouteConfig {
|
|
37
|
+
const permission = route.permissions[0];
|
|
38
|
+
if (permission === undefined) throw new AdminPageUnguardedError({ path: route.path });
|
|
39
|
+
const custom = route.component !== undefined;
|
|
40
|
+
const policy: RouteGuard = { permission };
|
|
41
|
+
|
|
18
42
|
return {
|
|
19
43
|
path: route.path,
|
|
20
44
|
view: route.view,
|
|
21
45
|
entity: route.entity,
|
|
22
46
|
permissions: route.permissions,
|
|
47
|
+
component: route.component === undefined ? null : guardedPage(route, route.component),
|
|
48
|
+
policy,
|
|
23
49
|
config: defineRoute({
|
|
24
|
-
|
|
50
|
+
// A custom page renders server data behind the guard; a generated view is a shell that
|
|
51
|
+
// fetches through the admin's own gated calls. One mode each, never an author's choice.
|
|
52
|
+
render: custom ? 'ssr' : 'spa',
|
|
25
53
|
offline: 'network-only',
|
|
26
|
-
hydrate: 'idle',
|
|
54
|
+
hydrate: custom ? 'never' : 'idle',
|
|
55
|
+
policy,
|
|
27
56
|
meta: () => ({ title: t(route.titleKey) }),
|
|
28
57
|
}),
|
|
29
58
|
};
|
|
@@ -33,3 +62,32 @@ export function adminRouteConfig(route: AdminRoute): AdminRouteConfig {
|
|
|
33
62
|
export function adminRoutes(app: AdminApp): readonly AdminRouteConfig[] {
|
|
34
63
|
return app.routes.map(adminRouteConfig);
|
|
35
64
|
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* The one route the admin declares for `path` — the lookup a host performs when it serves an admin
|
|
68
|
+
* URL from its own file rather than from `adminRoutes()`.
|
|
69
|
+
*
|
|
70
|
+
* It exists because the alternative is what the deployed demo shipped: a page file typing
|
|
71
|
+
* `policy: { permission: 'admin:read' }` beside a route table that separately declared
|
|
72
|
+
* `permissionsForOperation(...)` for the same URL. Two declarations of one URL's authz agree until
|
|
73
|
+
* one of them is edited, and nothing was ever going to notice. Reading the gate from here means
|
|
74
|
+
* there is one declaration and one reader.
|
|
75
|
+
*
|
|
76
|
+
* A path the table does not declare is refused rather than answered with a default: a mount with
|
|
77
|
+
* no route is a screen whose permissions nothing composed, which is exactly the shape `pages:`
|
|
78
|
+
* exists to make impossible.
|
|
79
|
+
*/
|
|
80
|
+
export function adminRouteFor(app: AdminApp, path: string): AdminRouteConfig {
|
|
81
|
+
const route = app.routes.find((candidate) => candidate.path === path);
|
|
82
|
+
if (route === undefined) {
|
|
83
|
+
throw new AdminPagePathInvalidError({
|
|
84
|
+
path,
|
|
85
|
+
cause: 'is not a route this admin declares',
|
|
86
|
+
fix:
|
|
87
|
+
'serve one of the paths defineAdmin() built — ' +
|
|
88
|
+
`${app.routes.map((candidate) => candidate.path).join(', ')} — ` +
|
|
89
|
+
'or declare this one in `pages:` on defineAdmin()',
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
return adminRouteConfig(route);
|
|
93
|
+
}
|
package/src/search.ts
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
// search config to drift from the entities, and no result an actor could not have opened —
|
|
3
3
|
// the same `admin:read` + `<entity>:read` pair gates the hit and the detail page it links to.
|
|
4
4
|
|
|
5
|
+
import { expectedQueryLoop } from '@ultimat3/db';
|
|
5
6
|
import type { CrudCtx } from './crud';
|
|
6
7
|
import { canOperate } from './crud';
|
|
7
8
|
import type { AdminFilter, AdminRow } from './registry';
|
|
@@ -41,6 +42,11 @@ export interface AdminSearchInput {
|
|
|
41
42
|
* One query per searchable field rather than one query with an OR: the admin's query IR is
|
|
42
43
|
* a conjunction by design (each filter maps to an indexed predicate), and three small
|
|
43
44
|
* indexed lookups beat one unindexed disjunction.
|
|
45
|
+
*
|
|
46
|
+
* That argument is declared to the runtime and not only to the reader — `expectedQueryLoop`
|
|
47
|
+
* carries it onto every statement the loop issues, so a statement diagnostic reports the loops
|
|
48
|
+
* nobody argued for and never this one. At most `MAX_FIELDS_PER_RESOURCE` queries, and the scope
|
|
49
|
+
* ends with the loop: anything the repo does afterwards is judged normally.
|
|
44
50
|
*/
|
|
45
51
|
async function searchResource(
|
|
46
52
|
resource: AdminResource,
|
|
@@ -52,24 +58,29 @@ async function searchResource(
|
|
|
52
58
|
const seen = new Set<string>();
|
|
53
59
|
const hits: AdminSearchHit[] = [];
|
|
54
60
|
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
61
|
+
return expectedQueryLoop(
|
|
62
|
+
'admin search runs one indexed lookup per text field, which beats one unindexed OR',
|
|
63
|
+
async () => {
|
|
64
|
+
for (const field of fields) {
|
|
65
|
+
const where: readonly AdminFilter[] = [{ field: field.name, op: 'contains', value: term }];
|
|
66
|
+
const rows = await repo.list({ where, sort: resource.defaultSort, limit });
|
|
67
|
+
for (const row of rows) {
|
|
68
|
+
const id = rowId(row, resource.idField);
|
|
69
|
+
if (id === '' || seen.has(id)) continue;
|
|
70
|
+
seen.add(id);
|
|
71
|
+
hits.push({
|
|
72
|
+
entity: resource.name,
|
|
73
|
+
id,
|
|
74
|
+
label: labelOf(row, resource),
|
|
75
|
+
matchedField: field.name,
|
|
76
|
+
href: `${resource.path}/${id}`,
|
|
77
|
+
});
|
|
78
|
+
if (hits.length >= limit) return hits;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
return hits;
|
|
82
|
+
},
|
|
83
|
+
);
|
|
73
84
|
}
|
|
74
85
|
|
|
75
86
|
function labelOf(row: AdminRow, resource: AdminResource): string {
|
package/src/widget-value.ts
CHANGED
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
// this returns and make no decisions of their own.
|
|
5
5
|
|
|
6
6
|
import type { Money } from '@ultimat3/money';
|
|
7
|
+
import { isCurrencyCode } from '@ultimat3/schema';
|
|
7
8
|
import { AdminFieldUnsupportedError } from './errors';
|
|
8
9
|
import type { AdminField } from './fields';
|
|
9
10
|
|
|
@@ -12,6 +13,18 @@ export interface WidgetContext {
|
|
|
12
13
|
readonly timeZone: string;
|
|
13
14
|
/** BCP-47, for the number/date/money formatters the widgets call. */
|
|
14
15
|
readonly locale: string;
|
|
16
|
+
/**
|
|
17
|
+
* Where a foreign-key value links to, or `null` for "do not link it". Absent renders the id as
|
|
18
|
+
* plain text.
|
|
19
|
+
*
|
|
20
|
+
* WHY a seam and not a derivation: the widget used to build `/${entity}s/${value}`, which is
|
|
21
|
+
* English pluralisation by string concatenation and drops the admin's own `basePath` — a link
|
|
22
|
+
* that is wrong on every admin not mounted at `/` and on every entity whose plural is not a
|
|
23
|
+
* trailing `s`. The route table is `AdminApp`'s (`basePath` + `AdminResource.path`, which is
|
|
24
|
+
* already pluralised once, in `resource.ts`), and a widget three layers down cannot see it. The
|
|
25
|
+
* caller that builds this context can. A wrong link is worse than no link.
|
|
26
|
+
*/
|
|
27
|
+
readonly hrefFor?: (entity: string, id: string) => string | null;
|
|
15
28
|
}
|
|
16
29
|
|
|
17
30
|
export interface SelectOption {
|
|
@@ -59,8 +72,6 @@ const fail = (field: AdminField, cause: string, fix: string): never => {
|
|
|
59
72
|
throw new AdminFieldUnsupportedError({ entity: field.entity, field: field.name, cause, fix });
|
|
60
73
|
};
|
|
61
74
|
|
|
62
|
-
const CURRENCY = /^[A-Z]{3}$/;
|
|
63
|
-
|
|
64
75
|
/**
|
|
65
76
|
* `money()` puts a `bigint` on the row — Postgres `bigint` minor units — where `Money` is a
|
|
66
77
|
* number. This is the one place that widening happens, and it refuses rather than round: a
|
|
@@ -101,7 +112,12 @@ export function assertMoney(field: AdminField, value: unknown): Money | null {
|
|
|
101
112
|
);
|
|
102
113
|
}
|
|
103
114
|
const currency = typeof bag.currency === 'string' ? bag.currency : field.currency;
|
|
104
|
-
|
|
115
|
+
// `isCurrencyCode` is `@ultimat3/schema`'s (tier 0), never a local regex: this widget refuses
|
|
116
|
+
// exactly what `t.money`, the published OpenAPI `pattern` and the Postgres CHECK refuse, so a
|
|
117
|
+
// row the app wrote can never be one the admin declines to render. It takes `unknown`, which is
|
|
118
|
+
// what makes the `undefined` case — no row currency and no declared `field.currency` — the same
|
|
119
|
+
// branch as a malformed one.
|
|
120
|
+
if (!isCurrencyCode(currency)) {
|
|
105
121
|
return fail(
|
|
106
122
|
field,
|
|
107
123
|
`money has no ISO-4217 currency (got ${String(bag.currency)})`,
|
package/src/widgets.tsx
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
// switch, so money is a Money widget and a timestamp is a DateTime widget with a zone in
|
|
3
3
|
// every one of those places — there is no second place to get it wrong.
|
|
4
4
|
|
|
5
|
+
import { safeUrl } from '@ultimat3/core';
|
|
5
6
|
import { t } from '@ultimat3/i18n';
|
|
6
7
|
import {
|
|
7
8
|
Checkbox,
|
|
@@ -65,7 +66,7 @@ const inputValueFor = (iso: string, precision: 'date' | 'instant'): string =>
|
|
|
65
66
|
iso.slice(0, precision === 'date' ? 10 : 16);
|
|
66
67
|
|
|
67
68
|
/** Read-mode rendering of already-guarded props. Never formats; the widgets do that. */
|
|
68
|
-
function readView(props: WidgetProps, field: AdminField): JSX.Element {
|
|
69
|
+
function readView(props: WidgetProps, field: AdminField, ctx: WidgetContext): JSX.Element {
|
|
69
70
|
switch (props.widget) {
|
|
70
71
|
case 'money':
|
|
71
72
|
return props.value === null ? (
|
|
@@ -95,17 +96,18 @@ function readView(props: WidgetProps, field: AdminField): JSX.Element {
|
|
|
95
96
|
);
|
|
96
97
|
case 'json-editor':
|
|
97
98
|
return <pre class="x-admin-json">{props.value}</pre>;
|
|
98
|
-
case 'reference':
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
99
|
+
case 'reference': {
|
|
100
|
+
if (props.value === null) return <span>{t('admin.value.empty')}</span>;
|
|
101
|
+
// `ctx.hrefFor` or nothing. The route table lives on `AdminApp`; this file only ever knew
|
|
102
|
+
// the target entity's NAME, and turning that into a URL by appending an `s` is a guess.
|
|
103
|
+
const href = ctx.hrefFor?.(props.entity, props.value) ?? null;
|
|
104
|
+
return href === null ? <span>{props.value}</span> : <a href={href}>{props.value}</a>;
|
|
105
|
+
}
|
|
104
106
|
case 'upload':
|
|
105
107
|
return props.value === null ? (
|
|
106
108
|
<span>{t('admin.value.empty')}</span>
|
|
107
109
|
) : (
|
|
108
|
-
<a href={props.value.url}>{props.value.name}</a>
|
|
110
|
+
<a href={safeUrl(props.value.url, 'href') ?? undefined}>{props.value.name}</a>
|
|
109
111
|
);
|
|
110
112
|
default:
|
|
111
113
|
return <span>{String(props.value ?? '')}</span>;
|
|
@@ -258,5 +260,5 @@ function locales(): readonly string[] {
|
|
|
258
260
|
|
|
259
261
|
export function Widget(input: WidgetInput): JSX.Element {
|
|
260
262
|
const props = widgetProps(input.field, input.value, input.ctx);
|
|
261
|
-
return input.mode === 'read' ? readView(props, input.field) : editView(props, input);
|
|
263
|
+
return input.mode === 'read' ? readView(props, input.field, input.ctx) : editView(props, input);
|
|
262
264
|
}
|