@ultimat3/admin 30.2.0 → 30.4.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/README.md CHANGED
@@ -482,7 +482,13 @@ The admin exposes **its own MCP surface**, derived from the same resources and g
482
482
  ```ts
483
483
  import { adminMcp, adminMcpTools } from '@ultimat3/admin';
484
484
 
485
- export const mcp = adminMcp({ app: admin, actor: (session) => actorFor(session.token) });
485
+ // apps/admin/mcp.ts — module scope: the mount serves every apps/<app>/mcp.ts, after the scan
486
+ export const mcp = adminMcp({
487
+ app: admin,
488
+ path: '/admin/mcp', // beside the web endpoint's /mcp
489
+ include: 'exposed', // the app's exposed actions (admin:write) and queries (admin:read) too
490
+ actor: ({ token, facts }) => actorFor(token, facts),
491
+ });
486
492
  adminMcpTools(admin, ctx); // admin.post.list · admin.post.read · admin.search · admin.action.post.publish
487
493
  ```
488
494
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ultimat3/admin",
3
- "version": "30.2.0",
3
+ "version": "30.4.0",
4
4
  "description": "Two dashboards: the /_x framework dev panels and the generated, AI-first app admin",
5
5
  "keywords": [
6
6
  "ultimate",
@@ -45,21 +45,21 @@
45
45
  "test": "bun test"
46
46
  },
47
47
  "dependencies": {
48
- "@ultimat3/action": "30.2.0",
49
- "@ultimat3/ai": "30.2.0",
50
- "@ultimat3/cache": "30.2.0",
51
- "@ultimat3/core": "30.2.0",
52
- "@ultimat3/db": "30.2.0",
53
- "@ultimat3/entity": "30.2.0",
54
- "@ultimat3/http": "30.2.0",
55
- "@ultimat3/i18n": "30.2.0",
56
- "@ultimat3/jobs": "30.2.0",
57
- "@ultimat3/mcp": "30.2.0",
58
- "@ultimat3/money": "30.2.0",
59
- "@ultimat3/policy": "30.2.0",
60
- "@ultimat3/query": "30.2.0",
61
- "@ultimat3/render": "30.2.0",
62
- "@ultimat3/schema": "30.2.0",
63
- "@ultimat3/ui": "30.2.0"
48
+ "@ultimat3/action": "30.4.0",
49
+ "@ultimat3/ai": "30.4.0",
50
+ "@ultimat3/cache": "30.4.0",
51
+ "@ultimat3/core": "30.4.0",
52
+ "@ultimat3/db": "30.4.0",
53
+ "@ultimat3/entity": "30.4.0",
54
+ "@ultimat3/http": "30.4.0",
55
+ "@ultimat3/i18n": "30.4.0",
56
+ "@ultimat3/jobs": "30.4.0",
57
+ "@ultimat3/mcp": "30.4.0",
58
+ "@ultimat3/money": "30.4.0",
59
+ "@ultimat3/policy": "30.4.0",
60
+ "@ultimat3/query": "30.4.0",
61
+ "@ultimat3/render": "30.4.0",
62
+ "@ultimat3/schema": "30.4.0",
63
+ "@ultimat3/ui": "30.4.0"
64
64
  }
65
65
  }
package/src/errors.ts CHANGED
@@ -30,6 +30,7 @@ export const ADMIN_OWNED_ERROR_CODES = [
30
30
  'X_ADMIN_ACTION_NOT_APPLICABLE',
31
31
  'X_ADMIN_MOUNT_MISSING',
32
32
  'X_ADMIN_VIEW_WRITE',
33
+ 'X_ADMIN_READ_ONLY_WRITE',
33
34
  ] as const;
34
35
 
