@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.
@@ -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
- readonly drift: readonly DriftFact[];
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
- const [tables, drift] = await Promise.all([sources.tables(), sources.drift()]);
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 };
@@ -1,6 +1,6 @@
1
1
  // Panel: Routes.
2
- // Kills: "which handler serves this?" — the route table with render mode, offline strategy,
3
- // budget, and whether the route declares meta.
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
- readonly missingMeta: readonly string[];
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
- const fields = (error ?? {}) as ErrorFields;
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: typeof fields.code === 'string' ? fields.code : 'X_NOT_IMPLEMENTED',
52
- cause:
53
- typeof fields.cause === 'string'
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: 'number',
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
- export function searchable(type: AdminFieldType): boolean {
150
- return type === 'text' || type === 'textarea';
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
@@ -87,6 +87,7 @@ export {
87
87
  export {
88
88
  ADMIN_ERROR_CODES,
89
89
  ADMIN_ERROR_TITLES,
90
+ AdminActionDuplicateError,
90
91
  AdminEntityUnknownError,
91
92
  type AdminErrorCode,
92
93
  type AdminErrorParts,
@@ -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 { agentActor } from '@ultimat3/core';
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 ctx = opts.app.ctx({ actor: adminActorOf(caller), requestId: requestId() });
274
- const result = await callAdminTool(opts.app, ctx, tool.name, args);
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.
@@ -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
- userActor({ id: actor.id, roles: actor.roles ?? [] });
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
- /** Mount-relative, e.g. `/posts`. */
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
- fix: `remove "${name}" from listFields, or add the column with x g migration`,
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 ?? `/${pluralize(entity.$name)}`,
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,