@ultimat3/admin 3.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 CHANGED
@@ -7,8 +7,13 @@ Two products, one package, **two entry points**: `@ultimat3/admin/dev` (`src/dev
7
7
  ## Rules
8
8
 
9
9
  - **One authz.** `AdminAuthz` (`authz.ts`) is the only decision path. `action-gate.ts`, `crud.ts` and `page-guard.tsx` call `decideAll`; views render what the gate returned. Never add a second check in a view or an MCP handler.
10
+ - **The subject carries the tenant and the loaded row.** `AdminActor.orgId` reaches `policyActor` and `AdminSubject.row` reaches `evaluate`, so an org-scoped or ownership rule can fire at all. It could not: every admin decision was evaluated with `actor.orgId === undefined` and `row === null`, so a role-only rule allowed and the coarse `admin:read` + `<entity>:read` pair was the only gate on a row — while `adminList`/`adminSearch` add no tenant predicate of their own. `adminDetail`, `adminUpdate` and `adminDestroy` load the row BEFORE the guard, the shape `packages/action/src/invoke.ts` uses; `undefined` means "not loaded" and is left off the subject, `null` means "looked and found none" and fails a row rule closed. Pinned in `policy-subject.test.ts`, which asserts the SUBJECT a policy receives — `staticAuthz` is a grant-list stub and no suite driving it can see any of this.
11
+ - **The MCP caller is the ambient actor for the whole call.** `mcp.ts` wraps `callAdminTool` in a child context (or a fresh root one over stdio, where there is no surrounding request), the same rule `@ultimat3/mcp`'s `app-tool.ts` states. `AdminApp.ctx()` builds a plain `CrudCtx` and touches nothing async-local, so everything deriving from `tryUseContext()` — entity's tenant guard, the query cache authority, the jit-preload store — read the TRANSPORT's actor: an agent token authorized as agent X while the repo reads ran as the cookie user's tenant, and over stdio `actorTenant` was `undefined` and `assertRowTenant` a no-op. Pinned in `mcp-context.test.ts`.
10
12
  - **A custom page's guard is composed, never written.** `pages.ts` turns a `pages:` entry into an `AdminRoute` carrying `[admin:read, …declared]`; `routes.ts` is the only thing that may hand a page component to a router and it hands the `guardedPage()` wrapper, with `permissions[0]` already in `defineRoute({ policy })`. `AdminPageProps.ctx` is required by the type so the wrapper cannot be bypassed by calling the component directly. An empty permission list is `X_ADMIN_PAGE_UNGUARDED` at `defineAdmin` time — the one place an unauthenticated admin screen could have been born.
13
+ - **One `AdminAction.name`, one handler.** The name is the MCP tool name (`admin.action.<name>`), the default label key, AND the key `callAdminTool` resolves a handler by — three addresses, one string. Two actions sharing it is `X_ADMIN_ACTION_DUPLICATE` at `defineAdmin` time (`admin.ts`, `mcp.test.ts`), not at the first agent call: `.find()` on a name dispatches to whichever resource came first, which is a call that SUCCEEDS against the wrong action and reports nothing. Refused at declaration rather than in `adminMcp()` because an app that renders the dashboard and never wires MCP has the same two broken label keys and the same ambiguous dispatch. The framework's own examples already qualify the name with the entity (`post.publish`) — the `fix:` line is that convention, made into the instruction. The same object attached through both `actions:` and `resources[e].actions` is one action, not two: identity, not name, is what "already seen" means.
11
14
  - **A host that serves an admin URL itself reads its gate, never restates it.** `adminRouteFor(app, path)` (`routes.ts`) is that read, and `AdminRouteConfig.policy` is what it hands over — the same object `config.policy` carries, non-optional so a caller need not prove it exists. Typing `policy: { permission: 'admin:read' }` into a page file beside a route table that already declares one for that URL is two declarations of one URL's authz; they agree until somebody edits one. The deployed demo shipped exactly that on five pages until 1.2.0, and its `apps/admin/app/admin/route-policy.test.ts` is the rule made executable: a quoted permission anywhere under `app/admin/**/page.tsx` fails the suite. An undeclared path is `X_ADMIN_PAGE_PATH_INVALID`, listing the paths that would have worked.
15
+ - **A `/_x` source reads a registry through its DESCRIPTOR TYPE, never as an untyped bag.** `import type { RouteDescriptor }` beside the dynamic `import()` — the type is erased, so /_x still costs the production graph nothing, and a renamed descriptor field becomes a typecheck failure in `dev/data.ts` naming the field. The bag was defended here as tolerance and bought three panels that were wrong for every row and could not go red: `route['render']`, `route['budget']`, `route['revalidate']`, `job['idempotencyKey']` and `entity['drift']` are names no descriptor has ever published, so a read answered `undefined`, took the fallback, and shipped the fallback as a fact — every route `stream` with no budget and no tag, every job non-idempotent, every schema drift-free. `dev/published-keys.test.ts` is the runtime half a type cannot answer: it walks what the real registries EMIT, so a field the type declares and the projection never writes is a red test.
16
+ - **A fact `/_x` cannot know is absent or `null` — never a value that happens to be constant.** `hasMeta` was dropped because `defineRoute()` refuses a route with no `meta`, so `missingMeta` was a defect list with no member it could hold; `JobDescriptor.idempotent` is published *because* `job()` refuses a definition without a key — a guarantee shown where the question is asked. `DbPanelData.drift` is `null` when nothing wired the check, because drift is the entities against the DATABASE and `[]` claims a match nobody verified.
12
17
  - **One bridge per foreign package.** `policy-bridge.ts` is the only file calling `evaluate`/`definePermissions`; `routes.ts` the only one calling `defineRoute`; `mcp.ts` the only one calling `defineAppMcp`; `dev/data.ts` the only one importing introspection (dynamically — `/_x` must stay out of the production graph). Source only: a **test** declares the permissions its own `can()` fixtures use, because `definePermissions` writes a process-global registry and `bun test` seats several files in one process — `dev/data.test.ts` relied on the empty-registry-means-permissive fallback and went red the first time it shared a process with anything that imports this package.
13
18
  - **One entity surface** (`registry.ts`) — the admin reads what `entity()` actually exposes: `$name`, `$primaryKey`, `$columns[c].$meta`, `$schema`, `$describe()`. It is a structural subset so a new column kind still derives, and `RegisteredEntity` is the `tsc`-checked proof that a real `entity()` result satisfies it — never a comment claiming it does.
14
19
  - **One flattener.** `entity-columns.ts` is the only file that reads `$meta` or calls `$describe()`; everything downstream takes `AdminColumnFacts`. Money stays one property (the admin renders rows, not tables), a FK target comes back resolved from `$describe()`, and only a **generated** default (`uuid`, `now`) is read-only — `.default('free')` is a starting value.
@@ -24,7 +29,7 @@ Two products, one package, **two entry points**: `@ultimat3/admin/dev` (`src/dev
24
29
  - **A dev panel catches only the error that means "not wired".** `DevSourceUnavailableError` and nothing wider: a bare `catch` in `dev/panel-live.ts` reported an authz refusal and a dropped NATS connection from a *running* sync node as `dev.live.no-sync-node`, telling the reader to install a tier they already had. Everything else reaches `panelPayload`, which renders its code and its fix.
25
30
  - **What an entity does not declare, the admin does not invent.** `sensitive`, a fixed `currency` and `labelField` come from `AdminResourceOptions` or they are absent. Same rule for a URL: the reference widget links through `WidgetContext.hrefFor` or renders plain text — it used to build `/${entity}s/${id}`, which is English pluralisation by concatenation and drops `basePath`. The route table is `AdminApp`'s; a widget three layers down does not get to guess it.
26
31
  - **One read-only SQL guard, and it is `@ultimat3/mcp`'s — the whole verdict, with nothing held back.** `dev/panel-db.ts` calls `assertReadOnlyQuery` (tier 5 → tier 4, already a dependency) rather than keeping a second keyword scan: that guard also refuses a batch, a call into the `pg_read_*`/`pg_advisory_*`/`pg_sleep`/`set_config` families, `FOR UPDATE`, and a delimiter that never closes in all five forms (`'`, `E'`, `"`, `$tag$`, slash-star). A local unterminated-delimiter refusal lived here for one revision and was **deleted**: it tested for a surviving `'`/`"`, so it covered three of the five and called a dollar-quoted body "a quote" — one failure mode, two explanations, and a second detector for a property the guard below already tests. What stays local is the emptiness test and the **way out**: `@ultimat3/mcp` tells its caller to expose an action, which a developer at `/_x` cannot act on. The panel says "Fix the statement, or — if it is meant to write — run it with `x db psql --write`", conditional on purpose: `--write` grants writes, it does not close a delimiter, and it used to be printed as *the* fix for a syntax error.
27
- - **Every admin operation is audited, reads included.** `adminList` was the one call that logged nothing in either direction; `ListResult` now carries its `AuditEntry` on both branches, keyed on the table (`entityId: null`), because the subject of a listing is not a row.
32
+ - **Every admin operation is audited, reads included.** `adminList` was the one call that logged nothing in either direction; `ListResult` now carries its `AuditEntry` on both branches, keyed on the table (`entityId: null`), because the subject of a listing is not a row. `adminSearch` was the second, and it read rows out of EVERY readable entity: `AdminSearchResult.audit` now carries one entry per resource it decided about — `allowed` per searched resource, a `deniedDraft` per refused one. A resource skipped for having no text field or no repo is not an authz event and writes none.
28
33
  - Money through `assertMoney`, timestamps through `assertZone`, pagination through `pagination.ts`. No `offset`, ever.
29
34
  - **One cursor codec.** `pagination.ts` is the only file calling core's `encodeCursor`/`decodeCursor`; it wraps them as `encodeAdminCursor`/`decodeAdminCursor`, scoped `admin:<resource>`. An invalid cursor is page one here, not an error page — but the signature is checked first, so a forged one cannot seek.
30
35
  - Labels are i18n keys derived in `resource.ts` (`admin.<entity>.field.<name>`); only `.tsx` calls `t()`. MCP tool descriptions are literal English (protocol payload, not UI copy).
package/README.md CHANGED
@@ -17,11 +17,11 @@ One panel per file. Each kills one question, and each is available as `--json`
17
17
 
18
18
  | Panel | Kills |
19
19
  |---|---|
20
- | `routes` | which handler serves this? — render mode, offline strategy, budget, meta |
20
+ | `routes` | which handler serves this? — render mode, offline strategy, revalidate tags, budget |
21
21
  | `timeline` | where did the time go? — flamegraph of SQL, cache, action, policy spans + the N+1 count |
22
22
  | `live` | what does each subscriber receive, and **why** — the matcher's decision trace |
23
23
  | `jobs` | queue depth, step traces, retry-from-step target, dead letter |
24
- | `db` | psql in a tab (read-only; `assertReadOnly` refuses DML), schema diff vs migrations |
24
+ | `db` | psql in a tab (read-only; `assertReadOnly` refuses DML), schema + drift (`null` unless a host wires the check) |
25
25
  | `mail` | caught mail, rendered, per locale, with the locale gaps listed |
26
26
  | `cache` | the tag graph — what invalidated what, and which tags are orphans |
27
27
  | `policy` | the permission matrix per actor, every cell carrying its trace |
@@ -201,7 +201,10 @@ shape already is, written down once so two apps do not invent two layouts. `x g
201
201
  | Every mutation and every denial is on the audit log, with a before/after diff | `audit.ts` |
202
202
  | Branding aliases tokens only — `accent: '#7c3aed'` is a compile error | `theme.ts` |
203
203
 
204
- Single-row reads are audited; list pages are not (volume).
204
+ Reads are audited too, and on both branches: `adminDetail` keys its entry on the row,
205
+ `adminList` and `adminSearch` key theirs on the table (`entityId: null`), and a refusal is an
206
+ entry of its own. `AdminSearchResult.audit` carries one entry per resource the call decided
207
+ about — searched or refused — so a jump box that walked every readable entity leaves a trace.
205
208
 
206
209
  ## AI-first
207
210
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ultimat3/admin",
3
- "version": "3.0.0",
3
+ "version": "4.0.0",
4
4
  "description": "Two dashboards: the /_x framework dev panels and the generated, AI-first app admin",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -32,20 +32,20 @@
32
32
  "test": "bun test"
33
33
  },
34
34
  "dependencies": {
35
- "@ultimat3/action": "3.0.0",
36
- "@ultimat3/ai": "3.0.0",
37
- "@ultimat3/cache": "3.0.0",
38
- "@ultimat3/core": "3.0.0",
39
- "@ultimat3/db": "3.0.0",
40
- "@ultimat3/entity": "3.0.0",
41
- "@ultimat3/i18n": "3.0.0",
42
- "@ultimat3/jobs": "3.0.0",
43
- "@ultimat3/mcp": "3.0.0",
44
- "@ultimat3/money": "3.0.0",
45
- "@ultimat3/policy": "3.0.0",
46
- "@ultimat3/query": "3.0.0",
47
- "@ultimat3/render": "3.0.0",
48
- "@ultimat3/schema": "3.0.0",
49
- "@ultimat3/ui": "3.0.0"
35
+ "@ultimat3/action": "4.0.0",
36
+ "@ultimat3/ai": "4.0.0",
37
+ "@ultimat3/cache": "4.0.0",
38
+ "@ultimat3/core": "4.0.0",
39
+ "@ultimat3/db": "4.0.0",
40
+ "@ultimat3/entity": "4.0.0",
41
+ "@ultimat3/i18n": "4.0.0",
42
+ "@ultimat3/jobs": "4.0.0",
43
+ "@ultimat3/mcp": "4.0.0",
44
+ "@ultimat3/money": "4.0.0",
45
+ "@ultimat3/policy": "4.0.0",
46
+ "@ultimat3/query": "4.0.0",
47
+ "@ultimat3/render": "4.0.0",
48
+ "@ultimat3/schema": "4.0.0",
49
+ "@ultimat3/ui": "4.0.0"
50
50
  }
51
51
  }
package/src/admin.ts CHANGED
@@ -6,6 +6,7 @@ import { type AuditLog, memoryAuditLog } from './audit';
6
6
  import type { AdminActor, AdminAuthz } from './authz';
7
7
  import type { CrudCtx } from './crud';
8
8
  import { permissionsForOperation } from './crud';
9
+ import { AdminActionDuplicateError } from './errors';
9
10
  import { adminNav, type NavGroup, type NavItem, type NavOptions, visibleNav } from './nav';
10
11
  import { type AdminCustomPage, type AdminPageComponent, pageNavItems, pageRoutes } from './pages';
11
12
  import type { AdminOperation } from './permissions';
@@ -124,6 +125,37 @@ function resourceRoutes(basePath: string, resource: AdminResource): readonly Adm
124
125
  return routes;
125
126
  }
126
127
 
128
+ /**
129
+ * One `AdminAction.name`, one handler. The name is the MCP tool name (`admin.action.<name>`), the
130
+ * default label key (`admin.action.<name>`) AND the key `callAdminTool` resolves a handler by, so
131
+ * two actions sharing it dispatch to whichever `.find()` reached first — a call that succeeds
132
+ * against the wrong action and reports nothing. Refused here rather than in `adminMcp()`, because
133
+ * an app that renders the dashboard and never wires MCP has the same two broken label keys and
134
+ * the same ambiguous dispatch.
135
+ *
136
+ * Identity, not name, is what "already seen" means: `defineAdmin` attaches `input.actions` to the
137
+ * entity each one names AND appends `resources[<entity>].actions`, so an author who spelled the
138
+ * same object in both meant one action, not two.
139
+ */
140
+ function assertUniqueActionNames(
141
+ resources: readonly AdminResource[],
142
+ declared: readonly AdminAction[],
143
+ ): void {
144
+ const seen = new Map<string, { readonly action: AdminAction; readonly entities: string[] }>();
145
+ // `declared` is every action the caller passed, not just the global ones: an action naming an
146
+ // entity that is not in `entities` reaches neither list, and its name still has to be unique.
147
+ for (const action of [...resources.flatMap((resource) => resource.actions), ...declared]) {
148
+ const found = seen.get(action.name);
149
+ if (found === undefined) {
150
+ seen.set(action.name, { action, entities: [action.entity ?? 'the global toolbar'] });
151
+ continue;
152
+ }
153
+ if (found.action === action) continue;
154
+ found.entities.push(action.entity ?? 'the global toolbar');
155
+ throw new AdminActionDuplicateError({ name: action.name, entities: found.entities });
156
+ }
157
+ }
158
+
127
159
  /** Derive the whole admin from the registries. */
128
160
  export function defineAdmin(input: DefineAdminInput): AdminApp {
129
161
  const basePath = input.basePath ?? '/admin';
@@ -141,6 +173,8 @@ export function defineAdmin(input: DefineAdminInput): AdminApp {
141
173
  });
142
174
  });
143
175
 
176
+ assertUniqueActionNames(resources, actions);
177
+
144
178
  const pages = input.pages ?? [];
145
179
  const navOptions = input.nav ?? {};
146
180
  const extra: readonly (NavItem & { readonly group: string })[] = [
package/src/authz.ts CHANGED
@@ -11,6 +11,14 @@ import { ADMIN_PERMISSION_SPEC, type AdminPermission } from './permissions';
11
11
 
12
12
  export interface AdminActor {
13
13
  readonly id: string;
14
+ /**
15
+ * The tenant this operator acts under. NOT optional in practice on a multi-tenant app: the
16
+ * admin's subject reached `policyAuthz` with `orgId: undefined`, so an org-scoped rule could
17
+ * not fire and a role-only rule allowed — while `adminList`/`adminSearch` add no tenant
18
+ * predicate of their own. Absent still means "single-tenant app", which is why the type keeps
19
+ * it optional; a rule that reads it must fail closed on `undefined` like any other policy.
20
+ */
21
+ readonly orgId?: string;
14
22
  readonly roles?: readonly string[];
15
23
  /** BCP-47. Drives every `t()` call and every `Intl` format in the admin. */
16
24
  readonly locale?: string;
@@ -23,6 +31,16 @@ export interface AdminSubject {
23
31
  readonly entity?: string;
24
32
  readonly id?: string;
25
33
  readonly input?: unknown;
34
+ /**
35
+ * The already-loaded row, for a row-level rule. `null` means "no row was loaded", which the
36
+ * policy layer treats as no evidence of permission — same contract as an `action`'s `row:`
37
+ * loader. Absent entirely means the surface has no row to load (a list page, a create form).
38
+ *
39
+ * Without it every admin decision evaluated with `row: null` and `input = { entity, id }`, so
40
+ * an ownership rule could not fire at all and the coarse `admin:read` + `<entity>:read` pair
41
+ * was the only gate on a single row.
42
+ */
43
+ readonly row?: unknown;
26
44
  }
27
45
 
28
46
  export interface AdminDecision {
package/src/crud.ts CHANGED
@@ -59,10 +59,20 @@ export function decideOperation(
59
59
  op: AdminOperation,
60
60
  ctx: CrudCtx,
61
61
  id?: string,
62
+ /**
63
+ * The row the surface ALREADY loaded, or `null` for "looked and found none". `undefined` means
64
+ * not loaded at all — a list page, a create form, a nav button — and is left off the subject
65
+ * entirely, because "there is no row here" and "there is a row and nobody loaded it" are
66
+ * different facts and only the second is a bug. Every admin decision used to be evaluated
67
+ * without one, so an ownership rule could not fire and the coarse `admin:read` + `<entity>:read`
68
+ * pair was the only gate on a single row.
69
+ */
70
+ row?: AdminRow | null,
62
71
  ): AdminDecision {
63
72
  return decideAll(ctx.authz, permissionsForOperation(resource.name, op), ctx.actor, {
64
73
  entity: resource.name,
65
74
  ...(id === undefined ? {} : { id }),
75
+ ...(row === undefined ? {} : { row }),
66
76
  });
67
77
  }
68
78
 
@@ -140,9 +150,12 @@ export async function adminDetail<Row extends AdminRow>(
140
150
  ctx: CrudCtx,
141
151
  id: string,
142
152
  ): Promise<CrudResult<Row>> {
143
- const decision = decideOperation(resource, 'detail', ctx, id);
144
- if (!decision.allowed) return refuse(resource, 'detail', ctx, decision, id);
153
+ // The row is loaded BEFORE the guard, the shape `packages/action/src/invoke.ts` uses for a
154
+ // row-level `policy`: a rule that decides about a row cannot decide without one, and the
155
+ // predicate has to stay synchronous. A denial still returns no row.
145
156
  const row = await repoOf(resource).find(id);
157
+ const decision = decideOperation(resource, 'detail', ctx, id, row);
158
+ if (!decision.allowed) return refuse(resource, 'detail', ctx, decision, id);
146
159
  return {
147
160
  ok: true,
148
161
  row,
@@ -196,11 +209,13 @@ export async function adminUpdate<Row extends AdminRow>(
196
209
  id: string,
197
210
  patch: Readonly<Record<string, unknown>>,
198
211
  ): Promise<CrudResult<Row>> {
199
- const decision = decideOperation(resource, 'update', ctx, id);
200
- if (!decision.allowed) return refuse(resource, 'update', ctx, decision, id);
201
-
212
+ // `before` was already loaded here, just after the guard rather than before it — so the rule
213
+ // that decides whether this actor may touch THIS row never saw the row.
202
214
  const repo = repoOf(resource);
203
215
  const before = await repo.find(id);
216
+ const decision = decideOperation(resource, 'update', ctx, id, before);
217
+ if (!decision.allowed) return refuse(resource, 'update', ctx, decision, id);
218
+
204
219
  const parsed = await validateInput(resource.entity.$schema, { ...(before ?? {}), ...patch });
205
220
  if (!parsed.ok) return invalid(resource, 'update', ctx, id, parsed.issues, decision);
206
221
 
@@ -208,9 +223,16 @@ export async function adminUpdate<Row extends AdminRow>(
208
223
  // strip (undeclared, or normalized to a different value) must never reach the repo. Scoped
209
224
  // to the keys actually submitted, so a partial update stays partial rather than rewriting
210
225
  // every field of `before` too.
226
+ // `Object.hasOwn`, not `key in`: `in` walks the prototype chain, so a patch naming `toString`,
227
+ // `constructor` or `__proto__` put an inherited member into the object handed to `repo.update`
228
+ // — the exact thing the paragraph above says cannot happen. Over MCP the transport refuses
229
+ // those keys (`additionalProperties: false`), but `callAdminTool` and `adminUpdate` are both
230
+ // public API and `mcp.ts` keeps its own gate for a direct call and a future transport.
211
231
  const submittedKeys = Object.keys(patch);
212
232
  const validatedPatch: Readonly<Record<string, unknown>> = Object.fromEntries(
213
- submittedKeys.filter((key) => key in parsed.value).map((key) => [key, parsed.value[key]]),
233
+ submittedKeys
234
+ .filter((key) => Object.hasOwn(parsed.value, key))
235
+ .map((key) => [key, parsed.value[key]]),
214
236
  );
215
237
  const after = await repo.update(id, validatedPatch);
216
238
  return {
@@ -238,7 +260,9 @@ export async function adminDestroy<Row extends AdminRow>(
238
260
  id: string,
239
261
  confirmation: string | undefined,
240
262
  ): Promise<CrudResult<Row>> {
241
- const decision = decideOperation(resource, 'delete', ctx, id);
263
+ const repo = repoOf(resource);
264
+ const before = await repo.find(id);
265
+ const decision = decideOperation(resource, 'delete', ctx, id, before);
242
266
  if (!decision.allowed) return refuse(resource, 'delete', ctx, decision, id);
243
267
 
244
268
  const expected = confirmationToken(resource.name, id);
@@ -258,8 +282,6 @@ export async function adminDestroy<Row extends AdminRow>(
258
282
  );
259
283
  }
260
284
 
261
- const repo = repoOf(resource);
262
- const before = await repo.find(id);
263
285
  await repo.destroy(id);
264
286
  return {
265
287
  ok: true,
package/src/detail.tsx CHANGED
@@ -33,6 +33,18 @@ export interface AdminDetailProps<Row extends AdminRow> {
33
33
  readonly onCancel?: () => void;
34
34
  }
35
35
 
36
+ /**
37
+ * The audit row's verb. `operation` holds a CRUD verb for `kind: 'operation'` and the ACTION NAME
38
+ * for `kind: 'action'`, and the catalog declares only `admin.operation.{list,detail,search,create,
39
+ * update,delete,page}` — so an entry for `post.publish` rendered the literal key
40
+ * `admin.operation.post.publish` into the page. `admin.action.<name>` is the same key
41
+ * `action-gate.ts` gives the button that ran it, so the two read identically.
42
+ */
43
+ export const operationLabel = (entry: AuditEntry): string =>
44
+ entry.kind === 'action'
45
+ ? t(`admin.action.${entry.operation}`)
46
+ : t(`admin.operation.${entry.operation}`);
47
+
36
48
  export function AdminDetail<Row extends AdminRow>(props: AdminDetailProps<Row>): JSX.Element {
37
49
  if (props.error !== null) {
38
50
  return <ErrorState error={adminErrorFrom(props.error)} />;
@@ -102,7 +114,7 @@ export function AdminDetail<Row extends AdminRow>(props: AdminDetailProps<Row>):
102
114
  {props.audit.map((entry) => (
103
115
  <li>
104
116
  <code>{entry.at}</code> <span>{entry.actor.id}</span>{' '}
105
- <span>{t(`admin.operation.${entry.operation}`)}</span>{' '}
117
+ <span>{operationLabel(entry)}</span>{' '}
106
118
  <span data-outcome={entry.outcome}>
107
119
  {t(`admin.audit.outcome.${entry.outcome}`)}
108
120
  </span>
package/src/dev/data.ts CHANGED
@@ -63,17 +63,6 @@ export function staticDevSources(facts: Partial<DevSources> = {}): DevSources {
63
63
  };
64
64
  }
65
65
 
66
- type Bag = Readonly<Record<string, unknown>>;
67
-
68
- const bagOf = (value: unknown): Bag =>
69
- typeof value === 'object' && value !== null ? (value as Bag) : {};
70
- const str = (value: unknown, fallback = ''): string =>
71
- typeof value === 'string' ? value : fallback;
72
- const numOf = (value: unknown, fallback = 0): number =>
73
- typeof value === 'number' ? value : fallback;
74
- const listOf = (value: unknown): readonly unknown[] => (Array.isArray(value) ? value : []);
75
- const strings = (value: unknown): readonly string[] => listOf(value).map((item) => String(item));
76
-
77
66
  export interface DevSourceOptions {
78
67
  /** The app's authz. The policy matrix is computed through it, never re-derived. */
79
68
  readonly authz?: AdminAuthz;
@@ -136,9 +125,19 @@ const STEP_STATUS: Readonly<Record<StepStatus, JobStepFact['status']>> = {
136
125
  };
137
126
 
138
127
  /**
139
- * Registry output is read field by field: a registry that grows a field must not break the
140
- * dev dashboard, and a registry that renames one should show a blank cell in /_x rather than
141
- * crash the process an engineer is debugging with.
128
+ * Every registry is read through its OWN descriptor type, not as an untyped bag. The bag was
129
+ * defended here as tolerance — "a renamed field should show a blank cell rather than crash the
130
+ * process an engineer is debugging with" — and what it actually bought was three panels that
131
+ * were wrong for every row and could not go red: `route['render']`, `route['budget']`,
132
+ * `route['revalidate']` and `job['idempotencyKey']` are names no descriptor has ever published,
133
+ * so /_x reported every route as `stream` with no budget and every job as non-idempotent.
134
+ *
135
+ * The packages ship in lockstep at one version, so a renamed descriptor field is a rename this
136
+ * file can be edited with — as a TYPECHECK failure naming the field, which is the tolerance
137
+ * worth having. It costs the production graph nothing: `await import('@ultimat3/render')` is
138
+ * typed by the module it resolves to, so /_x is still reached only through the dynamic import
139
+ * and no descriptor type has to be named here at all. `published-keys.test.ts` is the other
140
+ * half — it walks what the real registries EMIT, which a stale `dist/*.d.ts` would hide.
142
141
  */
143
142
  export function defaultDevSources(opts: DevSourceOptions = {}): DevSources {
144
143
  const hooks = opts.hooks ?? {};
@@ -146,23 +145,26 @@ export function defaultDevSources(opts: DevSourceOptions = {}): DevSources {
146
145
  const sources: DevSources = {
147
146
  async routes(): Promise<readonly RouteFact[]> {
148
147
  const { describeRoutes } = await import('@ultimat3/render');
149
- return listOf(describeRoutes()).map((raw) => {
150
- const route = bagOf(raw);
151
- const budget = bagOf(route['budget']);
152
- return {
153
- path: str(route['path']),
154
- render: str(route['render'], 'stream'),
155
- offline: str(route['offline'], 'network-only'),
156
- hydrate: str(route['hydrate'], 'idle'),
157
- handler: str(route['handler'], str(route['file'])),
158
- budget: {
159
- ...(typeof budget['js'] === 'string' ? { js: budget['js'] } : {}),
160
- ...(typeof budget['lcp'] === 'number' ? { lcp: budget['lcp'] } : {}),
161
- },
162
- revalidateTags: strings(bagOf(route['revalidate'])['tags']),
163
- hasMeta: route['meta'] !== undefined,
164
- };
165
- });
148
+ return describeRoutes().map((route) => ({
149
+ path: route.path,
150
+ // `RouteDescriptor` calls the render mode `mode`; the panel's own word is `render`, and
151
+ // the two are bridged here, once. Reading `route['render']` answered `undefined` for
152
+ // every route, so the fallback shipped — `stream` for the whole table, forever.
153
+ render: route.mode,
154
+ offline: route.offline,
155
+ hydrate: route.hydrate,
156
+ // A descriptor has no `handler`: the FILE is what names the row.
157
+ handler: route.file,
158
+ // Two flat fields on the descriptor, one nested bag on the fact — the panel's budget
159
+ // check reads `budget.js`. Spread, never `js: undefined`: `exactOptionalPropertyTypes`.
160
+ budget: {
161
+ ...(route.budgetJs === null ? {} : { js: route.budgetJs }),
162
+ ...(route.budgetLcp === null ? {} : { lcp: route.budgetLcp }),
163
+ },
164
+ // `revalidateTags`, already flattened to keys — never `revalidate.tags`, a shape the
165
+ // descriptor does not have and which answered `[]` for every ISR route in the app.
166
+ revalidateTags: route.revalidateTags,
167
+ }));
166
168
  },
167
169
 
168
170
  traces: unwired<readonly RequestTrace[]>('traces', 'timeline'),
@@ -186,38 +188,30 @@ export function defaultDevSources(opts: DevSourceOptions = {}): DevSources {
186
188
  entry.sql,
187
189
  ]),
188
190
  );
189
- return listOf(queries).map((raw) => {
190
- const query = bagOf(raw);
191
- const name = str(query['name']);
192
- return {
193
- name,
194
- live: query['live'] === true,
195
- // `QueryDescriptor`'s permission field is named `capability`, not `policy` — this
196
- // fact keeps its own field named `policy` (that is the /_x rendering, not the registry).
197
- policy: str(query['capability']),
198
- sql: sqlByName.get(name) ?? null,
199
- };
200
- });
191
+ return queries.map((query) => ({
192
+ name: query.name,
193
+ live: query.live,
194
+ // `QueryDescriptor`'s permission field is named `capability`, not `policy` — this
195
+ // fact keeps its own field named `policy` (that is the /_x rendering, not the registry).
196
+ policy: query.capability,
197
+ sql: sqlByName.get(query.name) ?? null,
198
+ }));
201
199
  },
202
200
 
203
201
  subscribers: unwired<readonly LiveSubscriberFact[]>('subscribers', 'live'),
204
202
 
205
203
  async jobDefs(): Promise<readonly JobDefFact[]> {
206
204
  const { describeJobs } = await import('@ultimat3/jobs');
207
- return listOf(describeJobs()).map((raw) => {
208
- const job = bagOf(raw);
209
- const retry = bagOf(job['retry']);
210
- return {
211
- name: str(job['name']),
212
- queue: str(job['queue'], 'default'),
213
- steps: strings(job['steps']),
214
- retry: {
215
- attempts: numOf(retry['attempts'], 1),
216
- backoff: str(retry['backoff'], 'exponential'),
217
- },
218
- idempotent: job['idempotencyKey'] !== undefined,
219
- };
220
- });
205
+ return describeJobs().map((job) => ({
206
+ name: job.name,
207
+ queue: job.queue,
208
+ steps: job.steps,
209
+ retry: { attempts: job.retry.attempts, backoff: job.retry.backoff },
210
+ // The descriptor's own boolean. `job['idempotencyKey']` was a read of the DEFINITION's
211
+ // field on a descriptor that never carried it, so every job on the panel reported
212
+ // non-idempotent — the opposite of what `job()` refuses to register without.
213
+ idempotent: job.idempotent,
214
+ }));
221
215
  },
222
216
 
223
217
  async queues(): Promise<readonly QueueFact[]> {
@@ -301,38 +295,28 @@ export function defaultDevSources(opts: DevSourceOptions = {}): DevSources {
301
295
 
302
296
  async tables(): Promise<readonly TableFact[]> {
303
297
  const { describeEntities } = await import('@ultimat3/entity');
304
- return listOf(describeEntities()).map((raw) => {
305
- const entity = bagOf(raw);
306
- return {
307
- name: str(entity['table'], str(entity['name'])),
308
- // `EntityDescription.columns` is a LIST of physical columns — money is already two
309
- // of them here. Reading it as a record produced a table whose columns were "0", "1".
310
- columns: listOf(entity['columns']).map((rawColumn) => {
311
- const column = bagOf(rawColumn);
312
- return {
313
- name: str(column['column'], str(column['property'], 'unknown')),
314
- type: str(column['kind'], 'unknown'),
315
- nullable: column['notNull'] !== true,
316
- };
317
- }),
318
- };
319
- });
298
+ return describeEntities().map((entity) => ({
299
+ name: entity.table,
300
+ // `EntityDescription.columns` is a LIST of physical columns — money is already two
301
+ // of them here. Reading it as a record produced a table whose columns were "0", "1".
302
+ columns: entity.columns.map((column) => ({
303
+ // The PHYSICAL name, which is the vocabulary a psql tab speaks.
304
+ name: column.column,
305
+ type: column.kind,
306
+ nullable: !column.notNull,
307
+ })),
308
+ }));
320
309
  },
321
310
 
322
- async drift(): Promise<readonly DriftFact[]> {
323
- const { describeEntities } = await import('@ultimat3/entity');
324
- return listOf(describeEntities()).flatMap((raw) => {
325
- const entity = bagOf(raw);
326
- return listOf(entity['drift']).map((rawIssue) => {
327
- const issue = bagOf(rawIssue);
328
- return {
329
- table: str(entity['table'], str(entity['name'])),
330
- column: typeof issue['column'] === 'string' ? issue['column'] : null,
331
- issue: str(issue['issue'], 'unknown'),
332
- };
333
- });
334
- });
335
- },
311
+ /**
312
+ * Unwired, and it has to be: drift is a COMPARISON — the entities this process declares
313
+ * against the columns a database actually has — and `describeEntities()` is only one half of
314
+ * it. This source used to read a `drift` key off `EntityDescription`, which has never had one,
315
+ * so the panel answered `[]` for every app and every schema: "no drift" printed over a
316
+ * database nobody looked at. Only a host holding the connection can answer (`x db migrate`'s
317
+ * `checkDrift`), so it wires the hook or the panel says the check did not run.
318
+ */
319
+ drift: unwired<readonly DriftFact[]>('drift', 'db'),
336
320
 
337
321
  runSql: unwired<SqlResult>('runSql', 'db'),
338
322
  mail: unwired<readonly MailFact[]>('mail', 'mail'),
@@ -347,16 +331,13 @@ export function defaultDevSources(opts: DevSourceOptions = {}): DevSources {
347
331
  import('@ultimat3/cache'),
348
332
  import('@ultimat3/entity'),
349
333
  ]);
350
- return listOf(describeEntities()).map((raw) => {
351
- const name = str(bagOf(raw)['name']);
352
- return {
353
- tag: name,
354
- dependents: listOf(dependentsOf([{ entity: name }])).map((rawDep) => {
355
- const dep = bagOf(rawDep);
356
- return { kind: str(dep['kind']), id: str(dep['id']) };
357
- }),
358
- };
359
- });
334
+ return describeEntities().map((entity) => ({
335
+ tag: entity.name,
336
+ dependents: dependentsOf([{ entity: entity.name }]).map((dependent) => ({
337
+ kind: dependent.kind,
338
+ id: dependent.id,
339
+ })),
340
+ }));
360
341
  },
361
342
 
362
343
  invalidations: unwired<readonly InvalidationFact[]>('invalidations', 'cache'),
@@ -379,11 +360,15 @@ export function defaultDevSources(opts: DevSourceOptions = {}): DevSources {
379
360
  });
