@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.
Files changed (97) hide show
  1. package/CLAUDE.md +83 -91
  2. package/README.md +282 -89
  3. package/package.json +19 -17
  4. package/src/action-form.tsx +139 -0
  5. package/src/action-gate.ts +141 -68
  6. package/src/action-input.ts +119 -0
  7. package/src/action-label.ts +9 -0
  8. package/src/action-row.ts +37 -0
  9. package/src/actions.tsx +89 -66
  10. package/src/actor.ts +53 -0
  11. package/src/admin.module.scss +298 -0
  12. package/src/admin.ts +187 -23
  13. package/src/audit-pg.ts +207 -0
  14. package/src/audit-schema.ts +47 -0
  15. package/src/audit.ts +109 -22
  16. package/src/authz.ts +11 -0
  17. package/src/batch-bar.tsx +66 -0
  18. package/src/batch-job.ts +152 -0
  19. package/src/batch-matching.ts +87 -0
  20. package/src/batch-queue.ts +90 -0
  21. package/src/batch.ts +396 -0
  22. package/src/catalog-keys.ts +55 -0
  23. package/src/crud-input.ts +104 -0
  24. package/src/crud-outcome.ts +224 -0
  25. package/src/crud.ts +172 -176
  26. package/src/describe.ts +131 -0
  27. package/src/detail.tsx +115 -43
  28. package/src/dev/data.ts +27 -12
  29. package/src/dev/facts.ts +28 -1
  30. package/src/dev/index.ts +1 -0
  31. package/src/dev/panel-cache.ts +8 -3
  32. package/src/dev/panel-db.ts +37 -5
  33. package/src/dev/panel-jobs.ts +8 -0
  34. package/src/dev/panel-routes.ts +10 -0
  35. package/src/dev/panel-timeline.ts +7 -2
  36. package/src/dev/panel.ts +10 -2
  37. package/src/dev/server.ts +16 -11
  38. package/src/dev/sql-runner.ts +31 -0
  39. package/src/entity-columns.ts +24 -5
  40. package/src/errors.ts +92 -5
  41. package/src/fields.ts +63 -4
  42. package/src/form-decode.ts +132 -0
  43. package/src/form.tsx +96 -49
  44. package/src/index.ts +199 -9
  45. package/src/jobs/fleet-repo.ts +155 -0
  46. package/src/jobs/job-actions.ts +172 -0
  47. package/src/jobs/job-entities.ts +118 -0
  48. package/src/jobs/job-labels.ts +108 -0
  49. package/src/jobs/job-repo.ts +137 -0
  50. package/src/jobs/job-resources.ts +211 -0
  51. package/src/jobs/job-where.ts +170 -0
  52. package/src/jobs/operator.ts +34 -0
  53. package/src/jobs/overview-data.ts +129 -0
  54. package/src/jobs/overview-tab.tsx +61 -0
  55. package/src/jobs/overview.tsx +189 -0
  56. package/src/json-text.ts +52 -0
  57. package/src/keyset-seek.ts +189 -0
  58. package/src/layout.tsx +55 -43
  59. package/src/list-columns.tsx +53 -0
  60. package/src/list-filter-bar.tsx +207 -0
  61. package/src/list-filters.ts +242 -0
  62. package/src/list-request.ts +129 -0
  63. package/src/list-scope-tabs.tsx +60 -0
  64. package/src/list-scope.ts +113 -0
  65. package/src/list.tsx +111 -72
  66. package/src/mcp-tools.ts +57 -13
  67. package/src/mcp.ts +112 -38
  68. package/src/mounts.ts +44 -0
  69. package/src/page-guard.tsx +26 -29
  70. package/src/pagination.ts +10 -4
  71. package/src/policy-bridge.ts +115 -18
  72. package/src/registry.ts +140 -9
  73. package/src/related.ts +105 -0
  74. package/src/relations.ts +240 -0
  75. package/src/renderers.tsx +89 -0
  76. package/src/repo-entity.ts +195 -0
  77. package/src/resource-fields.ts +116 -0
  78. package/src/resource-layout.ts +89 -0
  79. package/src/resource-list.ts +194 -0
  80. package/src/resource.ts +129 -79
  81. package/src/route-config.ts +71 -0
  82. package/src/routes.ts +55 -54
  83. package/src/row-scope-write.ts +130 -0
  84. package/src/row-version.ts +20 -0
  85. package/src/screen-action.tsx +333 -0
  86. package/src/screen-frame.tsx +167 -0
  87. package/src/screen-home.tsx +201 -0
  88. package/src/screen-lookup.tsx +157 -0
  89. package/src/screen-resource.tsx +441 -0
  90. package/src/screen-system.tsx +75 -0
  91. package/src/screens.ts +56 -0
  92. package/src/search.ts +22 -10
  93. package/src/stylesheet-scope.ts +8 -0
  94. package/src/test-ctx.ts +31 -0
  95. package/src/validate.ts +3 -0
  96. package/src/widget-value.ts +36 -8
  97. package/src/widgets.tsx +84 -16
