@ultimat3/admin 2.0.0 → 4.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CLAUDE.md CHANGED
@@ -7,9 +7,14 @@ 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.
12
- - **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).
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.
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.
15
20
  - **A deliberate query loop declares itself.** `search.ts` runs one indexed lookup per text field on purpose — the query IR is a conjunction, so three small indexed reads beat one unindexed `OR` — and says so through `expectedQueryLoop()` from `@ultimat3/db` (tier 1, downward, and the only thing this package imports from it). That is the one suppression mechanism: never a comment pragma and never a list of exempt call sites. A new loop here either argues for itself in a `reason` or it is an N+1.
@@ -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": "2.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": "2.0.0",
36
- "@ultimat3/ai": "2.0.0",
37
- "@ultimat3/cache": "2.0.0",
38
- "@ultimat3/core": "2.0.0",
39
- "@ultimat3/db": "2.0.0",
40
- "@ultimat3/entity": "2.0.0",
41
- "@ultimat3/i18n": "2.0.0",
42
- "@ultimat3/jobs": "2.0.0",
43
- "@ultimat3/mcp": "2.0.0",
44
- "@ultimat3/money": "2.0.0",
45
- "@ultimat3/policy": "2.0.0",
46
- "@ultimat3/query": "2.0.0",
47
- "@ultimat3/render": "2.0.0",
48
- "@ultimat3/schema": "2.0.0",
49
- "@ultimat3/ui": "2.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;