@ultimat3/admin 2.0.0 → 4.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 +7 -2
- package/README.md +6 -3
- package/package.json +16 -16
- package/src/admin.ts +34 -0
- package/src/authz.ts +18 -0
- package/src/crud.ts +31 -9
- package/src/detail.tsx +13 -1
- package/src/dev/data.ts +86 -101
- package/src/dev/facts.ts +24 -1
- package/src/dev/panel-db.ts +18 -2
- package/src/dev/panel-routes.ts +5 -4
- package/src/dev/panel.ts +9 -15
- package/src/errors.ts +24 -0
- package/src/fields.ts +42 -3
- package/src/index.ts +1 -0
- package/src/inert-jsx.ts +187 -0
- package/src/mcp.ts +35 -3
- package/src/policy-bridge.ts +9 -2
- package/src/resource.ts +15 -10
- package/src/search.ts +55 -5
- package/src/widget-value.ts +2 -1
- package/src/widgets.tsx +10 -3
package/src/dev/panel-db.ts
CHANGED
|
@@ -4,12 +4,19 @@
|
|
|
4
4
|
|
|
5
5
|
import { isUltimateError } from '@ultimat3/core';
|
|
6
6
|
import { assertReadOnlyQuery } from '@ultimat3/mcp';
|
|
7
|
+
import { DevSourceUnavailableError } from '../errors';
|
|
7
8
|
import type { DriftFact, SqlResult, TableFact } from './facts';
|
|
8
9
|
import type { DevPanel } from './panel';
|
|
9
10
|
|
|
10
11
|
export interface DbPanelData {
|
|
11
12
|
readonly tables: readonly TableFact[];
|
|
12
|
-
|
|
13
|
+
/**
|
|
14
|
+
* `null` when no host wired the check — NOT `[]`. Drift is the entities against the database,
|
|
15
|
+
* so it needs the connection this process may not have, and an empty list reads as "the schema
|
|
16
|
+
* matches", which is the one answer a panel that never looked has not earned. The same
|
|
17
|
+
* distinction `panel-timeline.ts` draws for `nPlusOne` and `panel-live.ts` for its subscribers.
|
|
18
|
+
*/
|
|
19
|
+
readonly drift: readonly DriftFact[] | null;
|
|
13
20
|
readonly sql: string | null;
|
|
14
21
|
readonly result: SqlResult | null;
|
|
15
22
|
/** Set when a statement was refused; the panel shows it instead of a result grid. */
|
|
@@ -95,7 +102,16 @@ export const dbPanel: DevPanel<DbPanelData> = {
|
|
|
95
102
|
titleKey: 'dev.panel.db.title',
|
|
96
103
|
questionKey: 'dev.panel.db.question',
|
|
97
104
|
async data(sources, params): Promise<DbPanelData> {
|
|
98
|
-
|
|
105
|
+
// The drift half degrades on its own: a host with no drift check must still get the table
|
|
106
|
+
// list and the SQL box. Only `DevSourceUnavailableError` is caught — a checker that ran and
|
|
107
|
+
// FAILED is a diagnostic, and reaches `panelPayload` with its code and its fix.
|
|
108
|
+
const [tables, drift] = await Promise.all([
|
|
109
|
+
sources.tables(),
|
|
110
|
+
sources.drift().catch((error: unknown) => {
|
|
111
|
+
if (!(error instanceof DevSourceUnavailableError)) throw error;
|
|
112
|
+
return null;
|
|
113
|
+
}),
|
|
114
|
+
]);
|
|
99
115
|
const sql = params.get('sql');
|
|
100
116
|
if (sql === null || sql.trim() === '') {
|
|
101
117
|
return { tables, drift, sql: null, result: null, refused: null, readOnly: true };
|
package/src/dev/panel-routes.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// Panel: Routes.
|
|
2
|
-
// Kills: "which handler serves this?" — the route table with render mode, offline strategy
|
|
3
|
-
//
|
|
2
|
+
// Kills: "which handler serves this?" — the route table with render mode, offline strategy
|
|
3
|
+
// and budget.
|
|
4
4
|
|
|
5
5
|
import type { RouteFact } from './facts';
|
|
6
6
|
import type { DevPanel } from './panel';
|
|
@@ -9,7 +9,9 @@ export interface RoutesPanelData {
|
|
|
9
9
|
readonly routes: readonly RouteFact[];
|
|
10
10
|
/** Counts per render mode: an app that is all `ssr` has a caching problem to find. */
|
|
11
11
|
readonly byRenderMode: Readonly<Record<string, number>>;
|
|
12
|
-
|
|
12
|
+
// No `missingMeta`. `defineRoute()` refuses a route without a `meta` function, so the list had
|
|
13
|
+
// no member it could ever hold — and it was published from a `RouteFact.hasMeta` that read a
|
|
14
|
+
// key no descriptor has, which made it name EVERY route instead. See `RouteFact` in `facts.ts`.
|
|
13
15
|
readonly overBudget: readonly string[];
|
|
14
16
|
}
|
|
15
17
|
|
|
@@ -34,7 +36,6 @@ export const routesPanel: DevPanel<RoutesPanelData> = {
|
|
|
34
36
|
return {
|
|
35
37
|
routes: [...routes].sort((a, b) => a.path.localeCompare(b.path)),
|
|
36
38
|
byRenderMode,
|
|
37
|
-
missingMeta: routes.filter((route) => !route.hasMeta).map((route) => route.path),
|
|
38
39
|
overBudget: routes
|
|
39
40
|
.filter((route) => kb(route.budget.js) > BUDGET_LIMIT_KB)
|
|
40
41
|
.map((route) => route.path),
|
package/src/dev/panel.ts
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
// draws — and because `data()` returns plain JSON, `--json` and the rendered tab are the
|
|
3
3
|
// same facts by construction.
|
|
4
4
|
|
|
5
|
+
import { renderThrowable, stringField } from '@ultimat3/core';
|
|
5
6
|
import type { DevSources } from './facts';
|
|
6
7
|
|
|
7
8
|
export interface DevPanel<Data = unknown> {
|
|
@@ -25,12 +26,6 @@ export type PanelPayload =
|
|
|
25
26
|
readonly error: { readonly code: string; readonly cause: string; readonly fix: string };
|
|
26
27
|
};
|
|
27
28
|
|
|
28
|
-
interface ErrorFields {
|
|
29
|
-
readonly code?: unknown;
|
|
30
|
-
readonly cause?: unknown;
|
|
31
|
-
readonly fix?: unknown;
|
|
32
|
-
}
|
|
33
|
-
|
|
34
29
|
/**
|
|
35
30
|
* A panel whose source is not wired must say so in the panel, with the fix line — the same
|
|
36
31
|
* payload the CLI prints. A blank tab would read as "nothing is happening".
|
|
@@ -43,19 +38,18 @@ export async function panelPayload(
|
|
|
43
38
|
try {
|
|
44
39
|
return { panel: panel.key, ok: true, data: await panel.data(sources, params) };
|
|
45
40
|
} catch (error) {
|
|
46
|
-
|
|
41
|
+
// Core's total readers, not `String(error)` and a raw property read. This catch owes its
|
|
42
|
+
// caller a `/_x` RESPONSE: `String(Object.create(null))` throws, and so does a getter or a
|
|
43
|
+
// Proxy trap on `error.code` — either turns a rendered failure panel into an unhandled
|
|
44
|
+
// rejection on the request. `stringField` answers `undefined` for absent, wrong type and
|
|
45
|
+
// threw alike, which is what every branch below already meant.
|
|
47
46
|
return {
|
|
48
47
|
panel: panel.key,
|
|
49
48
|
ok: false,
|
|
50
49
|
error: {
|
|
51
|
-
code:
|
|
52
|
-
cause:
|
|
53
|
-
|
|
54
|
-
? fields.cause
|
|
55
|
-
: error instanceof Error
|
|
56
|
-
? error.message
|
|
57
|
-
: String(error),
|
|
58
|
-
fix: typeof fields.fix === 'string' ? fields.fix : 'x dev --help',
|
|
50
|
+
code: stringField(error, 'code') ?? 'X_NOT_IMPLEMENTED',
|
|
51
|
+
cause: stringField(error, 'cause') ?? renderThrowable(error),
|
|
52
|
+
fix: stringField(error, 'fix') ?? 'x dev --help',
|
|
59
53
|
},
|
|
60
54
|
};
|
|
61
55
|
}
|
package/src/errors.ts
CHANGED
|
@@ -21,6 +21,10 @@ export const ADMIN_OWNED_ERROR_CODES = [
|
|
|
21
21
|
// screen, is a defect the author must never be able to deploy.
|
|
22
22
|
'X_ADMIN_PAGE_UNGUARDED',
|
|
23
23
|
'X_ADMIN_PAGE_PATH_INVALID',
|
|
24
|
+
// `AdminAction.name` addresses one handler in three places at once — the MCP tool name, the
|
|
25
|
+
// default label key, and `callAdminTool`'s lookup. Two actions sharing it is refused where the
|
|
26
|
+
// admin is DECLARED, so an app that never wires MCP still cannot ship the ambiguity.
|
|
27
|
+
'X_ADMIN_ACTION_DUPLICATE',
|
|
24
28
|
] as const;
|
|
25
29
|
|
|
26
30
|
/**
|
|
@@ -48,6 +52,7 @@ export const ADMIN_ERROR_TITLES: Readonly<Record<AdminOwnedErrorCode, string>> =
|
|
|
48
52
|
X_ADMIN_INVALID: "an admin tool's arguments failed the resource schema",
|
|
49
53
|
X_ADMIN_PAGE_UNGUARDED: 'a custom admin page declared no permissions',
|
|
50
54
|
X_ADMIN_PAGE_PATH_INVALID: 'a custom admin page has an unusable or already-taken path',
|
|
55
|
+
X_ADMIN_ACTION_DUPLICATE: 'two admin actions share one name',
|
|
51
56
|
};
|
|
52
57
|
|
|
53
58
|
// One unconditional call, so a second package claiming one of admin's codes throws
|
|
@@ -90,6 +95,25 @@ export class AdminFieldUnsupportedError extends UltimateError {
|
|
|
90
95
|
}
|
|
91
96
|
}
|
|
92
97
|
|
|
98
|
+
/**
|
|
99
|
+
* Two registered actions carry one `name`. Refused at `defineAdmin`, not at the first call: the
|
|
100
|
+
* name is the MCP tool name, the default label key AND the key `callAdminTool` resolves a handler
|
|
101
|
+
* by, so a collision is a call that succeeds against the wrong action and reports nothing.
|
|
102
|
+
*/
|
|
103
|
+
export class AdminActionDuplicateError extends UltimateError {
|
|
104
|
+
constructor(input: { name: string; entities: readonly string[] }) {
|
|
105
|
+
const where = input.entities.length > 0 ? input.entities.join(' and ') : 'the global toolbar';
|
|
106
|
+
super({
|
|
107
|
+
code: 'X_ADMIN_ACTION_DUPLICATE',
|
|
108
|
+
cause: `two admin actions are named "${input.name}" (on ${where}); an action name addresses one handler`,
|
|
109
|
+
// The convention the framework's own examples already follow, made into the instruction:
|
|
110
|
+
// an entity-qualified name is unique by construction.
|
|
111
|
+
fix: `rename one in defineAdmin's actions — name: '<entity>.${input.name}' — so "${input.name}" belongs to one of them`,
|
|
112
|
+
docs: docsFor('X_ADMIN_ACTION_DUPLICATE'),
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
93
117
|
/**
|
|
94
118
|
* An action reached the admin without a policy — the button would be an open door.
|
|
95
119
|
*
|
package/src/fields.ts
CHANGED
|
@@ -84,6 +84,22 @@ export function widgetFor(type: AdminFieldType): AdminWidget {
|
|
|
84
84
|
* Every kind `@ultimat3/entity` can put on a column, and nothing else: a name that no column
|
|
85
85
|
* builder emits would be a widget nobody can reach. `text` is the entry the length rule below
|
|
86
86
|
* then refines.
|
|
87
|
+
*
|
|
88
|
+
* The kinds an EXISTING schema brings (`bigint()`, `decimal()`, `date()`, `arrayOf()`) each map to
|
|
89
|
+
* the widget that survives a round trip of the value the column itself produces, which is not
|
|
90
|
+
* always the widget the Postgres type suggests:
|
|
91
|
+
*
|
|
92
|
+
* | kind | field type | why not the obvious one |
|
|
93
|
+
* |---|---|---|
|
|
94
|
+
* | `numeric` | `text` | the row value is the exact decimal STRING, and `number-input` renders anything that is not a JS number as null — a blanked field that saves the blank back |
|
|
95
|
+
* | `bigint` | `text` | same reason, and it shipped as `number` before `bigint()` existed: the row value is decimal digits, because a JS `bigint` is what `JSON.stringify` throws on and a `number` loses everything past 2^53 — the range a legacy `int8` key lives in |
|
|
96
|
+
* | `date` | `date` | a calendar date has no zone, so it takes the `precision: 'date'` branch (`<input type="date">`) rather than the instant one |
|
|
97
|
+
* | `array` | `json` | `String(['a,b'])` and `String(['a','b'])` are the same string, and what a text input posts back is not an array at all |
|
|
98
|
+
*
|
|
99
|
+
* `bytea` is deliberately ABSENT. The `file` widget's value is a storage reference (`{ url, name }`
|
|
100
|
+
* — see `uploadValue`), and the `Uint8Array` a `bytes()` column puts on the row is not one, so
|
|
101
|
+
* mapping it would move the same refusal from derive time, where the fix names the edit, to every
|
|
102
|
+
* render of every row. There is no `vector` row for the same reason there is no vector column.
|
|
87
103
|
*/
|
|
88
104
|
const FIELD_TYPE_BY_COLUMN_KIND: Readonly<Record<string, AdminFieldType>> = {
|
|
89
105
|
uuid: 'text',
|
|
@@ -91,9 +107,12 @@ const FIELD_TYPE_BY_COLUMN_KIND: Readonly<Record<string, AdminFieldType>> = {
|
|
|
91
107
|
char: 'text',
|
|
92
108
|
boolean: 'boolean',
|
|
93
109
|
integer: 'number',
|
|
94
|
-
bigint: '
|
|
110
|
+
bigint: 'text',
|
|
111
|
+
numeric: 'text',
|
|
95
112
|
timestamptz: 'timestamptz',
|
|
113
|
+
date: 'date',
|
|
96
114
|
jsonb: 'json',
|
|
115
|
+
array: 'json',
|
|
97
116
|
money: 'money',
|
|
98
117
|
};
|
|
99
118
|
|
|
@@ -146,6 +165,26 @@ export function sortable(type: AdminFieldType, column: AdminColumnFacts): boolea
|
|
|
146
165
|
return column.index || column.unique || column.primaryKey;
|
|
147
166
|
}
|
|
148
167
|
|
|
149
|
-
|
|
150
|
-
|
|
168
|
+
/**
|
|
169
|
+
* The column kinds Postgres will accept a `LIKE` against. MEASURED on Postgres 17 (PGlite), one
|
|
170
|
+
* statement per type: `text` and `char` answer rows; `uuid`, `numeric`, `bigint`, `integer`,
|
|
171
|
+
* `date`, `timestamptz`, `jsonb`, `boolean` and `text[]` all answer
|
|
172
|
+
* `operator does not exist: <type> ~~ unknown`, and `bytea` answers `Invalid input for bytea type`.
|
|
173
|
+
*
|
|
174
|
+
* It has to be the KIND and not the field type, because several kinds render in a text box and
|
|
175
|
+
* only two of them can be searched: `adminSearch` issues one `contains` filter per searchable
|
|
176
|
+
* field and the driver compiles `contains` to `<column> like $1` with NO cast
|
|
177
|
+
* (`packages/entity/src/pg-sql.ts`), so a searchable column of any other kind makes the admin's
|
|
178
|
+
* search box answer a database error rather than an empty result.
|
|
179
|
+
*/
|
|
180
|
+
const LIKE_ABLE_COLUMN_KINDS: ReadonlySet<string> = new Set(['text', 'char']);
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* A text box over a column a `LIKE` can run against — both halves required. The type keeps an
|
|
184
|
+
* enum or a relation out of the search index the way it always has; the kind keeps out the ones
|
|
185
|
+
* that would throw.
|
|
186
|
+
*/
|
|
187
|
+
export function searchable(type: AdminFieldType, column: AdminColumnFacts): boolean {
|
|
188
|
+
if (type !== 'text' && type !== 'textarea') return false;
|
|
189
|
+
return LIKE_ABLE_COLUMN_KINDS.has(column.kind);
|
|
151
190
|
}
|
package/src/index.ts
CHANGED
package/src/inert-jsx.ts
ADDED
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
// TEST-ONLY. Calls an admin `.tsx` view as the plain function it is and walks what came back, so
|
|
2
|
+
// a test can assert on the tree a screen produced rather than on a value it also constructed.
|
|
3
|
+
//
|
|
4
|
+
// Which JSX factory a `.tsx` in this package compiled to is NOT ours to choose: `@ultimat3/render`
|
|
5
|
+
// installs a process-global `Bun.plugin` `onLoad` for `/\.tsx$/` at import (`routes.ts` imports it,
|
|
6
|
+
// so any sibling suite can win the race) and `bun test` is one process. Both factories build the
|
|
7
|
+
// same shape — a `type`, a `props`, children under `props.children` — so this walker recognises
|
|
8
|
+
// both and the run's file order stops deciding. Recognising neither is what falls through to
|
|
9
|
+
// `String(value)` and asserts against `"[object Object]"`.
|
|
10
|
+
|
|
11
|
+
/** `@ultimat3/render`'s node brand, read off the GLOBAL symbol registry rather than imported. */
|
|
12
|
+
const RENDER_NODE: symbol = Symbol.for('ultimate.render.jsx');
|
|
13
|
+
|
|
14
|
+
export type InertComponent = (props: Record<string, unknown>) => unknown;
|
|
15
|
+
|
|
16
|
+
export interface InertNode {
|
|
17
|
+
readonly type: string | InertComponent;
|
|
18
|
+
readonly props: Record<string, unknown>;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function isInertNode(value: unknown): value is InertNode {
|
|
22
|
+
if (typeof value !== 'object' || value === null) return false;
|
|
23
|
+
return 'inert' in value || RENDER_NODE in value;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** The classic factory these `.tsx` files fall back to under `jsx: 'preserve'`. */
|
|
27
|
+
export function h(
|
|
28
|
+
type: string | InertComponent,
|
|
29
|
+
props: Record<string, unknown> | null,
|
|
30
|
+
...children: readonly unknown[]
|
|
31
|
+
): InertNode {
|
|
32
|
+
const base = { ...(props ?? {}) };
|
|
33
|
+
if (children.length > 0) base['children'] = children.length === 1 ? children[0] : children;
|
|
34
|
+
return { inert: true, type, props: base } as unknown as InertNode;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* How many installs are live, and what `globalThis.React` was before the first one. A harness that
|
|
39
|
+
* DELETED the property on the way out destroys a binding it did not create, and a nested install
|
|
40
|
+
* would tear the factory out from under the suite still using it — the counter is what makes the
|
|
41
|
+
* last restore the only one that acts.
|
|
42
|
+
*/
|
|
43
|
+
let depth = 0;
|
|
44
|
+
let saved: PropertyDescriptor | undefined;
|
|
45
|
+
|
|
46
|
+
export function installFactory(): void {
|
|
47
|
+
if (depth === 0) {
|
|
48
|
+
// The descriptor, not the value: the property may be a getter or non-writable, and `assign`
|
|
49
|
+
// onto either throws or silently loses.
|
|
50
|
+
saved = Object.getOwnPropertyDescriptor(globalThis, 'React');
|
|
51
|
+
Object.defineProperty(globalThis, 'React', {
|
|
52
|
+
value: { createElement: h },
|
|
53
|
+
configurable: true,
|
|
54
|
+
writable: true,
|
|
55
|
+
enumerable: true,
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
depth += 1;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Undo the matching `installFactory()`. Unbalanced calls are inert. */
|
|
62
|
+
export function restoreFactory(): void {
|
|
63
|
+
if (depth === 0) return;
|
|
64
|
+
depth -= 1;
|
|
65
|
+
if (depth > 0) return;
|
|
66
|
+
if (saved === undefined) Reflect.deleteProperty(globalThis, 'React');
|
|
67
|
+
else Object.defineProperty(globalThis, 'React', saved);
|
|
68
|
+
saved = undefined;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Every node in the tree, depth first, with nested components CALLED and thunks invoked — a
|
|
73
|
+
* component that reads a prop inside one renders nothing until something asks.
|
|
74
|
+
*
|
|
75
|
+
* Component nodes are KEPT as well as expanded, so a test can assert on the props a view handed
|
|
76
|
+
* `<Money>` — the admin's contract with the design system — and not only on the markup ui chose
|
|
77
|
+
* to emit for them, which is ui's business and changes without this package changing.
|
|
78
|
+
*/
|
|
79
|
+
export function nodesOf(value: unknown): InertNode[] {
|
|
80
|
+
if (value === null || value === undefined || typeof value === 'boolean') return [];
|
|
81
|
+
if (typeof value === 'string' || typeof value === 'number') return [];
|
|
82
|
+
if (Array.isArray(value)) return value.flatMap(nodesOf);
|
|
83
|
+
if (isInertNode(value)) {
|
|
84
|
+
if (typeof value.type === 'function') return [value, ...nodesOf(value.type(value.props))];
|
|
85
|
+
return [value, ...nodesOf(value.props['children'])];
|
|
86
|
+
}
|
|
87
|
+
if (typeof value === 'function') return nodesOf((value as () => unknown)());
|
|
88
|
+
return [];
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* The tree this package itself built: component nodes are kept but NOT called, so the walk stops
|
|
93
|
+
* at the design-system boundary. Use this when a design-system component's own internals would
|
|
94
|
+
* otherwise answer a query about the admin's markup — `<Dialog>` renders a close `<button>`, and a
|
|
95
|
+
* test asking "which buttons did the action bar render" must not be handed ui's.
|
|
96
|
+
*/
|
|
97
|
+
export function shallowNodesOf(value: unknown): InertNode[] {
|
|
98
|
+
if (value === null || value === undefined || typeof value === 'boolean') return [];
|
|
99
|
+
if (typeof value === 'string' || typeof value === 'number') return [];
|
|
100
|
+
if (Array.isArray(value)) return value.flatMap(shallowNodesOf);
|
|
101
|
+
if (isInertNode(value)) return [value, ...shallowNodesOf(value.props['children'])];
|
|
102
|
+
if (typeof value === 'function') return shallowNodesOf((value as () => unknown)());
|
|
103
|
+
return [];
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** Nodes rendered by the named component — `byComponent(nodes, 'Money')`. */
|
|
107
|
+
export function byComponent(nodes: readonly InertNode[], name: string): InertNode[] {
|
|
108
|
+
return nodes.filter((node) => typeof node.type === 'function' && node.type.name === name);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const SKIPPED_PROPS = new Set(['children', 'ref', 'innerHTML']);
|
|
112
|
+
|
|
113
|
+
function attributes(props: Record<string, unknown>): string {
|
|
114
|
+
return Object.entries(props)
|
|
115
|
+
.filter(([name, value]) => {
|
|
116
|
+
if (SKIPPED_PROPS.has(name) || name.startsWith('on')) return false;
|
|
117
|
+
return value !== undefined && value !== null && value !== false;
|
|
118
|
+
})
|
|
119
|
+
.map(([name, value]) => (value === true ? ` ${name}` : ` ${name}="${String(value)}"`))
|
|
120
|
+
.join('');
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** The tree as markup. Unknown values are `String()`d, which the premise test refuses to allow. */
|
|
124
|
+
export function renderHtml(value: unknown): string {
|
|
125
|
+
if (value === null || value === undefined || typeof value === 'boolean') return '';
|
|
126
|
+
if (typeof value === 'string' || typeof value === 'number') return String(value);
|
|
127
|
+
if (Array.isArray(value)) return value.map(renderHtml).join('');
|
|
128
|
+
if (isInertNode(value)) {
|
|
129
|
+
if (typeof value.type === 'function') return renderHtml(value.type(value.props));
|
|
130
|
+
const inner = renderHtml(value.props['children']);
|
|
131
|
+
return `<${value.type}${attributes(value.props)}>${inner}</${value.type}>`;
|
|
132
|
+
}
|
|
133
|
+
if (typeof value === 'function') return renderHtml((value as () => unknown)());
|
|
134
|
+
return String(value);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** Render a component to its host nodes. `props` is the component's own, untyped on purpose. */
|
|
138
|
+
export function renderNodes(component: unknown, props: Record<string, unknown> = {}): InertNode[] {
|
|
139
|
+
return nodesOf((component as InertComponent)(props));
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* `renderNodes`, stopping at the design-system boundary — the `shallowNodesOf` sibling. The
|
|
144
|
+
* `unknown` parameter is what makes it usable: a screen's props are typed and generic, so a test
|
|
145
|
+
* calling one through `as (props: Record<string, unknown>) => unknown` is a conversion TypeScript
|
|
146
|
+
* refuses outright. One widening here beats one per call site.
|
|
147
|
+
*/
|
|
148
|
+
export function renderShallowNodes(
|
|
149
|
+
component: unknown,
|
|
150
|
+
props: Record<string, unknown> = {},
|
|
151
|
+
): InertNode[] {
|
|
152
|
+
return shallowNodesOf((component as InertComponent)(props));
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** Render a component to markup. */
|
|
156
|
+
export function renderComponent(component: unknown, props: Record<string, unknown> = {}): string {
|
|
157
|
+
return renderHtml((component as InertComponent)(props));
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
export function byTag(nodes: readonly InertNode[], tag: string): InertNode[] {
|
|
161
|
+
return nodes.filter((node) => node.type === tag);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** Nodes whose attribute equals `value`; `undefined` matches "carries the attribute at all". */
|
|
165
|
+
export function withAttr(nodes: readonly InertNode[], name: string, value?: unknown): InertNode[] {
|
|
166
|
+
return nodes.filter((node) =>
|
|
167
|
+
value === undefined ? node.props[name] !== undefined : node.props[name] === value,
|
|
168
|
+
);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** The one node a test means, or a throw naming what it looked for — never a silent `undefined`. */
|
|
172
|
+
export function one(nodes: readonly InertNode[], what: string): InertNode {
|
|
173
|
+
const node = nodes[0];
|
|
174
|
+
if (node === undefined || nodes.length !== 1) {
|
|
175
|
+
throw new Error(`expected exactly one ${what}, found ${nodes.length}`);
|
|
176
|
+
}
|
|
177
|
+
return node;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/** Call an element's event handler prop, e.g. `fire(button, 'onClick', {})`. */
|
|
181
|
+
export function fire(node: InertNode, handler: string, event: unknown): void {
|
|
182
|
+
const fn = node.props[handler];
|
|
183
|
+
if (typeof fn !== 'function') {
|
|
184
|
+
throw new Error(`node <${String(node.type)}> carries no ${handler}`);
|
|
185
|
+
}
|
|
186
|
+
(fn as (e: unknown) => void)(event);
|
|
187
|
+
}
|
package/src/mcp.ts
CHANGED
|
@@ -2,7 +2,14 @@
|
|
|
2
2
|
// `defineAppMcp` so the user's agents drive the user's app. Same authz, same audit, same
|
|
3
3
|
// confirmation rules as the buttons — this file adds a transport, not a second back door.
|
|
4
4
|
|
|
5
|
-
import {
|
|
5
|
+
import type { Actor } from '@ultimat3/core';
|
|
6
|
+
import {
|
|
7
|
+
agentActor,
|
|
8
|
+
createContext,
|
|
9
|
+
hasContext,
|
|
10
|
+
runWithContext,
|
|
11
|
+
withChildContext,
|
|
12
|
+
} from '@ultimat3/core';
|
|
6
13
|
import {
|
|
7
14
|
type AnyMcpTool,
|
|
8
15
|
type AppMcp,
|
|
@@ -257,6 +264,28 @@ function allowedToolNames(
|
|
|
257
264
|
return names;
|
|
258
265
|
}
|
|
259
266
|
|
|
267
|
+
/**
|
|
268
|
+
* Run the call AS the MCP caller — the ambient actor, not only the `CrudCtx` one.
|
|
269
|
+
*
|
|
270
|
+
* `AdminApp.ctx()` builds a plain `CrudCtx` and never touches core's async context, so everything
|
|
271
|
+
* deriving from `tryUseContext()` — entity's tenant guard, the query cache authority, the
|
|
272
|
+
* jit-preload store — saw whatever actor the TRANSPORT's surrounding request installed rather than
|
|
273
|
+
* the token's. Over `mcpHttpRoute` mounted in a pipeline that resolves a session cookie, an agent
|
|
274
|
+
* token authorized as agent X while the repo reads ran as cookie-user Y's tenant; over stdio there
|
|
275
|
+
* was no context at all, so `actorTenant` was `undefined` and `assertRowTenant` became a no-op and
|
|
276
|
+
* an admin `create` could name any `orgId`. Same rule as `@ultimat3/mcp`'s `app-tool.ts`: the
|
|
277
|
+
* caller is the actor for the WHOLE call, by construction rather than by two call sites agreeing.
|
|
278
|
+
*
|
|
279
|
+
* Two spellings, one rule, because there are two transports: a child context when a request is
|
|
280
|
+
* already in flight (it keeps the parent's `requestId` and rebuilds the managed services against
|
|
281
|
+
* the child's actor), a fresh ROOT context over stdio — where `withChildContext` would throw
|
|
282
|
+
* `X_NO_CONTEXT` from its own `useContext()`.
|
|
283
|
+
*/
|
|
284
|
+
const asCaller = <T>(actor: Actor, requestId: string, run: () => Promise<T>): Promise<T> =>
|
|
285
|
+
hasContext()
|
|
286
|
+
? withChildContext({ actor }, run)
|
|
287
|
+
: runWithContext(createContext({ actor, requestId }), run);
|
|
288
|
+
|
|
260
289
|
function toMcpTool(opts: AdminMcpOptions, requestId: () => string, tool: AdminMcpTool): AnyMcpTool {
|
|
261
290
|
return {
|
|
262
291
|
name: tool.name,
|
|
@@ -270,8 +299,11 @@ function toMcpTool(opts: AdminMcpOptions, requestId: () => string, tool: AdminMc
|
|
|
270
299
|
visibleTo: (caller: McpCaller): boolean =>
|
|
271
300
|
allowedToolNames(opts, requestId, caller).has(tool.name),
|
|
272
301
|
async handle(args: ToolArgs, caller: McpCaller): Promise<McpToolResult> {
|
|
273
|
-
const
|
|
274
|
-
const
|
|
302
|
+
const id = requestId();
|
|
303
|
+
const ctx = opts.app.ctx({ actor: adminActorOf(caller), requestId: id });
|
|
304
|
+
const result = await asCaller(caller.actor, id, () =>
|
|
305
|
+
callAdminTool(opts.app, ctx, tool.name, args),
|
|
306
|
+
);
|
|
275
307
|
if (result.ok) return jsonResult(result.data);
|
|
276
308
|
// An expected outcome the model should reason about (a policy said no), not a
|
|
277
309
|
// protocol error: the transport still answers 200 with the denial in the body.
|
package/src/policy-bridge.ts
CHANGED
|
@@ -15,7 +15,10 @@ export const adminPermissions = definePermissions(ADMIN_PERMISSIONS);
|
|
|
15
15
|
* mapping lives here because this file is the only one allowed to speak to the policy layer.
|
|
16
16
|
*/
|
|
17
17
|
const policyActor = (actor: AdminActor): Actor =>
|
|
18
|
-
|
|
18
|
+
// `orgId` rides along, and its absence used to be silent: every admin decision was evaluated
|
|
19
|
+
// with `actor.orgId === undefined`, so an org-scoped rule could not fire and a role-only rule
|
|
20
|
+
// allowed a row from another tenant.
|
|
21
|
+
userActor({ id: actor.id, roles: actor.roles ?? [], orgId: actor.orgId });
|
|
19
22
|
|
|
20
23
|
/**
|
|
21
24
|
* `evaluate()`'s result is read structurally: the policy layer owns its own decision type,
|
|
@@ -55,9 +58,13 @@ export function policyAuthz(input: PolicyAuthzInput): AdminAuthz {
|
|
|
55
58
|
// `EvaluateArgs` carries exactly one payload. An admin subject with no action input IS
|
|
56
59
|
// that payload: a row-level rule has nothing but the entity and id to decide on.
|
|
57
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 };
|
|
58
65
|
return readDecision(
|
|
59
66
|
permission,
|
|
60
|
-
evaluate(policy, { actor: policyActor(actor), input: payload }),
|
|
67
|
+
evaluate(policy, { actor: policyActor(actor), input: payload, ...row }),
|
|
61
68
|
);
|
|
62
69
|
},
|
|
63
70
|
};
|
package/src/resource.ts
CHANGED
|
@@ -69,7 +69,16 @@ export interface AdminResourceOptions<Row extends AdminRow = AdminRow> {
|
|
|
69
69
|
|
|
70
70
|
export interface AdminResource<Row extends AdminRow = AdminRow> {
|
|
71
71
|
readonly name: string;
|
|
72
|
-
/**
|
|
72
|
+
/**
|
|
73
|
+
* Mount-relative, e.g. `/posts` — the layout composes it with `AdminApp.basePath`, so this is
|
|
74
|
+
* never the URL on its own.
|
|
75
|
+
*
|
|
76
|
+
* Defaults to the entity's `$name` VERBATIM. It used to be `pluralize($name)`, which is why the
|
|
77
|
+
* reference app's dashboard served `/orgses`, `/postses` and `/commentses`: every entity in both
|
|
78
|
+
* tracked apps is already named plural (19 of 19), so the heuristic ran a second time on a word
|
|
79
|
+
* that had already had it. Which plural a name takes is an app's convention and not a mechanism
|
|
80
|
+
* the framework can own (axiom 8) — `path:` is how an app spells its own.
|
|
81
|
+
*/
|
|
73
82
|
readonly path: string;
|
|
74
83
|
readonly titleKey: string;
|
|
75
84
|
readonly group: string;
|
|
@@ -90,12 +99,6 @@ export interface AdminResource<Row extends AdminRow = AdminRow> {
|
|
|
90
99
|
field(name: string): AdminField;
|
|
91
100
|
}
|
|
92
101
|
|
|
93
|
-
const pluralize = (name: string): string => {
|
|
94
|
-
if (/[sxz]$/.test(name) || /(ch|sh)$/.test(name)) return `${name}es`;
|
|
95
|
-
if (/[^aeiou]y$/.test(name)) return `${name.slice(0, -1)}ies`;
|
|
96
|
-
return `${name}s`;
|
|
97
|
-
};
|
|
98
|
-
|
|
99
102
|
function deriveField(
|
|
100
103
|
entity: AdminEntity,
|
|
101
104
|
column: AdminColumnFacts,
|
|
@@ -131,7 +134,7 @@ function deriveField(
|
|
|
131
134
|
sortable: override?.sortable ?? sortable(type, column),
|
|
132
135
|
// Generated columns are excluded from search: an id is found by exact lookup, and a
|
|
133
136
|
// `contains` over a uuid column is a scan that returns nothing useful.
|
|
134
|
-
searchable: override?.searchable ?? (searchable(type) && !sensitive && !generated),
|
|
137
|
+
searchable: override?.searchable ?? (searchable(type, column) && !sensitive && !generated),
|
|
135
138
|
...(values === undefined ? {} : { values }),
|
|
136
139
|
...(currency === undefined ? {} : { currency }),
|
|
137
140
|
...(relation === undefined ? {} : { relation }),
|
|
@@ -213,7 +216,9 @@ function pickListFields(
|
|
|
213
216
|
entity: entityName,
|
|
214
217
|
field: name,
|
|
215
218
|
cause: 'listed in listFields but not a column of the entity',
|
|
216
|
-
|
|
219
|
+
// `x db gen` and NOT `x g migration`: there is no `migration` generator, and the fix
|
|
220
|
+
// line an operator pastes has to be a command the CLI actually dispatches.
|
|
221
|
+
fix: `remove "${name}" from listFields, or add the column with x db gen "add ${name} to ${entityName}"`,
|
|
217
222
|
});
|
|
218
223
|
}
|
|
219
224
|
return field;
|
|
@@ -261,7 +266,7 @@ export function adminResource<Row extends AdminRow = AdminRow>(
|
|
|
261
266
|
|
|
262
267
|
const resource: AdminResource<Row> = {
|
|
263
268
|
name: entity.$name,
|
|
264
|
-
path: opts.path ?? `/${
|
|
269
|
+
path: opts.path ?? `/${entity.$name}`,
|
|
265
270
|
titleKey: opts.titleKey ?? `admin.${entity.$name}.title`,
|
|
266
271
|
group: opts.group ?? 'admin.group.data',
|
|
267
272
|
idField,
|