package/src/list.tsx CHANGED
@@ -1,33 +1,46 @@
1
- // The list view: derived columns, keyset pagination, and the three states a table actually
2
- // has (loading, empty, error) instead of the one it usually ships with. Prev/Next are the
3
- // only navigation — there is no page number, because there is no offset.
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, AdminSort } from './registry';
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> | null;
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
- readonly onCursor: (cursor: string | null) => void;
28
- readonly onOpen: (id: string) => void;
29
- /** Absent means the table renders its current order without sortable headers. */
30
- readonly onSort?: (sort: AdminSort) => void;
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(props.resource.titleKey)}</h2>}>
53
- <AdminActions
54
- actions={props.resource.actions}
55
- actor={props.actor}
56
- authz={props.authz}
57
- subject={{ entity: props.resource.name }}
58
- onRun={() => props.onCursor(null)}
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(props.resource.titleKey)}
100
+ caption={t(resource.titleKey)}
66
101
  columns={[
67
- // A real link, not a row click handler: the detail view has to survive a
68
- // middle-click, a keyboard, and a crawler that never runs the onClick.
69
- {
70
- key: 'open',
71
- header: t('admin.list.open'),
72
- cell: (row: Row) => (
73
- <a
74
- href={href(idOf(row))}
75
- onClick={(event) => {
76
- event.preventDefault();
77
- props.onOpen(idOf(row));
78
- }}
79
- >
80
- {idOf(row)}
81
- </a>
82
- ),
83
- },
84
- ...props.resource.listFields.map((field) => ({
85
- key: field.name,
86
- header: t(field.labelKey),
87
- sortable: field.sortable && props.onSort !== undefined,
88
- cell: (row: Row) => (
89
- <Widget field={field} value={row[field.name]} ctx={props.ctx} mode="read" />
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
- onSortChange={(next) => {
97
- if (next !== undefined) props.onSort?.({ field: next.key, direction: next.direction });
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 = (destructive: boolean): readonly AdminToolField[] => [
55
+ const actionEnvelope = (action: AdminAction): readonly AdminToolField[] => [
53
56
  { name: 'id', type: 'text', required: false },
54
- ...(destructive ? [{ name: 'confirmation', type: 'text', required: true } as const] : []),
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: `List ${resource.name} rows, cursor-paginated, newest first.`,
109
+ description: listDescription(resource),
71
110
  entity: resource.name,
72
111
  action: null,
73
- permissions: permissionsForOperation(resource.name, 'list'),
112
+ permissions: permissionsForOperation(resource.permission, 'list'),
74
113
  destructive: false,
75
- input: [CURSOR_FIELD, { name: 'limit', type: 'number', required: false }],
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.name, 'detail'),
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.name, 'create'),
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.name, 'update'),
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.name, 'delete'),
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.mcp?.description ?? `Run the ${action.name} 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.destructive === true),
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.mcp?.description ?? `Run the ${action.name} 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.destructive === true),
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 row =
120
- owner === undefined || id === '' ? undefined : ((await repoOf(owner).find(id)) ?? null);
121
- const result = await invokeAdminAction({
122
- action,
123
- input: withoutKeys(input, ['confirmation']),
124
- actor: ctx.actor,
125
- authz: ctx.authz,
126
- audit: ctx.audit,
127
- requestId: ctx.requestId,
128
- subject: {
129
- ...(action.entity === undefined ? {} : { entity: action.entity }),
130
- ...(id === '' ? {} : { id }),
131
- ...(row === undefined ? {} : { row }),
132
- },
133
- // The agent must echo the token, exactly as the UI makes an operator type it — and the gate
134
- // derives the token it expects from the entity and `subject.id` above.
135
- confirmation: str(input, 'confirmation'),
136
- });
137
- return result.ok
138
- ? { ok: true, data: result.value }
139
- : { ok: false, error: 'X_ADMIN_DENIED', reason: result.decision.reason };
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 result = await adminList(resource, ctx, {
147
- cursor: str(input, 'cursor'),
148
- ...(limit === undefined ? {} : { limit }),
149
- });
150
- return result.ok
151
- ? { ok: true, data: result.page }
152
- : { ok: false, error: 'X_ADMIN_DENIED', reason: result.decision.reason };
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
- return result.kind === 'denied'
172
- ? { ok: false, error: 'X_ADMIN_DENIED', reason: result.decision.reason }
173
- : { ok: false, error: 'X_ADMIN_INVALID', reason: JSON.stringify(result.issues) };
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) => [field.name, { type: JSON_TYPE[field.type] }]),
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
+ }