@ultimat3/admin 22.14.0 → 23.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 +73 -80
- package/README.md +276 -87
- package/package.json +19 -17
- package/src/action-form.tsx +139 -0
- package/src/action-gate.ts +127 -28
- 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 +92 -0
- package/src/batch.ts +431 -0
- package/src/catalog-keys.ts +55 -0
- package/src/crud-input.ts +104 -0
- package/src/crud-outcome.ts +166 -0
- package/src/crud.ts +129 -177
- package/src/describe.ts +131 -0
- package/src/detail.tsx +115 -43
- package/src/dev/data.ts +24 -6
- package/src/dev/facts.ts +27 -0
- 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.ts +6 -0
- package/src/dev/server.ts +15 -7
- package/src/entity-columns.ts +24 -5
- package/src/errors.ts +83 -0
- package/src/fields.ts +63 -4
- package/src/form-decode.ts +91 -0
- package/src/form.tsx +78 -45
- package/src/index.ts +198 -9
- package/src/jobs/fleet-repo.ts +143 -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 +103 -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/layout.tsx +55 -43
- package/src/list-columns.tsx +53 -0
- package/src/list-filter-bar.tsx +207 -0
- package/src/list-filters.ts +217 -0
- package/src/list-request.ts +127 -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 +108 -38
- package/src/mounts.ts +44 -0
- package/src/page-guard.tsx +26 -29
- package/src/policy-bridge.ts +110 -15
- package/src/registry.ts +138 -8
- package/src/related.ts +105 -0
- package/src/relations.ts +224 -0
- package/src/renderers.tsx +89 -0
- package/src/repo-entity.ts +204 -0
- package/src/resource-fields.ts +116 -0
- package/src/resource-layout.ts +89 -0
- package/src/resource-list.ts +185 -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/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 +393 -0
- package/src/screen-system.tsx +69 -0
- package/src/screens.ts +56 -0
- package/src/search.ts +13 -9
- 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 -12
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,23 @@ 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
|
+
return invalidIssues(result.issues);
|
|
174
238
|
}
|
|
175
239
|
|
|
176
240
|
export interface AdminMcpOptions {
|
|
@@ -195,6 +259,9 @@ const JSON_TYPE: Readonly<Record<AdminFieldType, NonNullable<JsonSchema['type']>
|
|
|
195
259
|
json: 'object',
|
|
196
260
|
relation: 'string',
|
|
197
261
|
file: 'string',
|
|
262
|
+
// A sealed column travels as the text an agent sends. It is in no tool schema today — the
|
|
263
|
+
// CRUD tools are built from `formFields`, which never holds one.
|
|
264
|
+
secret: 'string',
|
|
198
265
|
};
|
|
199
266
|
|
|
200
267
|
/**
|
|
@@ -210,7 +277,10 @@ const JSON_TYPE: Readonly<Record<AdminFieldType, NonNullable<JsonSchema['type']>
|
|
|
210
277
|
const inputSchema = (tool: AdminMcpTool): JsonSchema => ({
|
|
211
278
|
type: 'object',
|
|
212
279
|
properties: Object.fromEntries(
|
|
213
|
-
tool.input.map((field) =>
|
|
280
|
+
tool.input.map((field) => {
|
|
281
|
+
const one = { type: JSON_TYPE[field.type] };
|
|
282
|
+
return [field.name, field.list === true ? { type: 'array', items: one } : one];
|
|
283
|
+
}),
|
|
214
284
|
),
|
|
215
285
|
required: tool.input.filter((field) => field.required).map((field) => field.name),
|
|
216
286
|
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
|
+
}
|
package/src/page-guard.tsx
CHANGED
|
@@ -1,13 +1,14 @@
|
|
|
1
|
-
//
|
|
2
|
-
//
|
|
3
|
-
//
|
|
1
|
+
// What a refused screen renders and the audit entry it leaves. The wrapper that DECIDES is
|
|
2
|
+
// `guardedScreen()` in `screen-frame.tsx`: `routes.ts` never hands a host the author's component
|
|
3
|
+
// — it hands that screen, which asks the SAME `decideAll` every CRUD call and every nav item
|
|
4
|
+
// asks, audits the refusal through this file, and only then calls the author's code.
|
|
4
5
|
|
|
5
6
|
import { t } from '@ultimat3/i18n';
|
|
6
7
|
import type { JSX } from 'solid-js';
|
|
7
|
-
import
|
|
8
|
+
import styles from './admin.module.scss';
|
|
8
9
|
import { deniedDraft } from './audit';
|
|
9
|
-
import {
|
|
10
|
-
import type {
|
|
10
|
+
import type { AdminDecision } from './authz';
|
|
11
|
+
import type { CrudCtx } from './crud';
|
|
11
12
|
|
|
12
13
|
/**
|
|
13
14
|
* The refusal, rendered as the page. Not a redirect and not a 404: an operator who is missing a
|
|
@@ -21,7 +22,7 @@ export function AdminPageDenied(props: {
|
|
|
21
22
|
return (
|
|
22
23
|
<section class="x-admin-denied" role="alert">
|
|
23
24
|
<h1>{t(props.titleKey)}</h1>
|
|
24
|
-
<p>
|
|
25
|
+
<p class={styles['refusal']}>
|
|
25
26
|
{t('admin.denied.body', {
|
|
26
27
|
permission: props.decision.permission,
|
|
27
28
|
reason: props.decision.reason,
|
|
@@ -32,27 +33,23 @@ export function AdminPageDenied(props: {
|
|
|
32
33
|
}
|
|
33
34
|
|
|
34
35
|
/**
|
|
35
|
-
*
|
|
36
|
-
*
|
|
36
|
+
* The audit entry of a refused SCREEN. The page is the subject, so its path is what the row
|
|
37
|
+
* names — there is no entity and no row id to key a refused screen by. One writer, so a custom
|
|
38
|
+
* page and a generated form's GET leave the same entry.
|
|
37
39
|
*/
|
|
38
|
-
export function
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
);
|
|
54
|
-
return <AdminPageDenied titleKey={route.titleKey} decision={decision} />;
|
|
55
|
-
}
|
|
56
|
-
return component(props);
|
|
57
|
-
};
|
|
40
|
+
export async function auditRefusal(
|
|
41
|
+
ctx: CrudCtx,
|
|
42
|
+
path: string,
|
|
43
|
+
decision: AdminDecision,
|
|
44
|
+
): Promise<void> {
|
|
45
|
+
await ctx.audit.append(
|
|
46
|
+
deniedDraft({
|
|
47
|
+
requestId: ctx.requestId,
|
|
48
|
+
actor: ctx.actor,
|
|
49
|
+
operation: 'page',
|
|
50
|
+
kind: 'operation',
|
|
51
|
+
entity: path,
|
|
52
|
+
decision,
|
|
53
|
+
}),
|
|
54
|
+
);
|
|
58
55
|
}
|
package/src/policy-bridge.ts
CHANGED
|
@@ -3,8 +3,24 @@
|
|
|
3
3
|
// policies and the dashboard — and no second authz implementation can grow next to it.
|
|
4
4
|
|
|
5
5
|
import { type Actor, userActor } from '@ultimat3/core';
|
|
6
|
-
import {
|
|
7
|
-
|
|
6
|
+
import {
|
|
7
|
+
can,
|
|
8
|
+
definePermissions,
|
|
9
|
+
evaluate,
|
|
10
|
+
isKnownPermission,
|
|
11
|
+
type KnownPermission,
|
|
12
|
+
type Permission,
|
|
13
|
+
type Policy,
|
|
14
|
+
} from '@ultimat3/policy';
|
|
15
|
+
import {
|
|
16
|
+
type AdminActor,
|
|
17
|
+
type AdminAuthz,
|
|
18
|
+
type AdminDecision,
|
|
19
|
+
type AdminSubject,
|
|
20
|
+
allowed,
|
|
21
|
+
denied,
|
|
22
|
+
impliersOf,
|
|
23
|
+
} from './authz';
|
|
8
24
|
import { ADMIN_PERMISSIONS } from './permissions';
|
|
9
25
|
|
|
10
26
|
/** The admin's own permission set, registered with the policy layer at import time. */
|
|
@@ -27,15 +43,104 @@ const policyActor = (actor: AdminActor): Actor =>
|
|
|
27
43
|
function readDecision(permission: string, result: unknown): AdminDecision {
|
|
28
44
|
const bag = (typeof result === 'object' && result !== null ? result : {}) as {
|
|
29
45
|
allowed?: unknown;
|
|
30
|
-
|
|
46
|
+
decision?: unknown;
|
|
31
47
|
trace?: unknown;
|
|
32
48
|
};
|
|
49
|
+
// The reason lives on the evaluation's `decision`, not beside `allowed`. It was read off the top
|
|
50
|
+
// level, where no evaluation has ever carried one, so every refusal an operator read said
|
|
51
|
+
// `admin.policy.evaluated` — the same string whichever rule refused them.
|
|
52
|
+
const inner = (typeof bag.decision === 'object' && bag.decision !== null ? bag.decision : {}) as {
|
|
53
|
+
reason?: unknown;
|
|
54
|
+
};
|
|
33
55
|
const verdict = result === true || bag.allowed === true;
|
|
34
|
-
const reason = typeof
|
|
56
|
+
const reason = typeof inner.reason === 'string' ? inner.reason : 'admin.policy.evaluated';
|
|
35
57
|
const trace = Array.isArray(bag.trace) ? bag.trace.map((line) => String(line)) : [];
|
|
36
58
|
return verdict ? allowed(permission, reason, trace) : denied(permission, reason, trace);
|
|
37
59
|
}
|
|
38
60
|
|
|
61
|
+
/** `resource:verb` — the only shape `can()` takes. Anything else is denied, never thrown. */
|
|
62
|
+
const isPermission = (value: string): value is Permission => /^[^:]+:[^:]+$/.test(value);
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Declare the permissions an admin DERIVES — `<entity>:read|write|delete`, a page's, an action's —
|
|
66
|
+
* so `can()` knows them. Granting them stays the app's: a role map that names none of these
|
|
67
|
+
* refuses every screen. Idempotent, and this file's because it is the one that speaks to
|
|
68
|
+
* `@ultimat3/policy`.
|
|
69
|
+
*/
|
|
70
|
+
export function declareAdminPermissions(permissions: readonly string[]): void {
|
|
71
|
+
definePermissions(permissions.filter(isPermission));
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const evaluated = (
|
|
75
|
+
permission: string,
|
|
76
|
+
policy: Policy,
|
|
77
|
+
actor: AdminActor,
|
|
78
|
+
subject: AdminSubject | undefined,
|
|
79
|
+
): AdminDecision => {
|
|
80
|
+
// `EvaluateArgs` carries exactly one payload. An admin subject with no action input IS
|
|
81
|
+
// that payload: a row-level rule has nothing but the entity and id to decide on.
|
|
82
|
+
const payload = subject?.input ?? subject;
|
|
83
|
+
// `row` is passed through only when the surface LOADED one. Omitting it and passing
|
|
84
|
+
// `row: undefined` are different facts to `evaluate`, and a rule that reads `row` must see
|
|
85
|
+
// `null` — "no row was loaded" — rather than a value the admin invented from `input`.
|
|
86
|
+
const row = subject === undefined || !('row' in subject) ? undefined : { row: subject.row };
|
|
87
|
+
return readDecision(
|
|
88
|
+
permission,
|
|
89
|
+
evaluate(policy, { actor: policyActor(actor), input: payload, ...row }),
|
|
90
|
+
);
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* The default authz: the app's ROLE MAP decides. Every permission the admin asks about is
|
|
95
|
+
* `can(permission)` — "does a role this actor holds grant it" — so an app with a `defineRoles()`
|
|
96
|
+
* call has a working, closed admin with no permission → policy table to keep in step. An app
|
|
97
|
+
* whose rules read the row or the tenant passes `policyAuthz({ policies })` instead.
|
|
98
|
+
*/
|
|
99
|
+
export function roleAuthz(): AdminAuthz {
|
|
100
|
+
return {
|
|
101
|
+
decide({ permission, actor, subject }): AdminDecision {
|
|
102
|
+
// Asked BEFORE `can()`, which throws on a name the registry lacks: a decision is an answer,
|
|
103
|
+
// and an undeclared permission is a refusal with its fix, never a 500 out of a nav render.
|
|
104
|
+
if (!isPermission(permission) || !isKnownPermission(permission)) {
|
|
105
|
+
return denied(permission, 'admin.policy.missing', [
|
|
106
|
+
`"${permission}" is not a declared resource:verb permission`,
|
|
107
|
+
`fix: definePermissions(['${permission}']), then grant it to a role in defineRoles()`,
|
|
108
|
+
]);
|
|
109
|
+
}
|
|
110
|
+
// `can()` asserts the name against the registry `declareAdminPermissions` filled. The cast
|
|
111
|
+
// is the same one a route guard's bare string takes: the registry, not the type, is the check.
|
|
112
|
+
const own = evaluated(permission, can(permission as KnownPermission), actor, subject);
|
|
113
|
+
if (own.allowed) return own;
|
|
114
|
+
// The admin's implications, as `staticAuthz` applies them: a role granting `admin:destroy`
|
|
115
|
+
// holds `admin:write` and `admin:read` too. Without this the same grants answered two ways.
|
|
116
|
+
for (const implier of impliersOf(permission)) {
|
|
117
|
+
const carried = evaluated(implier, can(implier as KnownPermission), actor, subject);
|
|
118
|
+
if (carried.allowed) {
|
|
119
|
+
return allowed(permission, carried.reason, [
|
|
120
|
+
...carried.trace,
|
|
121
|
+
`${permission} is implied by ${implier}`,
|
|
122
|
+
]);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
return own;
|
|
126
|
+
},
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* ONE policy answers every gate the admin asks — "whoever may run the app may run its admin".
|
|
132
|
+
* The shape a small app starts in: a single grant its role map already has, with no table of
|
|
133
|
+
* per-entity permissions to keep in step as entities are added. The pair is still asked
|
|
134
|
+
* (`admin:write` then `posts:write`), each is still audited under its own name, and the day a
|
|
135
|
+
* view-only operator is needed this becomes `roleAuthz()` or `policyAuthz({ policies })`.
|
|
136
|
+
*/
|
|
137
|
+
export function singlePolicyAuthz(policy: Policy): AdminAuthz {
|
|
138
|
+
return {
|
|
139
|
+
decide: ({ permission, actor, subject }): AdminDecision =>
|
|
140
|
+
evaluated(permission, policy, actor, subject),
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
|
|
39
144
|
export interface PolicyAuthzInput {
|
|
40
145
|
/** Permission name → the policy that decides it. `describeActions()` supplies these. */
|
|
41
146
|
readonly policies: Readonly<Record<string, Policy>>;
|
|
@@ -55,17 +160,7 @@ export function policyAuthz(input: PolicyAuthzInput): AdminAuthz {
|
|
|
55
160
|
`fix: definePermissions({ '${permission}': … }) or can('${permission}') on the action`,
|
|
56
161
|
]);
|
|
57
162
|
}
|
|
58
|
-
|
|
59
|
-
// that payload: a row-level rule has nothing but the entity and id to decide on.
|
|
60
|
-
const payload = subject?.input ?? subject;
|
|
61
|
-
// `row` is passed through only when the surface LOADED one. Omitting it and passing
|
|
62
|
-
// `row: undefined` are different facts to `evaluate`, and a rule that reads `row` must see
|
|
63
|
-
// `null` — "no row was loaded" — rather than a value the admin invented from `input`.
|
|
64
|
-
const row = subject === undefined || !('row' in subject) ? undefined : { row: subject.row };
|
|
65
|
-
return readDecision(
|
|
66
|
-
permission,
|
|
67
|
-
evaluate(policy, { actor: policyActor(actor), input: payload, ...row }),
|
|
68
|
-
);
|
|
163
|
+
return evaluated(permission, policy, actor, subject);
|
|
69
164
|
},
|
|
70
165
|
};
|
|
71
166
|
}
|