35
36
  /**
@@ -64,6 +65,7 @@ export const ADMIN_ERROR_TITLES: Readonly<Record<AdminOwnedErrorCode, string>> =
64
65
  X_ADMIN_ACTION_NOT_APPLICABLE: 'an admin action was run on a row its when() rule excludes',
65
66
  X_ADMIN_MOUNT_MISSING: 'an admin batch job ran in a process that never declared its admin',
66
67
  X_ADMIN_VIEW_WRITE: 'an admin resource over a view entity offers a write',
68
+ X_ADMIN_READ_ONLY_WRITE: 'an admin resource over a readOnly entity offers a write',
67
69
  };
68
70
 
69
71
  // One unconditional call, so a second package claiming one of admin's codes throws
package/src/mcp-scopes.ts CHANGED
@@ -2,10 +2,10 @@
2
2
  // what a resolved token carries. The gate itself is `@ultimat3/mcp`'s registry — this file only
3
3
  // declares the cut (`defineAppMcp({ scopes })`) and the token's half of it.
4
4
 
5
- import type { McpScopes } from '@ultimat3/mcp';
5
+ import type { McpScopes, ProjectablePrimitive } from '@ultimat3/mcp';
6
6
  import { expandPermissions } from './authz';
7
7
  import type { AdminMcpTool } from './mcp-tools';
8
- import { ADMIN_DESTROY, ADMIN_PERMISSIONS } from './permissions';
8
+ import { ADMIN_DESTROY, ADMIN_PERMISSIONS, ADMIN_READ, ADMIN_WRITE } from './permissions';
9
9
 
10
10
  const isAdminPermission = (scope: string): boolean =>
11
11
  (ADMIN_PERMISSIONS as readonly string[]).includes(scope);
@@ -34,3 +34,21 @@ export function adminToolScopes(tools: readonly AdminMcpTool[]): McpScopes {
34
34
  for (const tool of tools) by.set(tool.scope, [...(by.get(tool.scope) ?? []), tool.name]);
35
35
  return Object.fromEntries(by);
36
36
  }
37
+
38
+ /**
39
+ * The app's exposed primitives an admin endpoint serves (`adminMcp({ include: 'exposed' })`),
40
+ * each under the admin scope its KIND needs — a query reads, an action writes; the framework
41
+ * cannot tell an insert from a delete, so never `admin:destroy`. Merged into the admin's own cut,
42
+ * so the map stays total and a read-only token stays read-only on the app's tools too.
43
+ */
44
+ export function includedToolScopes(
45
+ primitives: readonly ProjectablePrimitive[],
46
+ scopes: McpScopes,
47
+ ): McpScopes {
48
+ const by = new Map(Object.entries(scopes).map(([scope, names]) => [scope, [...names]]));
49
+ for (const primitive of primitives) {
50
+ const scope = primitive.mutates === false ? ADMIN_READ : ADMIN_WRITE;
51
+ by.set(scope, [...(by.get(scope) ?? []), primitive.name]);
52
+ }
53
+ return Object.fromEntries(by);
54
+ }
package/src/mcp.ts CHANGED
@@ -8,9 +8,11 @@ import {
8
8
  type AnyMcpTool,
9
9
  type AppMcp,
10
10
  defineAppMcp,
11
+ exposedPrimitives,
11
12
  type JsonSchema,
12
13
  jsonResult,
13
14
  type McpCaller,
15
+ type McpRequestFacts,
14
16
  type McpToolResult,
15
17
  type ResolvedToken,
16
18
  type ToolArgs,
@@ -33,7 +35,7 @@ import {
33
35
  import { AdminFilterInvalidError } from './errors';
34
36
  import type { AdminFieldType } from './fields';
35
37
  import { type AskedFilter, checkedFilter } from './list-filters';
36
- import { adminTokenScopes, adminToolScopes } from './mcp-scopes';
38
+ import { adminTokenScopes, adminToolScopes, includedToolScopes } from './mcp-scopes';
37
39
  import { type AdminMcpTool, adminMcpTools, adminToolCatalog } from './mcp-tools';
38
40
  import type { AdminAction, AdminRow } from './registry';
39
41
  import { adminSearch } from './search';
@@ -250,9 +252,23 @@ export interface AdminMcpActor extends AdminActor {
250
252
 
251
253
  export interface AdminMcpOptions {
252
254
  readonly app: AdminApp;
253
- /** Resolve the MCP session's actor. The same hook the HTTP surface uses, never a bypass. */
254
- actor(session: { readonly token?: string }): Promise<AdminMcpActor | null> | AdminMcpActor | null;
255
+ /**
256
+ * Resolve the MCP session's actor. The same hook the HTTP surface uses, never a bypass. `facts`:
257
+ * how the request arrived (address, `Origin`) — what a development token is decided on.
258
+ */
259
+ actor(session: {
260
+ readonly token?: string;
261
+ readonly facts?: McpRequestFacts;
262
+ }): Promise<AdminMcpActor | null> | AdminMcpActor | null;
255
263
  readonly requestId?: () => string;
264
+ /** Mount path, `defineAppMcp`'s own. Default `/mcp`; an admin surface beside the app's says `/admin/mcp`. */
265
+ readonly path?: string;
266
+ /**
267
+ * `'exposed'`: the app's own MCP tools here too, projected by `defineAppMcp` as on `/mcp`, each
268
+ * under its kind's admin scope (`includedToolScopes`). Call `adminMcp` at module scope in
269
+ * `apps/<app>/mcp.ts`: the mount refuses a catalog built before the scan ended (`X_MCP_INCLUDE_EARLY`).
270
+ */
271
+ readonly include?: 'exposed';
256
272
  }
257
273
 
258
274
  /** A field type an agent can actually send. Anything richer is a JSON object on the wire. */
@@ -436,15 +452,20 @@ export function adminMcp(opts: AdminMcpOptions): AppMcp {
436
452
  const requestId = opts.requestId ?? ((): string => crypto.randomUUID());
437
453
  const catalog = adminToolCatalog(opts.app);
438
454
 
455
+ const own = adminToolScopes(catalog);
456
+
439
457
  return defineAppMcp({
440
458
  name: 'admin',
441
459
  tools: catalog.map((tool) => toMcpTool(opts, requestId, tool)),
460
+ ...(opts.path === undefined ? {} : { path: opts.path }),
461
+ // The app's tools are `defineAppMcp`'s projection, never a copy; the scopes read the same registry.
462
+ ...(opts.include === undefined ? {} : { include: opts.include }),
442
463
  // The connection gate: each tool needs its admin-level permission as a token scope, checked
443
464
  // by the registry BEFORE the actor's policy. A stdio host builds its caller's scopes with
444
465
  // `adminTokenScopes`, as `resolveToken` does below.
445
- scopes: adminToolScopes(catalog),
446
- async resolveToken(token: string): Promise<ResolvedToken | null> {
447
- const actor = await opts.actor({ token });
466
+ scopes: opts.include === 'exposed' ? includedToolScopes(exposedPrimitives(), own) : own,
467
+ async resolveToken(token: string, facts: McpRequestFacts): Promise<ResolvedToken | null> {
468
+ const actor = await opts.actor({ token, facts });
448
469
  // `kind: 'agent'` — the same actor shape an agent gets everywhere else, so a policy
449
470
  // that distinguishes agents from people keeps working on this surface.
450
471
  //
package/src/registry.ts CHANGED
@@ -86,6 +86,8 @@ export interface AdminEntity {
86
86
  * hand-built fixture has none; a view offers reads alone (`resource-operations.ts`).
87
87
  */
88
88
  readonly $viewQuery?: { readonly materialized: boolean } | null;
89
+ /** `entity({ readOnly })`'s reason, `null` on a table the app writes: reads alone, as a view. */
90
+ readonly $readOnly?: string | null;
89
91
  /** Resolves foreign-key targets. See `entity-columns.ts` for what the admin takes from it. */
90
92
  $describe(): AdminEntityDescription;
91
93
  }
@@ -1,7 +1,7 @@
1
1
  // Single responsibility: which operations an admin resource offers. Every one, by default, on a
2
- // table; only the reads on a view entity (`entity({ view })`), whose rows Postgres derives — so a
3
- // view gets no create/edit/delete route, button or MCP tool, and an override asking for one is
4
- // refused where it was declared.
2
+ // table; only the reads on a view entity (`entity({ view })`), whose rows Postgres derives, and on a
3
+ // `readOnly` one — so neither gets a create/edit/delete route, button or MCP tool, and an override
4
+ // asking for one is refused where it was declared.
5
5
 
6
6
  import { renderFixShellArg, UltimateError } from '@ultimat3/core';
7
7
  import { ADMIN_OPERATIONS, type AdminOperation } from './permissions';
@@ -23,14 +23,34 @@ export class AdminViewWriteError extends UltimateError {
23
23
  }
24
24
  }
25
25
 
26
+ /** An admin resource over a `readOnly` entity was declared with a write its repository refuses. */
27
+ export class AdminReadOnlyWriteError extends UltimateError {
28
+ constructor(input: {
29
+ readonly entity: string;
30
+ readonly reason: string;
31
+ readonly writes: readonly AdminOperation[];
32
+ }) {
33
+ super({
34
+ code: 'X_ADMIN_READ_ONLY_WRITE',
35
+ cause: `${input.entity} is readOnly (${input.reason}), and its admin resource offers ${input.writes.join(', ')} — every one would be refused with X_ENTITY_READ_ONLY`,
36
+ fix: `x entities show ${renderFixShellArg(input.entity, '<entity>')} --json # then drop operations from its admin resource (a readOnly table lists read-only), or name only ${VIEW_OPERATIONS.join(', ')}`,
37
+ });
38
+ }
39
+ }
40
+
26
41
  export function operationsFor(
27
42
  entity: AdminEntity,
28
43
  declared: readonly AdminOperation[] | undefined,
29
44
  ): readonly AdminOperation[] {
30
45
  const isView = entity.$viewQuery !== undefined && entity.$viewQuery !== null;
31
- if (!isView) return declared ?? ADMIN_OPERATIONS;
46
+ const reason = entity.$readOnly ?? null;
47
+ if (!isView && reason === null) return declared ?? ADMIN_OPERATIONS;
32
48
  if (declared === undefined) return VIEW_OPERATIONS;
33
49
  const writes = declared.filter((operation) => !READS.has(operation));
34
- if (writes.length > 0) throw new AdminViewWriteError({ entity: entity.$name, writes });
50
+ if (writes.length > 0) {
51
+ throw isView
52
+ ? new AdminViewWriteError({ entity: entity.$name, writes })
53
+ : new AdminReadOnlyWriteError({ entity: entity.$name, reason: reason ?? '', writes });
54
+ }
35
55
  return declared;
36
56
  }