@ultimat3/admin 22.15.0 → 24.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CLAUDE.md +83 -91
- package/README.md +282 -89
- package/package.json +19 -17
- package/src/action-form.tsx +139 -0
- package/src/action-gate.ts +141 -68
- package/src/action-input.ts +119 -0
- package/src/action-label.ts +9 -0
- package/src/action-row.ts +37 -0
- package/src/actions.tsx +89 -66
- package/src/actor.ts +53 -0
- package/src/admin.module.scss +298 -0
- package/src/admin.ts +187 -23
- package/src/audit-pg.ts +207 -0
- package/src/audit-schema.ts +47 -0
- package/src/audit.ts +109 -22
- package/src/authz.ts +11 -0
- package/src/batch-bar.tsx +66 -0
- package/src/batch-job.ts +152 -0
- package/src/batch-matching.ts +87 -0
- package/src/batch-queue.ts +90 -0
- package/src/batch.ts +396 -0
- package/src/catalog-keys.ts +55 -0
- package/src/crud-input.ts +104 -0
- package/src/crud-outcome.ts +224 -0
- package/src/crud.ts +172 -176
- package/src/describe.ts +131 -0
- package/src/detail.tsx +115 -43
- package/src/dev/data.ts +27 -12
- package/src/dev/facts.ts +28 -1
- package/src/dev/index.ts +1 -0
- package/src/dev/panel-cache.ts +8 -3
- package/src/dev/panel-db.ts +37 -5
- package/src/dev/panel-jobs.ts +8 -0
- package/src/dev/panel-routes.ts +10 -0
- package/src/dev/panel-timeline.ts +7 -2
- package/src/dev/panel.ts +10 -2
- package/src/dev/server.ts +16 -11
- package/src/dev/sql-runner.ts +31 -0
- package/src/entity-columns.ts +24 -5
- package/src/errors.ts +92 -5
- package/src/fields.ts +63 -4
- package/src/form-decode.ts +132 -0
- package/src/form.tsx +96 -49
- package/src/index.ts +199 -9
- package/src/jobs/fleet-repo.ts +155 -0
- package/src/jobs/job-actions.ts +172 -0
- package/src/jobs/job-entities.ts +118 -0
- package/src/jobs/job-labels.ts +108 -0
- package/src/jobs/job-repo.ts +137 -0
- package/src/jobs/job-resources.ts +211 -0
- package/src/jobs/job-where.ts +170 -0
- package/src/jobs/operator.ts +34 -0
- package/src/jobs/overview-data.ts +129 -0
- package/src/jobs/overview-tab.tsx +61 -0
- package/src/jobs/overview.tsx +189 -0
- package/src/json-text.ts +52 -0
- package/src/keyset-seek.ts +189 -0
- package/src/layout.tsx +55 -43
- package/src/list-columns.tsx +53 -0
- package/src/list-filter-bar.tsx +207 -0
- package/src/list-filters.ts +242 -0
- package/src/list-request.ts +129 -0
- package/src/list-scope-tabs.tsx +60 -0
- package/src/list-scope.ts +113 -0
- package/src/list.tsx +111 -72
- package/src/mcp-tools.ts +57 -13
- package/src/mcp.ts +112 -38
- package/src/mounts.ts +44 -0
- package/src/page-guard.tsx +26 -29
- package/src/pagination.ts +10 -4
- package/src/policy-bridge.ts +115 -18
- package/src/registry.ts +140 -9
- package/src/related.ts +105 -0
- package/src/relations.ts +240 -0
- package/src/renderers.tsx +89 -0
- package/src/repo-entity.ts +195 -0
- package/src/resource-fields.ts +116 -0
- package/src/resource-layout.ts +89 -0
- package/src/resource-list.ts +194 -0
- package/src/resource.ts +129 -79
- package/src/route-config.ts +71 -0
- package/src/routes.ts +55 -54
- package/src/row-scope-write.ts +130 -0
- package/src/row-version.ts +20 -0
- package/src/screen-action.tsx +333 -0
- package/src/screen-frame.tsx +167 -0
- package/src/screen-home.tsx +201 -0
- package/src/screen-lookup.tsx +157 -0
- package/src/screen-resource.tsx +441 -0
- package/src/screen-system.tsx +75 -0
- package/src/screens.ts +56 -0
- package/src/search.ts +22 -10
- package/src/stylesheet-scope.ts +8 -0
- package/src/test-ctx.ts +31 -0
- package/src/validate.ts +3 -0
- package/src/widget-value.ts +36 -8
- package/src/widgets.tsx +84 -16
package/src/list.tsx
CHANGED
|
@@ -1,33 +1,46 @@
|
|
|
1
|
-
// The list view:
|
|
2
|
-
//
|
|
3
|
-
//
|
|
1
|
+
// The list view: scope tabs, the filter bar, the batch bar, derived and computed columns, keyset
|
|
2
|
+
// pagination, and the states a server-rendered table has (rows, empty, error). Every control is a
|
|
3
|
+
// link or a native form — a row opens through a real `<a href>`, a header sorts through one,
|
|
4
|
+
// paging is `<Pagination hrefFor>`, a row is selected by a checkbox the batch form owns — so the
|
|
5
|
+
// page ships no script and there is nothing to hydrate.
|
|
6
|
+
// Prev/Next are the only navigation: there is no page number, because there is no offset.
|
|
4
7
|
|
|
5
8
|
import { t } from '@ultimat3/i18n';
|
|
6
9
|
import { Card, DataTable, ErrorState } from '@ultimat3/ui';
|
|
7
10
|
import type { JSX } from 'solid-js';
|
|
11
|
+
import { decideAction } from './action-gate';
|
|
8
12
|
import { AdminActions } from './actions';
|
|
9
13
|
import type { AdminActor, AdminAuthz } from './authz';
|
|
14
|
+
import { AdminBatchBar, BatchCheckbox } from './batch-bar';
|
|
10
15
|
import { type AdminErrorParts, adminErrorFrom } from './errors';
|
|
16
|
+
import { resourceColumns } from './list-columns';
|
|
17
|
+
import { AdminFilterBar } from './list-filter-bar';
|
|
18
|
+
import type { ListLocation } from './list-request';
|
|
19
|
+
import type { AdminListRequest } from './list-scope';
|
|
20
|
+
import { AdminScopeTabs } from './list-scope-tabs';
|
|
11
21
|
import type { AdminPage } from './pagination';
|
|
12
|
-
import type { AdminRow
|
|
22
|
+
import type { AdminRow } from './registry';
|
|
13
23
|
import { rowId } from './registry';
|
|
14
24
|
import type { AdminResource } from './resource';
|
|
25
|
+
import type { AdminScope } from './resource-list';
|
|
15
26
|
import type { WidgetContext } from './widget-value';
|
|
16
|
-
import { Widget } from './widgets';
|
|
17
27
|
|
|
18
28
|
export interface AdminListProps<Row extends AdminRow> {
|
|
19
29
|
readonly resource: AdminResource<Row>;
|
|
20
|
-
readonly page: AdminPage<Row
|
|
21
|
-
readonly loading: boolean;
|
|
30
|
+
readonly page: AdminPage<Row>;
|
|
22
31
|
readonly error: AdminErrorParts | null;
|
|
23
32
|
readonly ctx: WidgetContext;
|
|
24
33
|
readonly actor: AdminActor;
|
|
25
34
|
readonly authz: AdminAuthz;
|
|
26
35
|
readonly basePath: string;
|
|
27
|
-
|
|
28
|
-
readonly
|
|
29
|
-
/**
|
|
30
|
-
readonly
|
|
36
|
+
/** The list as it was asked for — its scope, filters and sort ride every link on the page. */
|
|
37
|
+
readonly request: AdminListRequest;
|
|
38
|
+
/** The scope the page was READ under: the request's, or the resource's default. */
|
|
39
|
+
readonly scope: AdminScope | null;
|
|
40
|
+
/** The count of each scope that declared `count: true`, and of no other. */
|
|
41
|
+
readonly counts: ReadonlyMap<string, number>;
|
|
42
|
+
/** The URL of one list state — `listHref()`, bound to this admin and this resource. */
|
|
43
|
+
readonly hrefFor: (location: ListLocation) => string;
|
|
31
44
|
}
|
|
32
45
|
|
|
33
46
|
export function AdminList<Row extends AdminRow>(props: AdminListProps<Row>): JSX.Element {
|
|
@@ -38,83 +51,109 @@ export function AdminList<Row extends AdminRow>(props: AdminListProps<Row>): JSX
|
|
|
38
51
|
return <ErrorState error={adminErrorFrom(props.error)} />;
|
|
39
52
|
}
|
|
40
53
|
|
|
41
|
-
if (props.loading || props.page === null) {
|
|
42
|
-
return (
|
|
43
|
-
<Card header={<h2>{t(props.resource.titleKey)}</h2>}>
|
|
44
|
-
<p aria-busy="true">{t('admin.list.loading')}</p>
|
|
45
|
-
</Card>
|
|
46
|
-
);
|
|
47
|
-
}
|
|
48
|
-
|
|
49
54
|
const page = props.page;
|
|
55
|
+
// The base `AdminResource`: this component's own `Row` is the app's, the pieces below read rows
|
|
56
|
+
// as `AdminRow`, and a resource is the same object either way.
|
|
57
|
+
const resource: AdminResource = props.resource as unknown as AdminResource;
|
|
58
|
+
const acts = resource.actions.length > 0;
|
|
59
|
+
const sealed = resource.secretFields.map((field) => field.name);
|
|
60
|
+
// The batch actions THIS actor may run — the gate's own answer, asked once for the list. A row
|
|
61
|
+
// the action's `when` excludes is still selectable: the server counts it `refused`, by name.
|
|
62
|
+
const batch = resource.actions.filter(
|
|
63
|
+
(action) =>
|
|
64
|
+
action.batch !== undefined &&
|
|
65
|
+
decideAction(action, props.actor, props.authz, { entity: resource.name }).allowed,
|
|
66
|
+
);
|
|
67
|
+
const { scope, filters, sort } = props.request;
|
|
68
|
+
/** Everything but the cursor: a cursor is a position IN this state, never part of it. */
|
|
69
|
+
const state: ListLocation = {
|
|
70
|
+
...(scope === undefined ? {} : { scope }),
|
|
71
|
+
...(filters === undefined ? {} : { filters }),
|
|
72
|
+
...(sort === undefined ? {} : { sort }),
|
|
73
|
+
};
|
|
50
74
|
|
|
51
75
|
return (
|
|
52
|
-
<Card header={<h2>{t(
|
|
53
|
-
<
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
76
|
+
<Card header={<h2>{t(resource.titleKey)}</h2>}>
|
|
77
|
+
<AdminScopeTabs
|
|
78
|
+
resource={resource}
|
|
79
|
+
active={props.scope}
|
|
80
|
+
counts={props.counts}
|
|
81
|
+
locale={props.ctx.locale}
|
|
82
|
+
hrefFor={(name) => props.hrefFor({ ...state, scope: name })}
|
|
59
83
|
/>
|
|
60
|
-
|
|
84
|
+
<AdminFilterBar
|
|
85
|
+
resource={resource}
|
|
86
|
+
request={props.request}
|
|
87
|
+
ctx={props.ctx}
|
|
88
|
+
action={props.hrefFor({})}
|
|
89
|
+
clearHref={props.hrefFor({
|
|
90
|
+
...(scope === undefined ? {} : { scope }),
|
|
91
|
+
...(sort === undefined ? {} : { sort }),
|
|
92
|
+
})}
|
|
93
|
+
returnTo={props.hrefFor(state)}
|
|
94
|
+
/>
|
|
95
|
+
<AdminBatchBar actions={batch} href={props.hrefFor(state)} />
|
|
61
96
|
{page.rows.length === 0 ? (
|
|
62
97
|
<p class="x-admin-empty">{t('admin.list.empty')}</p>
|
|
63
98
|
) : (
|
|
64
99
|
<DataTable
|
|
65
|
-
caption={t(
|
|
100
|
+
caption={t(resource.titleKey)}
|
|
66
101
|
columns={[
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
102
|
+
...(batch.length === 0
|
|
103
|
+
? []
|
|
104
|
+
: [
|
|
105
|
+
{
|
|
106
|
+
key: 'select',
|
|
107
|
+
header: t('admin.batch.select'),
|
|
108
|
+
cell: (row: Row) => (
|
|
109
|
+
<BatchCheckbox
|
|
110
|
+
id={idOf(row)}
|
|
111
|
+
label={t('admin.batch.select-row', { id: idOf(row) })}
|
|
112
|
+
/>
|
|
113
|
+
),
|
|
114
|
+
},
|
|
115
|
+
]),
|
|
116
|
+
// The resource's columns, the same ones a related card on another detail page draws.
|
|
117
|
+
...resourceColumns({ resource, ctx: props.ctx, hrefOf: href }),
|
|
118
|
+
// Decided per ROW, with the row on the subject: a rule that reads it (ownership, a
|
|
119
|
+
// state) answers for this row and not for the table.
|
|
120
|
+
...(acts
|
|
121
|
+
? [
|
|
122
|
+
{
|
|
123
|
+
key: 'actions',
|
|
124
|
+
header: t('admin.actions.label'),
|
|
125
|
+
cell: (row: Row) => (
|
|
126
|
+
<AdminActions
|
|
127
|
+
actions={resource.actions}
|
|
128
|
+
actor={props.actor}
|
|
129
|
+
authz={props.authz}
|
|
130
|
+
subject={{ entity: resource.name, id: idOf(row), row }}
|
|
131
|
+
sealed={sealed}
|
|
132
|
+
href={href(idOf(row))}
|
|
133
|
+
/>
|
|
134
|
+
),
|
|
135
|
+
},
|
|
136
|
+
]
|
|
137
|
+
: []),
|
|
92
138
|
]}
|
|
93
139
|
rows={page.rows}
|
|
94
140
|
rowKey={idOf}
|
|
95
141
|
sort={{ key: page.sort.field, direction: page.sort.direction }}
|
|
96
|
-
|
|
97
|
-
|
|
142
|
+
nextCursor={page.hasMore ? (page.nextCursor ?? undefined) : undefined}
|
|
143
|
+
prevCursor={page.prevCursor ?? undefined}
|
|
144
|
+
hrefFor={(cursor) => props.hrefFor({ ...state, cursor })}
|
|
145
|
+
// The header's NEXT state, as a URL. "Unsorted" is the list with no sort in it — the
|
|
146
|
+
// resource's own default order.
|
|
147
|
+
sortHrefFor={(next) => {
|
|
148
|
+
const { sort: _current, ...rest } = state;
|
|
149
|
+
return props.hrefFor(
|
|
150
|
+
next === undefined
|
|
151
|
+
? rest
|
|
152
|
+
: { ...rest, sort: { field: next.key, direction: next.direction } },
|
|
153
|
+
);
|
|
98
154
|
}}
|
|
99
155
|
/>
|
|
100
156
|
)}
|
|
101
|
-
|
|
102
|
-
<nav class="x-admin-pager" aria-label={t('admin.list.pagination')}>
|
|
103
|
-
<button
|
|
104
|
-
type="button"
|
|
105
|
-
disabled={page.prevCursor === null}
|
|
106
|
-
onClick={() => props.onCursor(page.prevCursor)}
|
|
107
|
-
>
|
|
108
|
-
{t('admin.list.previous')}
|
|
109
|
-
</button>
|
|
110
|
-
<button
|
|
111
|
-
type="button"
|
|
112
|
-
disabled={!page.hasMore}
|
|
113
|
-
onClick={() => props.onCursor(page.nextCursor)}
|
|
114
|
-
>
|
|
115
|
-
{t('admin.list.next')}
|
|
116
|
-
</button>
|
|
117
|
-
</nav>
|
|
118
157
|
</Card>
|
|
119
158
|
);
|
|
120
159
|
}
|
package/src/mcp-tools.ts
CHANGED
|
@@ -11,6 +11,7 @@ import { type AdminDecision, decideAll } from './authz';
|
|
|
11
11
|
import { type CrudCtx, decideOperation, permissionsForOperation } from './crud';
|
|
12
12
|
import type { AdminFieldType } from './fields';
|
|
13
13
|
import type { AdminOperation } from './permissions';
|
|
14
|
+
import type { AdminAction } from './registry';
|
|
14
15
|
import type { AdminResource } from './resource';
|
|
15
16
|
|
|
16
17
|
export type AdminToolKind = 'list' | 'read' | 'search' | 'create' | 'update' | 'delete' | 'action';
|
|
@@ -19,6 +20,8 @@ export interface AdminToolField {
|
|
|
19
20
|
readonly name: string;
|
|
20
21
|
readonly type: AdminFieldType;
|
|
21
22
|
readonly required: boolean;
|
|
23
|
+
/** The argument is a LIST of `type`: the list tool's `where` is a list of predicates. */
|
|
24
|
+
readonly list?: true;
|
|
22
25
|
}
|
|
23
26
|
|
|
24
27
|
export interface AdminMcpTool {
|
|
@@ -49,11 +52,30 @@ const ID_FIELD: AdminToolField = { name: 'id', type: 'text', required: true };
|
|
|
49
52
|
* description of the same contract. `mcp.ts` leaves an action tool's schema OPEN instead, so the
|
|
50
53
|
* action's own validation is the one that decides.
|
|
51
54
|
*/
|
|
52
|
-
const actionEnvelope = (
|
|
55
|
+
const actionEnvelope = (action: AdminAction): readonly AdminToolField[] => [
|
|
53
56
|
{ name: 'id', type: 'text', required: false },
|
|
54
|
-
|
|
57
|
+
// A batch action is the SAME tool, given a list of rows instead of one: run once per row through
|
|
58
|
+
// the button's gate, answered with the counts the batch bar shows.
|
|
59
|
+
...(action.batch === undefined
|
|
60
|
+
? []
|
|
61
|
+
: [{ name: 'ids', type: 'text', required: false, list: true } as const]),
|
|
62
|
+
...(action.destructive === true
|
|
63
|
+
? [{ name: 'confirmation', type: 'text', required: true } as const]
|
|
64
|
+
: []),
|
|
55
65
|
];
|
|
56
66
|
|
|
67
|
+
/** What an agent reads about when and over what an action runs — the schema cannot say it. */
|
|
68
|
+
const actionDescription = (action: AdminAction): string =>
|
|
69
|
+
[
|
|
70
|
+
action.mcp?.description ?? `Run the ${action.name} action.`,
|
|
71
|
+
action.when === undefined
|
|
72
|
+
? ''
|
|
73
|
+
: ' Applies only to some rows: a row it does not apply to is refused with X_ADMIN_ACTION_NOT_APPLICABLE.',
|
|
74
|
+
action.batch === undefined
|
|
75
|
+
? ''
|
|
76
|
+
: ` Pass ids: [...] to run it once per row; the answer counts done, refused and failed${action.destructive === true ? ', and confirmation is "<entity>:<n> rows"' : ''}.`,
|
|
77
|
+
].join('');
|
|
78
|
+
|
|
57
79
|
const formFields = (resource: AdminResource): readonly AdminToolField[] =>
|
|
58
80
|
resource.formFields.map((field) => ({
|
|
59
81
|
name: field.name,
|
|
@@ -61,18 +83,40 @@ const formFields = (resource: AdminResource): readonly AdminToolField[] =>
|
|
|
61
83
|
required: field.required,
|
|
62
84
|
}));
|
|
63
85
|
|
|
86
|
+
/**
|
|
87
|
+
* The list tool says what it filters by, because a schema cannot: `where` is a list of
|
|
88
|
+
* `{ field, op?, value }` and the fields and scopes are this resource's own. The same names the
|
|
89
|
+
* list screen's URL takes — one grammar, two transports.
|
|
90
|
+
*/
|
|
91
|
+
const listDescription = (resource: AdminResource): string => {
|
|
92
|
+
const filters = resource.filters.map((field) => field.name);
|
|
93
|
+
const scopes = resource.scopes.map((scope) => scope.name);
|
|
94
|
+
return [
|
|
95
|
+
`List ${resource.name} rows, cursor-paginated.`,
|
|
96
|
+
filters.length === 0
|
|
97
|
+
? ''
|
|
98
|
+
: ` where: [{ field, op?, value }] over ${filters.join(', ')}; op defaults per field.`,
|
|
99
|
+
scopes.length === 0 ? '' : ` scope: one of ${scopes.join(', ')}.`,
|
|
100
|
+
].join('');
|
|
101
|
+
};
|
|
102
|
+
|
|
64
103
|
function toolFor(resource: AdminResource, op: AdminOperation): AdminMcpTool | null {
|
|
65
104
|
switch (op) {
|
|
66
105
|
case 'list':
|
|
67
106
|
return {
|
|
68
107
|
name: `admin.${resource.name}.list`,
|
|
69
108
|
kind: 'list',
|
|
70
|
-
description:
|
|
109
|
+
description: listDescription(resource),
|
|
71
110
|
entity: resource.name,
|
|
72
111
|
action: null,
|
|
73
|
-
permissions: permissionsForOperation(resource.
|
|
112
|
+
permissions: permissionsForOperation(resource.permission, 'list'),
|
|
74
113
|
destructive: false,
|
|
75
|
-
input: [
|
|
114
|
+
input: [
|
|
115
|
+
CURSOR_FIELD,
|
|
116
|
+
{ name: 'limit', type: 'number', required: false },
|
|
117
|
+
{ name: 'scope', type: 'text', required: false },
|
|
118
|
+
{ name: 'where', type: 'json', required: false, list: true },
|
|
119
|
+
],
|
|
76
120
|
};
|
|
77
121
|
case 'detail':
|
|
78
122
|
return {
|
|
@@ -81,7 +125,7 @@ function toolFor(resource: AdminResource, op: AdminOperation): AdminMcpTool | nu
|
|
|
81
125
|
description: `Read one ${resource.name} row by id.`,
|
|
82
126
|
entity: resource.name,
|
|
83
127
|
action: null,
|
|
84
|
-
permissions: permissionsForOperation(resource.
|
|
128
|
+
permissions: permissionsForOperation(resource.permission, 'detail'),
|
|
85
129
|
destructive: false,
|
|
86
130
|
input: [ID_FIELD],
|
|
87
131
|
};
|
|
@@ -92,7 +136,7 @@ function toolFor(resource: AdminResource, op: AdminOperation): AdminMcpTool | nu
|
|
|
92
136
|
description: `Create a ${resource.name} row. Validated by the entity's schema.`,
|
|
93
137
|
entity: resource.name,
|
|
94
138
|
action: null,
|
|
95
|
-
permissions: permissionsForOperation(resource.
|
|
139
|
+
permissions: permissionsForOperation(resource.permission, 'create'),
|
|
96
140
|
destructive: false,
|
|
97
141
|
input: formFields(resource),
|
|
98
142
|
};
|
|
@@ -103,7 +147,7 @@ function toolFor(resource: AdminResource, op: AdminOperation): AdminMcpTool | nu
|
|
|
103
147
|
description: `Update fields of one ${resource.name} row.`,
|
|
104
148
|
entity: resource.name,
|
|
105
149
|
action: null,
|
|
106
|
-
permissions: permissionsForOperation(resource.
|
|
150
|
+
permissions: permissionsForOperation(resource.permission, 'update'),
|
|
107
151
|
destructive: false,
|
|
108
152
|
input: [ID_FIELD, ...formFields(resource)],
|
|
109
153
|
};
|
|
@@ -114,7 +158,7 @@ function toolFor(resource: AdminResource, op: AdminOperation): AdminMcpTool | nu
|
|
|
114
158
|
description: `Delete one ${resource.name} row. Requires confirmation "<entity>:<id>".`,
|
|
115
159
|
entity: resource.name,
|
|
116
160
|
action: null,
|
|
117
|
-
permissions: permissionsForOperation(resource.
|
|
161
|
+
permissions: permissionsForOperation(resource.permission, 'delete'),
|
|
118
162
|
destructive: true,
|
|
119
163
|
input: [ID_FIELD, { name: 'confirmation', type: 'text', required: true }],
|
|
120
164
|
};
|
|
@@ -154,12 +198,12 @@ function toolGates(app: AdminApp): readonly ToolGate[] {
|
|
|
154
198
|
tool: {
|
|
155
199
|
name: `admin.action.${action.name}`,
|
|
156
200
|
kind: 'action',
|
|
157
|
-
description: action
|
|
201
|
+
description: actionDescription(action),
|
|
158
202
|
entity: action.entity ?? null,
|
|
159
203
|
action: action.name,
|
|
160
204
|
permissions: permissionsForAction(action),
|
|
161
205
|
destructive: action.destructive === true,
|
|
162
|
-
input: actionEnvelope(action
|
|
206
|
+
input: actionEnvelope(action),
|
|
163
207
|
},
|
|
164
208
|
gate: (ctx) => decideAction(action, ctx.actor, ctx.authz),
|
|
165
209
|
});
|
|
@@ -189,12 +233,12 @@ function toolGates(app: AdminApp): readonly ToolGate[] {
|
|
|
189
233
|
tool: {
|
|
190
234
|
name: `admin.action.${action.name}`,
|
|
191
235
|
kind: 'action',
|
|
192
|
-
description: action
|
|
236
|
+
description: actionDescription(action),
|
|
193
237
|
entity: null,
|
|
194
238
|
action: action.name,
|
|
195
239
|
permissions: permissionsForAction(action),
|
|
196
240
|
destructive: action.destructive === true,
|
|
197
|
-
input: actionEnvelope(action
|
|
241
|
+
input: actionEnvelope(action),
|
|
198
242
|
},
|
|
199
243
|
gate: (ctx) => decideAction(action, ctx.actor, ctx.authz),
|
|
200
244
|
});
|
package/src/mcp.ts
CHANGED
|
@@ -22,8 +22,11 @@ import {
|
|
|
22
22
|
type ToolArgs,
|
|
23
23
|
} from '@ultimat3/mcp';
|
|
24
24
|
import { invokeAdminAction } from './action-gate';
|
|
25
|
+
import { invokeRowAction } from './action-row';
|
|
25
26
|
import type { AdminApp } from './admin';
|
|
26
27
|
import type { AdminActor } from './authz';
|
|
28
|
+
import { batchPlan, runAdminBatch } from './batch';
|
|
29
|
+
import { batchEnqueue } from './batch-job';
|
|
27
30
|
import {
|
|
28
31
|
adminCreate,
|
|
29
32
|
adminDestroy,
|
|
@@ -33,11 +36,13 @@ import {
|
|
|
33
36
|
type CrudCtx,
|
|
34
37
|
type CrudResult,
|
|
35
38
|
} from './crud';
|
|
39
|
+
import { AdminFilterInvalidError } from './errors';
|
|
36
40
|
import type { AdminFieldType } from './fields';
|
|
41
|
+
import { type AskedFilter, checkedFilter } from './list-filters';
|
|
37
42
|
import { type AdminMcpTool, adminMcpTools, adminToolCatalog } from './mcp-tools';
|
|
38
43
|
import type { AdminAction, AdminRow } from './registry';
|
|
39
|
-
import { repoOf } from './resource';
|
|
40
44
|
import { adminSearch } from './search';
|
|
45
|
+
import type { ValidationIssue } from './validate';
|
|
41
46
|
|
|
42
47
|
export type McpInput = Readonly<Record<string, unknown>>;
|
|
43
48
|
|
|
@@ -63,6 +68,22 @@ const withoutKeys = (input: McpInput, keys: readonly string[]): Record<string, u
|
|
|
63
68
|
return out;
|
|
64
69
|
};
|
|
65
70
|
|
|
71
|
+
/** The `where` argument as a list of asked filters; anything else in it is refused by name. */
|
|
72
|
+
const askedFilters = (where: unknown): readonly AskedFilter[] => {
|
|
73
|
+
if (where === undefined || where === null) return [];
|
|
74
|
+
const list: readonly unknown[] = Array.isArray(where) ? where : [where];
|
|
75
|
+
return list.map((one) => {
|
|
76
|
+
const bag = (typeof one === 'object' && one !== null ? one : {}) as Record<string, unknown>;
|
|
77
|
+
const op = bag['op'];
|
|
78
|
+
return {
|
|
79
|
+
// A predicate with no `field` names the empty one, which no resource derives.
|
|
80
|
+
field: typeof bag['field'] === 'string' ? bag['field'] : '',
|
|
81
|
+
...(typeof op === 'string' ? { op } : {}),
|
|
82
|
+
value: bag['value'],
|
|
83
|
+
};
|
|
84
|
+
});
|
|
85
|
+
};
|
|
86
|
+
|
|
66
87
|
const actionByName = (app: AdminApp, name: string): AdminAction | undefined =>
|
|
67
88
|
[...app.resources.flatMap((resource) => resource.actions), ...app.globalActions].find(
|
|
68
89
|
(action) => action.name === name,
|
|
@@ -110,46 +131,77 @@ async function dispatch(
|
|
|
110
131
|
if (action === undefined) {
|
|
111
132
|
return { ok: false, error: 'X_ADMIN_TOOL_FORBIDDEN', reason: 'action is not registered' };
|
|
112
133
|
}
|
|
113
|
-
const id = str(input, 'id');
|
|
114
|
-
// The row a row-level rule decides about, loaded through the resource that OWNS the action —
|
|
115
|
-
// what the UI's action button does. Without it the subject carried an id and no row, so an
|
|
116
|
-
// ownership rule could never allow. `null` for a row that does not exist: no evidence of
|
|
117
|
-
// permission, same contract as an action's `row:` loader. A global action names no resource.
|
|
118
134
|
const owner = app.resources.find((resource) => resource.actions.includes(action));
|
|
119
|
-
const
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
135
|
+
const own = withoutKeys(input, ['id', 'ids', 'confirmation']);
|
|
136
|
+
const confirmation = str(input, 'confirmation');
|
|
137
|
+
// `ids`: the same tool as a batch — the bar's own path, row by row through the gate.
|
|
138
|
+
if (owner !== undefined && action.batch !== undefined && Array.isArray(input['ids'])) {
|
|
139
|
+
const ids = (input['ids'] as readonly unknown[]).filter(
|
|
140
|
+
(one): one is string => typeof one === 'string',
|
|
141
|
+
);
|
|
142
|
+
const result = await runAdminBatch({
|
|
143
|
+
resource: owner,
|
|
144
|
+
action,
|
|
145
|
+
ctx,
|
|
146
|
+
selection: { kind: 'ids', ids },
|
|
147
|
+
input: own,
|
|
148
|
+
confirmation,
|
|
149
|
+
...(batchPlan(action) === null ? {} : { enqueue: batchEnqueue(app.basePath) }),
|
|
150
|
+
});
|
|
151
|
+
if (result.ok) return { ok: true, data: result };
|
|
152
|
+
if (result.kind === 'invalid') return invalidIssues(result.issues);
|
|
153
|
+
return { ok: false, error: 'X_ADMIN_DENIED', reason: result.decision.reason };
|
|
154
|
+
}
|
|
155
|
+
const id = str(input, 'id');
|
|
156
|
+
// The row a row-level rule — and the action's own `when` — decides about, loaded through the
|
|
157
|
+
// resource that OWNS the action: what the UI's button does. A global action names no row.
|
|
158
|
+
const result =
|
|
159
|
+
owner !== undefined && id !== ''
|
|
160
|
+
? await invokeRowAction({ resource: owner, action, id, ctx, input: own, confirmation })
|
|
161
|
+
: await invokeAdminAction({
|
|
162
|
+
action,
|
|
163
|
+
input: { ...own, ...(id === '' ? {} : { id }) },
|
|
164
|
+
actor: ctx.actor,
|
|
165
|
+
authz: ctx.authz,
|
|
166
|
+
audit: ctx.audit,
|
|
167
|
+
requestId: ctx.requestId,
|
|
168
|
+
subject: {
|
|
169
|
+
...(action.entity === undefined ? {} : { entity: action.entity }),
|
|
170
|
+
...(id === '' ? {} : { id }),
|
|
171
|
+
},
|
|
172
|
+
// The agent must echo the token, exactly as the UI makes an operator type it.
|
|
173
|
+
confirmation,
|
|
174
|
+
});
|
|
175
|
+
if (result.ok) return { ok: true, data: result.value };
|
|
176
|
+
if (result.kind === 'not-applicable') {
|
|
177
|
+
return { ok: false, error: result.error.code, reason: result.error.cause };
|
|
178
|
+
}
|
|
179
|
+
if (result.kind === 'invalid') return invalidIssues(result.issues);
|
|
180
|
+
return { ok: false, error: 'X_ADMIN_DENIED', reason: result.decision.reason };
|
|
140
181
|
}
|
|
141
182
|
|
|
142
183
|
const resource = app.resource(tool.entity ?? '');
|
|
143
184
|
switch (tool.kind) {
|
|
144
185
|
case 'list': {
|
|
145
186
|
const limit = num(input, 'limit');
|
|
146
|
-
const
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
187
|
+
const scope = str(input, 'scope');
|
|
188
|
+
try {
|
|
189
|
+
// `where` through the same validator a URL's `f.<field>` passes, and the scope by name:
|
|
190
|
+
// an agent filters by exactly what an operator can, and the resource's row scope rides
|
|
191
|
+
// the read inside `adminList` whatever the agent sends.
|
|
192
|
+
const result = await adminList(resource, ctx, {
|
|
193
|
+
cursor: str(input, 'cursor'),
|
|
194
|
+
...(limit === undefined ? {} : { limit }),
|
|
195
|
+
...(scope === '' ? {} : { scope }),
|
|
196
|
+
filters: askedFilters(input['where']).map((asked) => checkedFilter(resource, asked)),
|
|
197
|
+
});
|
|
198
|
+
return result.ok
|
|
199
|
+
? { ok: true, data: result.page }
|
|
200
|
+
: { ok: false, error: 'X_ADMIN_DENIED', reason: result.decision.reason };
|
|
201
|
+
} catch (error) {
|
|
202
|
+
if (!(error instanceof AdminFilterInvalidError)) throw error;
|
|
203
|
+
return { ok: false, error: error.code, reason: error.cause };
|
|
204
|
+
}
|
|
153
205
|
}
|
|
154
206
|
case 'read':
|
|
155
207
|
return crudResult(await adminDetail(resource, ctx, str(input, 'id')));
|
|
@@ -166,11 +218,27 @@ async function dispatch(
|
|
|
166
218
|
}
|
|
167
219
|
}
|
|
168
220
|
|
|
221
|
+
/** The schema's issues, as the one string an agent's result carries. */
|
|
222
|
+
const invalidIssues = (issues: readonly ValidationIssue[]): AdminToolResult => ({
|
|
223
|
+
ok: false,
|
|
224
|
+
error: 'X_ADMIN_INVALID',
|
|
225
|
+
reason: JSON.stringify(issues),
|
|
226
|
+
});
|
|
227
|
+
|
|
169
228
|
function crudResult(result: CrudResult<AdminRow>): AdminToolResult {
|
|
170
229
|
if (result.ok) return { ok: true, data: result.row };
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
230
|
+
if (result.kind === 'denied') {
|
|
231
|
+
return { ok: false, error: 'X_ADMIN_DENIED', reason: result.decision.reason };
|
|
232
|
+
}
|
|
233
|
+
// No row by that id — or none this caller's row scope lets them see, which reads the same.
|
|
234
|
+
if (result.kind === 'missing') {
|
|
235
|
+
return { ok: false, error: 'X_ADMIN_INVALID', reason: 'no row has that id' };
|
|
236
|
+
}
|
|
237
|
+
// Reached only by a caller that sent a version; the update tool sends none.
|
|
238
|
+
if (result.kind === 'stale') {
|
|
239
|
+
return { ok: false, error: 'X_ADMIN_INVALID', reason: 'the row changed since it was read' };
|
|
240
|
+
}
|
|
241
|
+
return invalidIssues(result.issues);
|
|
174
242
|
}
|
|
175
243
|
|
|
176
244
|
export interface AdminMcpOptions {
|
|
@@ -195,6 +263,9 @@ const JSON_TYPE: Readonly<Record<AdminFieldType, NonNullable<JsonSchema['type']>
|
|
|
195
263
|
json: 'object',
|
|
196
264
|
relation: 'string',
|
|
197
265
|
file: 'string',
|
|
266
|
+
// A sealed column travels as the text an agent sends. It is in no tool schema today — the
|
|
267
|
+
// CRUD tools are built from `formFields`, which never holds one.
|
|
268
|
+
secret: 'string',
|
|
198
269
|
};
|
|
199
270
|
|
|
200
271
|
/**
|
|
@@ -210,7 +281,10 @@ const JSON_TYPE: Readonly<Record<AdminFieldType, NonNullable<JsonSchema['type']>
|
|
|
210
281
|
const inputSchema = (tool: AdminMcpTool): JsonSchema => ({
|
|
211
282
|
type: 'object',
|
|
212
283
|
properties: Object.fromEntries(
|
|
213
|
-
tool.input.map((field) =>
|
|
284
|
+
tool.input.map((field) => {
|
|
285
|
+
const one = { type: JSON_TYPE[field.type] };
|
|
286
|
+
return [field.name, field.list === true ? { type: 'array', items: one } : one];
|
|
287
|
+
}),
|
|
214
288
|
),
|
|
215
289
|
required: tool.input.filter((field) => field.required).map((field) => field.name),
|
|
216
290
|
additionalProperties: tool.kind === 'action',
|
package/src/mounts.ts
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
// Which admins this process declared, by base path — what a host reads to MOUNT them. Kept on the
|
|
2
|
+
// global symbol registry rather than in a module variable so the host can ask "did this app
|
|
3
|
+
// declare an admin?" WITHOUT importing this package: importing it loads every screen, and an app
|
|
4
|
+
// with no admin must not pay for one (axiom 6).
|
|
5
|
+
|
|
6
|
+
import type { AdminApp } from './admin';
|
|
7
|
+
|
|
8
|
+
/** The global key. `@ultimat3/cli`'s `runtime-admin.ts` restates the string; a test holds them equal. */
|
|
9
|
+
export const ADMIN_MOUNTS: symbol = Symbol.for('ultimate.admin.mounts');
|
|
10
|
+
|
|
11
|
+
type Mounts = Map<string, AdminApp>;
|
|
12
|
+
|
|
13
|
+
const mounts = (): Mounts => {
|
|
14
|
+
const scope = globalThis as { [key: symbol]: unknown };
|
|
15
|
+
const held = scope[ADMIN_MOUNTS];
|
|
16
|
+
if (held instanceof Map) return held as Mounts;
|
|
17
|
+
const created: Mounts = new Map();
|
|
18
|
+
scope[ADMIN_MOUNTS] = created;
|
|
19
|
+
return created;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Called by `defineAdmin()`. Keyed by base path and last-wins: `x dev` re-evaluates the module
|
|
24
|
+
* that declares the admin when it changes, and the admin a request then meets has to be the one
|
|
25
|
+
* the author just saved.
|
|
26
|
+
*/
|
|
27
|
+
export function registerAdminMount(app: AdminApp): void {
|
|
28
|
+
mounts().set(app.basePath, app);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Every declared admin, in declaration order. */
|
|
32
|
+
export function adminMounts(): readonly AdminApp[] {
|
|
33
|
+
return [...mounts().values()];
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** The admin declared at `basePath` NOW, or `undefined`. Read per request, never captured. */
|
|
37
|
+
export function adminMountAt(basePath: string): AdminApp | undefined {
|
|
38
|
+
return mounts().get(basePath);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Test seam; a running process never forgets an admin it declared. */
|
|
42
|
+
export function clearAdminMounts(): void {
|
|
43
|
+
mounts().clear();
|
|
44
|
+
}
|