380
361
  }
381
362
  const { describeActions } = await import('@ultimat3/action');
382
- // `ActionDescriptor`'s permission field is named `capability`, not `policy` — reading the
383
- // latter answered '' for every action, so the matrix came back empty even when both
384
- // `authz` and `actors` were wired correctly.
363
+ // `permissions`, NOT `capability`. `capability` is the policy's DISPLAY label and
364
+ // `action.ts` says so: a composite renders as `and(post:read, org:member)`, which is not a
365
+ // permission and can never be granted — so every composite-guarded action was a
366
+ // permanently-denied row, and an operator read a real grant as missing. `permissions` is
367
+ // the flattened list a grant is actually matched against. This is the SECOND time the two
368
+ // were confused: `x policy list` reported the same actions as unenforced for the same
369
+ // reason, and `ActionDescriptor.permissions` carries that history in its own doc comment.
385
370
  const permissions = [
386
- ...new Set(listOf(describeActions()).map((raw) => str(bagOf(raw)['capability']))),
371
+ ...new Set(describeActions().flatMap((action) => action.permissions)),
387
372
  ].filter((permission) => permission !== '');
388
373
 
389
374
  return actors.flatMap((actor) =>
package/src/dev/facts.ts CHANGED
@@ -1,15 +1,27 @@
1
1
  // The facts a /_x panel may read: one shape per introspection call. Kept apart from the
2
2
  // sources that produce them so a panel imports the shape it renders and nothing else.
3
3
 
4
+ /**
5
+ * One row of the route table, in the panel's own words: `render` is the descriptor's `mode` and
6
+ * `handler` is its `file`. Both renames are made in `data.ts`, once.
7
+ *
8
+ * There is no `hasMeta`, deliberately. `defineRoute()` REFUSES a route with no `meta` function
9
+ * (`packages/render/src/route.ts`), so every registered route has one and the field could only
10
+ * ever be `true` — it was published as `false` for every route instead, off a `meta` key no
11
+ * descriptor carries, and fed a `missingMeta` list that can never have a member. Whether the meta
12
+ * a route returns is any GOOD is a render-time question: `meta` takes the loaded data, so nothing
13
+ * static can answer it, and a list that always reads "0 routes missing meta" is worse than absent.
14
+ */
4
15
  export interface RouteFact {
5
16
  readonly path: string;
17
+ /** `RouteDescriptor.mode` — `ssr`, `isr`, `static`, `stream`, `spa`. */
6
18
  readonly render: string;
7
19
  readonly offline: string;
8
20
  readonly hydrate: string;
21
+ /** The route's source file: what names the row. A descriptor publishes no handler. */
9
22
  readonly handler: string;
10
23
  readonly budget: { readonly js?: string; readonly lcp?: number };
11
24
  readonly revalidateTags: readonly string[];
12
- readonly hasMeta: boolean;
13
25
  }
14
26
 
15
27
  /**
@@ -85,6 +97,12 @@ export interface JobDefFact {
85
97
  readonly queue: string;
86
98
  readonly steps: readonly string[];
87
99
  readonly retry: { readonly attempts: number; readonly backoff: string };
100
+ /**
101
+ * `JobDescriptor.idempotent` — `true` for every registered job, because `job()` refuses a
102
+ * definition with no `idempotencyKey`. Published rather than assumed: it read the DEFINITION's
103
+ * `idempotencyKey` off a descriptor that never carried it, so the panel called every job in
104
+ * the app unsafe to retry.
105
+ */
88
106
  readonly idempotent: boolean;
89
107
  }
90
108
 
@@ -153,6 +171,11 @@ export interface TableFact {
153
171
  readonly columns: readonly ColumnFact[];
154
172
  }
155
173
 
174
+ /**
175
+ * One column the database and the entities disagree about. A COMPARISON, so no registry can
176
+ * produce it alone — `DevSources.drift` is a hook a host holding the connection wires, and the
177
+ * db panel reports `drift: null` ("nobody checked") rather than `[]` ("nothing wrong") without it.
178
+ */
156
179
  export interface DriftFact {
157
180
  readonly table: string;
158
181
  readonly column: string | null;
@@ -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/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,
@@ -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,
package/src/search.ts CHANGED
@@ -3,8 +3,10 @@
3
3
  // the same `admin:read` + `<entity>:read` pair gates the hit and the detail page it links to.
4
4
 
5
5
  import { expectedQueryLoop } from '@ultimat3/db';
6
+ import { type AuditEntry, deniedDraft } from './audit';
7
+ import { denied } from './authz';
6
8
  import type { CrudCtx } from './crud';
7
- import { canOperate } from './crud';
9
+ import { decideOperation } from './crud';
8
10
  import type { AdminFilter, AdminRow } from './registry';
9
11
  import { rowId } from './registry';
10
12
  import { type AdminResource, repoOf } from './resource';
@@ -29,6 +31,14 @@ export interface AdminSearchResult {
29
31
  readonly searched: readonly string[];
30
32
  /** Resources left out, and why — so an operator is never silently shown a subset. */
31
33
  readonly skipped: readonly { readonly entity: string; readonly reason: string }[];
34
+ /**
35
+ * One entry per resource this call decided about: allowed, or refused. Search read rows out of
36
+ * every readable entity and wrote NOTHING, on either path — while `permissions.ts` carries
37
+ * `audited: true` for it, `audit.ts` says denied attempts are logged too, and `CLAUDE.md` says
38
+ * "every admin operation is audited, reads included". Shaped like `adminList`'s: the subject is
39
+ * the table, so there is no `entityId`.
40
+ */
41
+ readonly audit: readonly AuditEntry[];
32
42
  }
33
43
 
34
44
  export interface AdminSearchInput {
@@ -89,20 +99,45 @@ function labelOf(row: AdminRow, resource: AdminResource): string {
89
99
  return rowId(row, resource.idField);
90
100
  }
91
101
 
102
+ const SKIPPED_FORBIDDEN = 'admin.search.skipped.forbidden';
103
+
92
104
  export async function adminSearch(input: AdminSearchInput): Promise<AdminSearchResult> {
105
+ const { ctx } = input;
93
106
  const term = input.term.trim();
94
107
  const limit = input.limitPerResource ?? DEFAULT_LIMIT_PER_RESOURCE;
95
108
  const searched: string[] = [];
96
109
  const skipped: { entity: string; reason: string }[] = [];
97
110
  const hits: AdminSearchHit[] = [];
111
+ const audit: AuditEntry[] = [];
98
112
 
99
- if (term === '') return { term, hits: [], searched: [], skipped: [] };
113
+ // Nothing was decided and nothing was read, so there is nothing to log: an empty term never
114
+ // reaches a repo.
115
+ if (term === '') return { term, hits: [], searched: [], skipped: [], audit: [] };
100
116
 
101
117
  for (const resource of input.resources) {
102
- if (!canOperate(resource, 'search', input.ctx)) {
103
- skipped.push({ entity: resource.name, reason: 'admin.search.skipped.forbidden' });
118
+ const offered = resource.operations.includes('search');
119
+ const decision = decideOperation(resource, 'search', ctx);
120
+ if (!offered || !decision.allowed) {
121
+ skipped.push({ entity: resource.name, reason: SKIPPED_FORBIDDEN });
122
+ audit.push(
123
+ await ctx.audit.append(
124
+ deniedDraft({
125
+ requestId: ctx.requestId,
126
+ actor: ctx.actor,
127
+ operation: 'search',
128
+ kind: 'operation',
129
+ entity: resource.name,
130
+ entityId: null,
131
+ // A resource that does not OFFER search was refused by the registry, not by a policy,
132
+ // and the decision object would otherwise read `allow` on a denied entry.
133
+ decision: offered ? decision : denied(decision.permission, SKIPPED_FORBIDDEN),
134
+ }),
135
+ ),
136
+ );
104
137
  continue;
105
138
  }
139
+ // Neither of these is an authorization event: the resource is searchable in principle and has
140
+ // no text index or no repo to ask. They stay in `skipped` and out of the log.
106
141
  if (resource.searchFields.length === 0) {
107
142
  skipped.push({ entity: resource.name, reason: 'admin.search.skipped.no-text-fields' });
108
143
  continue;
@@ -112,8 +147,23 @@ export async function adminSearch(input: AdminSearchInput): Promise<AdminSearchR
112
147
  continue;
113
148
  }
114
149
  searched.push(resource.name);
150
+ // Appended BEFORE the read, so a repo that throws still leaves the record that the rows were
151
+ // asked for — `audit.ts`: if it isn't logged, it didn't happen.
152
+ audit.push(
153
+ await ctx.audit.append({
154
+ requestId: ctx.requestId,
155
+ actor: ctx.actor,
156
+ operation: 'search',
157
+ kind: 'operation',
158
+ entity: resource.name,
159
+ entityId: null,
160
+ permission: decision.permission,
161
+ outcome: 'allowed',
162
+ reason: decision.reason,
163
+ }),
164
+ );
115
165
  hits.push(...(await searchResource(resource, term, limit)));
116
166
  }
117
167
 
118
- return { term, hits, searched, skipped };
168
+ return { term, hits, searched, skipped, audit };
119
169
  }
@@ -92,7 +92,8 @@ export function assertMoney(field: AdminField, value: unknown): Money | null {
92
92
  return fail(
93
93
  field,
94
94
  `money value arrived as the number ${value}; money is integer minor units + an ISO currency`,
95
- `store ${field.name} as { minor, currency } — x g migration "money ${field.entity}.${field.name}"`,
95
+ // `x db gen` writes migrations; `x g migration` is not a generator and never was.
96
+ `store ${field.name} as { minor, currency } — x db gen "change ${field.entity}.${field.name} to money"`,
96
97
  );
97
98
  }
98
99
  if (typeof value !== 'object') {