@ultimat3/admin 1.1.0 → 2.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 ADDED
@@ -0,0 +1,52 @@
1
+ # @ultimat3/admin — boundary
2
+
3
+ Tier 5. May import tiers 0–4. Exactly one importer: `@ultimat3/cli`, and only of `@ultimat3/admin/dev` — `cli → admin` is a declared sideways edge (`scripts/lib/tiers.ts`) so `x dev` can **mount** `/_x`. The root barrel `@ultimat3/admin` has no importer.
4
+
5
+ Two products, one package, **two entry points**: `@ultimat3/admin/dev` (`src/dev/index.ts`) = the `/_x` dev dashboard, `@ultimat3/admin` (`src/index.ts`) = the generated app admin (production, authz'd, AI-first). Keep them apart; nothing in `src/dev/` may be imported by an admin view, and the root barrel does not re-export the dev half — a host that mounts `/_x` (the CLI) must not load a Solid component to do it.
6
+
7
+ ## Rules
8
+
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
+ - **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.
11
+ - **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).
13
+ - **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
+ - **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
+ - **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.
16
+ - **The timeline panel renders the detector's verdicts, it does not re-derive them.** `repeatedSql` (`dev/panel-timeline.ts`) is a measurement over the trace this panel recorded — every SQL text seen twice. `nPlusOne` is the verdict: `x dev`'s statement ledger, read through `DevSources.statementLoops()` and scoped to the selected request, in the ledger's own order. A second count here would be blind to the `expectedQueryLoop` above and to statement attribution (`members.findById`, not raw SQL text), and would disagree with the `fix:` an author actually pastes. No detector wired → `null`, never `[]`: "nobody counted" is not "this request was clean".
17
+ - **This catalog is the framework's one opt-OUT MCP surface, and that is deliberate.**
18
+ `mcp-tools.ts` is the only file that does not call `isMcpExposed` from `@ultimat3/core`: every
19
+ tool here is already gated on an admin permission, and the CRUD tools carry no `mcp` block at
20
+ all, so opting in would list `admin.posts.delete` while hiding the action button beside it.
21
+ `mcp: { expose: false }` withdraws one. Stated there, in core's `mcp-exposure.ts`, and in
22
+ `wiki/Admin-Dashboard.md` — a *second* exception anywhere is the bug.
23
+ - **`dev/panel-db.ts`'s `sanitize` decides "did they type a statement", not "is it safe".** It blanks every opaque span in ONE left-to-right pass — `'…'`, `"…"`, `$tag$…$tag$` and both comment forms in a single alternation, because each form can contain another's opener — so `-- still typing` reads as an empty box. It used to feed a write-keyword scan in this file and that scan is gone; **do not cite it as a security property**. It once said an unterminated quote "leaves the rest visible — the guard fails closed": true of the scan it fed, meaningless now, and it never covered `$tag$` or slash-star, whose surviving character is `$` or `/`.
24
+ - **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
+ - **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
+ - **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.
28
+ - Money through `assertMoney`, timestamps through `assertZone`, pagination through `pagination.ts`. No `offset`, ever.
29
+ - **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
+ - 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).
31
+ - Colours only via `ThemeTokenRef` (`--x-*`). Raw hex does not typecheck.
32
+ - Views are pure functions of props — no `createSignal`, no local state; the route owns it.
33
+
34
+ ## Layout
35
+
36
+ | File | Owns |
37
+ |---|---|
38
+ | `admin.ts` | `defineAdmin` → resources, nav, route table, audit, authz |
39
+ | `pages.ts` / `page-guard.tsx` | a `pages:` entry → route + nav item (pure) / the wrapper that decides before it renders |
40
+ | `routes.ts` | the only `defineRoute` caller: mode per view, `policy` composed from the route's permissions |
41
+ | `registry.ts` / `entity-columns.ts` | the entity surface the admin reads / one entity → column facts |
42
+ | `resource.ts` / `fields.ts` / `widget-value.ts` | derivation, widget table, value guards |
43
+ | `crud.ts` / `action-gate.ts` | policy → confirmation → validation → repo → audit |
44
+ | `mcp-tools.ts` / `mcp.ts` | tool derivation (pure) / transport wiring |
45
+ | `dev/server.ts` + `dev/panel-*.ts` | `/_x` mount guard and one panel per file |
46
+
47
+ ## Commands
48
+
49
+ ```bash
50
+ bun test --filter @ultimat3/admin
51
+ bun run --filter @ultimat3/admin typecheck
52
+ ```
package/README.md CHANGED
@@ -32,7 +32,7 @@ import { devDashboard, defaultDevSources } from '@ultimat3/admin/dev';
32
32
 
33
33
  const dev = devDashboard({ sources: defaultDevSources({ authz, actors }) }); // throws in prod
34
34
  const response = await dev.handle(request); // null when the path is not /_x
35
- await dev.json('jobs'); // the same payload `x dev --panel jobs --json` prints
35
+ await dev.json('jobs'); // the same payload /_x/jobs renders from
36
36
  ```
37
37
 
38
38
  The root barrel does not re-export any of this: `x dev` mounts `/_x` without pulling a Solid
@@ -58,9 +58,123 @@ export const admin = defineAdmin({
58
58
  audit: memoryAuditLog({ sinks: [auditTable] }),
59
59
  });
60
60
 
61
- export const routes = adminRoutes(admin); // every page `spa`, `network-only`, noindex
61
+ export const routes = adminRoutes(admin); // generated views `spa`, custom pages `ssr`, all gated
62
62
  ```
63
63
 
64
+ ### The views are TSX, and the pieces come apart
65
+
66
+ There is no view DSL here, and there will not be one. Every renderer is an ordinary SolidJS
67
+ component in an ordinary `.tsx` file importing `@ultimat3/ui` — `AdminList`, `AdminForm`,
68
+ `AdminDetail`, `Widget`, `AdminLayout` are each exported on their own, so a table can be lifted
69
+ out of its page and dropped into a screen the generator never wrote:
70
+
71
+ ```tsx
72
+ import { AdminList, AdminForm, Widget } from '@ultimat3/admin';
73
+
74
+ <AdminList resource={admin.resource('posts')} page={page} ctx={ctx} … />; // just the table
75
+ <Widget field={field} value={row.total} ctx={ctx} mode="read" />; // just one cell
76
+ ```
77
+
78
+ `Widget` takes the field and the raw row value, not pre-derived props: `widgetProps()` is the
79
+ guard (money is minor units, a timestamp has an IANA zone) and the component calls it, so there is
80
+ no way to render a cell that skipped it. `mode` is `read` or `edit`; an edit cell also takes the
81
+ `control` the surrounding `<Field>` hands its child, and `onInput`.
82
+
83
+ An admin route is an ordinary `route` primitive; an admin action is an ordinary `action`. Nothing
84
+ in this package is written in a second language that only the dashboard understands — the escape
85
+ hatch is the same TSX as the main path, which is why there is no cliff to fall off when a screen
86
+ stops being CRUD.
87
+
88
+ ### Custom pages
89
+
90
+ The bespoke ops screen is the common case, not the corner: a reconciliation fixer, a proxy health
91
+ board, a deploy button. Declare it in `pages:` and it becomes a real admin route.
92
+
93
+ ```tsx
94
+ // admin/ops/page.tsx — a component, nothing framework-shaped about it
95
+ export function OpsPage(props: AdminPageProps) {
96
+ return <OpsBoard counts={await mediaStateCounts()} />;
97
+ }
98
+
99
+ // admin/index.ts
100
+ defineAdmin({
101
+ entities: [posts],
102
+ pages: [
103
+ {
104
+ path: '/ops', // rooted at basePath → /admin/ops
105
+ titleKey: 'admin.ops.title',
106
+ navGroup: 'admin.group.operations', // omit to keep it out of the nav
107
+ permissions: ['ops:read'], // `admin:read` is composed in front of it
108
+ component: OpsPage,
109
+ },
110
+ ],
111
+ auth,
112
+ });
113
+ ```
114
+
115
+ | What you get | How |
116
+ |---|---|
117
+ | a row in `app.routes`, `adminRoutes()`, `x manifest` | `pageRoutes()` folds it in beside the generated screens |
118
+ | a nav item that disappears for an actor who cannot open it | `NavItem.permissions` → `visibleNav` |
119
+ | a `defineRoute({ policy })` you never wrote and cannot omit | `adminRouteConfig` composes `permissions[0]` into it |
120
+ | a per-request refusal, audited, before your component runs | `guardedPage()` wraps it in `decideAll` |
121
+
122
+ **The guard is not yours to remember.** `adminRoutes()` hands the router the *wrapped* component,
123
+ never the one you wrote, and `AdminPageProps.ctx` is required by the type — a page component
124
+ cannot be called without the handle the guard decides on. `permissions: []` throws
125
+ `X_ADMIN_PAGE_UNGUARDED` where it is written, not on the first unauthenticated request. A path
126
+ that shadows a generated screen throws `X_ADMIN_PAGE_PATH_INVALID` the same way.
127
+
128
+ Custom pages are `render: 'ssr'`, `hydrate: 'never'`: the guard runs on the server, so there is no
129
+ shell to ship and nothing to decide twice.
130
+
131
+ ### Serving an admin URL from your own route file
132
+
133
+ A host whose router is file-based writes its own `page.tsx` for an admin URL. Take the gate from
134
+ the route table — never type it a second time:
135
+
136
+ ```ts
137
+ import { adminRouteFor } from '@ultimat3/admin';
138
+ import { admin } from './admin';
139
+
140
+ const route = adminRouteFor(admin, `${admin.basePath}/users`);
141
+
142
+ export const config = defineRoute({
143
+ render: 'ssr',
144
+ hydrate: 'never',
145
+ offline: 'network-only',
146
+ policy: route.policy, // `defineAdmin()`'s, from `permissionsForOperation('users', 'list')`
147
+ meta: () => ({ title: t('admin.users.title') }),
148
+ });
149
+ ```
150
+
151
+ `route.policy` is the same object `route.config.policy` carries, so the gate has one declaration
152
+ and one reader. A `path` the admin does not declare throws `X_ADMIN_PAGE_PATH_INVALID` listing the
153
+ paths that would have worked — a page serving an admin URL the admin never built is a screen whose
154
+ permissions nothing composed.
155
+
156
+ ### Splitting the admin across files
157
+
158
+ `defineAdmin` takes **plain values** — `entities`, `resources`, `actions`, `jobs`, `pages`, `nav`,
159
+ `branding`, `auth`, `audit`. Every one of them can be imported from its own module, so the cut is
160
+ along the input's own keys and there is exactly one layout:
161
+
162
+ ```
163
+ app/admin/
164
+ index.ts defineAdmin({ … }) — composition only, no logic
165
+ auth.ts actor() + policyAuthz({ policies })
166
+ nav.ts groups, order, extras
167
+ <resource>/
168
+ resource.ts the AdminResourceOptions entry (listFields, sensitive, labelField)
169
+ repo.ts the AdminRepo binding
170
+ actions.ts the AdminAction[] for this entity
171
+ pages/<name>.tsx a custom page component for this entity, if any
172
+ pages/<name>.tsx an app-wide custom page (ops, reconciliation, deploy)
173
+ ```
174
+
175
+ `index.ts` imports each and composes. Nothing here is a framework rule — it is what the input
176
+ shape already is, written down once so two apps do not invent two layouts. `x g admin` emits it.
177
+
64
178
  ### Derived from the entity, and only from it
65
179
 
66
180
  | Admin decision | Read from |
@@ -112,4 +226,4 @@ Panes are off until enabled, and `runAiPane` refuses (never no-ops) without a ru
112
226
 
113
227
  ## Errors
114
228
 
115
- `X_ADMIN_ENTITY_UNKNOWN` · `X_ADMIN_FIELD_UNSUPPORTED` · `X_ADMIN_POLICY_MISSING` · `X_DEV_DASHBOARD_IN_PROD` · `X_NOT_IMPLEMENTED` (an unwired `/_x` source, carrying the wiring line).
229
+ `X_ADMIN_ENTITY_UNKNOWN` · `X_ADMIN_FIELD_UNSUPPORTED` · `X_ADMIN_POLICY_MISSING` · `X_ADMIN_PAGE_UNGUARDED` · `X_ADMIN_PAGE_PATH_INVALID` · `X_ADMIN_DENIED` · `X_ADMIN_TOOL_FORBIDDEN` · `X_ADMIN_INVALID` · `X_DEV_DASHBOARD_IN_PROD` · `X_NOT_IMPLEMENTED` (an unwired `/_x` source, carrying the wiring line).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ultimat3/admin",
3
- "version": "1.1.0",
3
+ "version": "2.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",
@@ -20,6 +20,7 @@
20
20
  "files": [
21
21
  "src",
22
22
  "!src/**/*.test.ts",
23
+ "CLAUDE.md",
23
24
  "README.md",
24
25
  "LICENSE"
25
26
  ],
@@ -31,18 +32,20 @@
31
32
  "test": "bun test"
32
33
  },
33
34
  "dependencies": {
34
- "@ultimat3/action": "1.1.0",
35
- "@ultimat3/ai": "1.1.0",
36
- "@ultimat3/cache": "1.1.0",
37
- "@ultimat3/core": "1.1.0",
38
- "@ultimat3/entity": "1.1.0",
39
- "@ultimat3/i18n": "1.1.0",
40
- "@ultimat3/jobs": "1.1.0",
41
- "@ultimat3/mcp": "1.1.0",
42
- "@ultimat3/money": "1.1.0",
43
- "@ultimat3/policy": "1.1.0",
44
- "@ultimat3/query": "1.1.0",
45
- "@ultimat3/render": "1.1.0",
46
- "@ultimat3/ui": "1.1.0"
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"
47
50
  }
48
51
  }
package/src/admin.ts CHANGED
@@ -6,7 +6,8 @@ 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 { adminNav, type NavGroup, type NavOptions, visibleNav } from './nav';
9
+ import { adminNav, type NavGroup, type NavItem, type NavOptions, visibleNav } from './nav';
10
+ import { type AdminCustomPage, type AdminPageComponent, pageNavItems, pageRoutes } from './pages';
10
11
  import type { AdminOperation } from './permissions';
11
12
  import type { AdminAction, AdminEntity, AdminJobSummary, AdminRow } from './registry';
12
13
  import {
@@ -31,7 +32,9 @@ export type AdminView =
31
32
  | 'search'
32
33
  | 'jobs'
33
34
  | 'audit'
34
- | 'dashboard';
35
+ | 'dashboard'
36
+ /** A `pages:` entry: the app's own component, guarded by the frame. */
37
+ | 'page';
35
38
 
36
39
  /**
37
40
  * A route as data. `routes.ts` turns these into `defineRoute()` configs; keeping the table
@@ -43,6 +46,11 @@ export interface AdminRoute {
43
46
  readonly entity: string | null;
44
47
  readonly titleKey: string;
45
48
  readonly permissions: readonly string[];
49
+ /**
50
+ * Set only on `view: 'page'`. It is the AUTHOR's component, still unguarded — `routes.ts` is
51
+ * the only thing that may hand it out, and it hands out the wrapped one.
52
+ */
53
+ readonly component?: AdminPageComponent;
46
54
  }
47
55
 
48
56
  export interface DefineAdminInput {
@@ -52,6 +60,8 @@ export interface DefineAdminInput {
52
60
  /** Registered actions. Each is attached to `action.entity`, or the global toolbar. */
53
61
  readonly actions?: readonly AdminAction[];
54
62
  readonly jobs?: readonly AdminJobSummary[];
63
+ /** Screens the generator would never write. Declared here so they are not a second surface. */
64
+ readonly pages?: readonly AdminCustomPage[];
55
65
  readonly nav?: NavOptions;
56
66
  readonly branding?: Partial<AdminBranding>;
57
67
  readonly auth: AdminAuth;
@@ -80,7 +90,7 @@ export interface AdminApp {
80
90
  }
81
91
 
82
92
  const VIEW_OPERATION: Readonly<
83
- Record<Exclude<AdminView, 'jobs' | 'audit' | 'dashboard'>, AdminOperation>
93
+ Record<Exclude<AdminView, 'jobs' | 'audit' | 'dashboard' | 'page'>, AdminOperation>
84
94
  > = {
85
95
  list: 'list',
86
96
  detail: 'detail',
@@ -92,7 +102,10 @@ const VIEW_OPERATION: Readonly<
92
102
  function resourceRoutes(basePath: string, resource: AdminResource): readonly AdminRoute[] {
93
103
  const base = `${basePath}${resource.path}`;
94
104
  const routes: AdminRoute[] = [];
95
- const add = (view: Exclude<AdminView, 'jobs' | 'audit' | 'dashboard'>, path: string): void => {
105
+ const add = (
106
+ view: Exclude<AdminView, 'jobs' | 'audit' | 'dashboard' | 'page'>,
107
+ path: string,
108
+ ): void => {
96
109
  const op = VIEW_OPERATION[view];
97
110
  if (!resource.operations.includes(op)) return;
98
111
  routes.push({
@@ -128,8 +141,14 @@ export function defineAdmin(input: DefineAdminInput): AdminApp {
128
141
  });
129
142
  });
130
143
 
131
- const nav = adminNav(resources, input.nav ?? {});
132
- const routes: AdminRoute[] = [
144
+ const pages = input.pages ?? [];
145
+ const navOptions = input.nav ?? {};
146
+ const extra: readonly (NavItem & { readonly group: string })[] = [
147
+ ...(navOptions.extra ?? []),
148
+ ...pageNavItems(pages),
149
+ ];
150
+ const nav = adminNav(resources, { ...navOptions, extra });
151
+ const generated: AdminRoute[] = [
133
152
  {
134
153
  path: basePath,
135
154
  view: 'dashboard',
@@ -161,6 +180,16 @@ export function defineAdmin(input: DefineAdminInput): AdminApp {
161
180
  ...resources.flatMap((resource) => resourceRoutes(basePath, resource)),
162
181
  ];
163
182
 
183
+ // Generated first, so a page that would shadow one is refused rather than deciding a race.
184
+ const routes: readonly AdminRoute[] = [
185
+ ...generated,
186
+ ...pageRoutes(
187
+ basePath,
188
+ pages,
189
+ generated.map((route) => route.path),
190
+ ),
191
+ ];
192
+
164
193
  return {
165
194
  basePath,
166
195
  branding,
package/src/crud.ts CHANGED
@@ -41,8 +41,13 @@ export type CrudResult<Row extends AdminRow> =
41
41
  };
42
42
 
43
43
  export type ListResult<Row extends AdminRow> =
44
- | { readonly ok: true; readonly page: AdminPage<Row> }
45
- | { readonly ok: false; readonly kind: 'denied'; readonly decision: AdminDecision };
44
+ | { readonly ok: true; readonly page: AdminPage<Row>; readonly audit: AuditEntry }
45
+ | {
46
+ readonly ok: false;
47
+ readonly kind: 'denied';
48
+ readonly decision: AdminDecision;
49
+ readonly audit: AuditEntry;
50
+ };
46
51
 
47
52
  /** Both gates, always in this order: the admin-level one, then the entity-level one. */
48
53
  export function permissionsForOperation(entity: string, op: AdminOperation): readonly string[] {
@@ -96,14 +101,38 @@ async function refuse<Row extends AdminRow>(
96
101
  };
97
102
  }
98
103
 
104
+ /**
105
+ * The one read that logged nothing, in either direction: `audit.ts` says denied and failed attempts
106
+ * are logged too, and `adminDetail` below logs the allowed read as well — so a listing that walked
107
+ * every row of a table left no trace, and a refused listing left no trace of the refusal either.
108
+ * There is no `entityId`: the subject is the table, not a row.
109
+ */
99
110
  export async function adminList<Row extends AdminRow>(
100
111
  resource: AdminResource<Row>,
101
112
  ctx: CrudCtx,
102
113
  req: PageRequest = {},
103
114
  ): Promise<ListResult<Row>> {
104
115
  const decision = decideOperation(resource, 'list', ctx);
105
- if (!decision.allowed) return { ok: false, kind: 'denied', decision };
106
- return { ok: true, page: await fetchPage(resource, req) };
116
+ if (!decision.allowed) {
117
+ const refused = await refuse(resource, 'list', ctx, decision, null);
118
+ return { ok: false, kind: 'denied', decision, audit: refused.audit };
119
+ }
120
+ const page = await fetchPage(resource, req);
121
+ return {
122
+ ok: true,
123
+ page,
124
+ audit: await ctx.audit.append({
125
+ requestId: ctx.requestId,
126
+ actor: ctx.actor,
127
+ operation: 'list',
128
+ kind: 'operation',
129
+ entity: resource.name,
130
+ entityId: null,
131
+ permission: decision.permission,
132
+ outcome: 'allowed',
133
+ reason: decision.reason,
134
+ }),
135
+ };
107
136
  }
108
137
 
109
138
  export async function adminDetail<Row extends AdminRow>(
package/src/detail.tsx CHANGED
@@ -49,7 +49,10 @@ export function AdminDetail<Row extends AdminRow>(props: AdminDetailProps<Row>):
49
49
  <ErrorState
50
50
  error={adminErrorFrom({
51
51
  code: 'X_ADMIN_ENTITY_UNKNOWN',
52
- cause: t('admin.detail.not-found', { entity: props.resource.name }),
52
+ // `.cause`, not a bare `admin.detail.not-found`: a catalog is authored nested and
53
+ // `parseNestedCatalog` refuses a dot inside a key, so a name is a leaf or a branch and
54
+ // never both — `admin.detail.not-found` + `.fix` could not have coexisted in en.json.
55
+ cause: t('admin.detail.not-found.cause', { entity: props.resource.name }),
53
56
  fix: t('admin.detail.not-found.fix'),
54
57
  })}
55
58
  />
package/src/dev/data.ts CHANGED
@@ -11,6 +11,7 @@ import type { JobState, StepStatus } from '@ultimat3/jobs';
11
11
  import type { AdminActor, AdminAuthz } from '../authz';
12
12
  import { DevSourceUnavailableError } from '../errors';
13
13
  import type {
14
+ BackfillFact,
14
15
  CacheEdgeFact,
15
16
  DevSources,
16
17
  DriftFact,
@@ -27,6 +28,7 @@ import type {
27
28
  RequestTrace,
28
29
  RouteFact,
29
30
  SqlResult,
31
+ StatementLoopFact,
30
32
  TableFact,
31
33
  TaskFact,
32
34
  } from './facts';
@@ -38,11 +40,14 @@ export function staticDevSources(facts: Partial<DevSources> = {}): DevSources {
38
40
  return {
39
41
  routes: facts.routes ?? ((): Promise<readonly RouteFact[]> => empty([])),
40
42
  traces: facts.traces ?? ((): Promise<readonly RequestTrace[]> => empty([])),
43
+ statementLoops:
44
+ facts.statementLoops ?? ((): Promise<readonly StatementLoopFact[]> => empty([])),
41
45
  liveQueries: facts.liveQueries ?? ((): Promise<readonly LiveQueryFact[]> => empty([])),
42
46
  subscribers: facts.subscribers ?? ((): Promise<readonly LiveSubscriberFact[]> => empty([])),
43
47
  jobDefs: facts.jobDefs ?? ((): Promise<readonly JobDefFact[]> => empty([])),
44
48
  queues: facts.queues ?? ((): Promise<readonly QueueFact[]> => empty([])),
45
49
  jobRuns: facts.jobRuns ?? ((): Promise<readonly JobRunFact[]> => empty([])),
50
+ backfills: facts.backfills ?? ((): Promise<readonly BackfillFact[]> => empty([])),
46
51
  tasks: facts.tasks ?? ((): Promise<readonly TaskFact[]> => empty([])),
47
52
  tables: facts.tables ?? ((): Promise<readonly TableFact[]> => empty([])),
48
53
  drift: facts.drift ?? ((): Promise<readonly DriftFact[]> => empty([])),
@@ -80,6 +85,13 @@ export interface DevSourceOptions {
80
85
  * wiring line, rather than rendering an empty panel that reads as "nothing happened".
81
86
  */
82
87
  readonly hooks?: Partial<DevSources>;
88
+ /**
89
+ * Sample input per query name, so the live panel can show the SQL a query actually compiles
90
+ * to. `@ultimat3/query`'s `QueryDescriptor` carries no SQL text — it depends on the input —
91
+ * so a query with no sample here stays `sql: null` rather than an invented guess. `x dev
92
+ * --actor` supplies these the same way it supplies `actors` for the policy matrix.
93
+ */
94
+ readonly sqlSamples?: Readonly<Record<string, unknown>>;
83
95
  }
84
96
 
85
97
  const unavailable = (source: string, panel: string): DevSourceUnavailableError =>
@@ -96,13 +108,22 @@ const unwired = <T>(source: string, panel: string): (() => Promise<T>) => {
96
108
  /** How many recent runs the jobs panel traces. A dev panel reads, it does not page. */
97
109
  const RUN_WINDOW = 50;
98
110
 
99
- /** `JobState` and `StepStatus` are the queue's vocabulary; the panel renders its own. */
111
+ /**
112
+ * `JobState` and `StepStatus` are the queue's vocabulary; the panel renders its own.
113
+ *
114
+ * `cancelled` is `ok` and not `dead`, because the panel's four words split on "did this need
115
+ * attention", not on "did the handler run". `panel-jobs.ts` reads `dead` as the dead-letter list
116
+ * and `failed | dead` as the needs-attention list — a job an operator stopped on purpose belongs
117
+ * in neither, and filing it under `dead` would put `x jobs retry` in front of a reader as the
118
+ * remedy for a cancellation nobody wants resurrected.
119
+ */
100
120
  const RUN_STATUS: Readonly<Record<JobState, JobRunFact['status']>> = {
101
121
  ready: 'running',
102
122
  delayed: 'running',
103
123
  running: 'running',
104
124
  suspended: 'running',
105
125
  done: 'ok',
126
+ cancelled: 'ok',
106
127
  failed: 'failed',
107
128
  dead: 'dead',
108
129
  };
@@ -146,19 +167,35 @@ export function defaultDevSources(opts: DevSourceOptions = {}): DevSources {
146
167
 
147
168
  traces: unwired<readonly RequestTrace[]>('traces', 'timeline'),
148
169
 
170
+ // The verdicts belong to the one statement ledger `x dev` installs, so a host without it
171
+ // refuses here rather than answering `[]`: an empty list claims "no N+1 in this request",
172
+ // which is a different and unearned answer — the same argument `subscribers` and `mail` make.
173
+ statementLoops: unwired<readonly StatementLoopFact[]>('statementLoops', 'timeline'),
174
+
149
175
  async liveQueries(): Promise<readonly LiveQueryFact[]> {
150
- const { describeQueries } = await import('@ultimat3/query');
151
- return listOf(describeQueries()).map((raw) => {
176
+ const { describeQueries, describeSql, listQueries } = await import('@ultimat3/query');
177
+ const queries = describeQueries();
178
+ // `describeSql` is the one place that actually compiles a query to text — it needs a
179
+ // sample input to do it, so a name with no entry in `sqlSamples` comes back `sql: null`
180
+ // rather than the permanently-empty string this source used to answer for every query.
181
+ // It takes the live `AnyQuery[]` targets (`listQueries()`), never the already-projected
182
+ // `QueryDescriptor[]` this method also needs for `name`/`live`/`capability`.
183
+ const sqlByName = new Map(
184
+ (await describeSql(opts.sqlSamples ?? {}, listQueries())).map((entry) => [
185
+ entry.query,
186
+ entry.sql,
187
+ ]),
188
+ );
189
+ return listOf(queries).map((raw) => {
152
190
  const query = bagOf(raw);
191
+ const name = str(query['name']);
153
192
  return {
154
- name: str(query['name']),
193
+ name,
155
194
  live: query['live'] === true,
156
195
  // `QueryDescriptor`'s permission field is named `capability`, not `policy` — this
157
196
  // fact keeps its own field named `policy` (that is the /_x rendering, not the registry).
158
197
  policy: str(query['capability']),
159
- // `QueryDescriptor` carries no SQL text at all; this stays blank until the query
160
- // registry actually describes one — never invent a value here.
161
- sql: str(query['sql']),
198
+ sql: sqlByName.get(name) ?? null,
162
199
  };
163
200
  });
164
201
  },
@@ -227,6 +264,31 @@ export function defaultDevSources(opts: DevSourceOptions = {}): DevSources {
227
264
  }));
228
265
  },
229
266
 
267
+ /**
268
+ * The whole ledger, newest first — not just the passes in flight. `x jobs ls` reports the live
269
+ * queue and says so; a panel is read to answer "has this backfill ever run here, and what did
270
+ * it sweep", and the completed rows ARE that answer.
271
+ *
272
+ * `inspectBackfills` returns `[]` for a driver that ships no ledger, so only a process with no
273
+ * queue at all refuses here — which is the same line `queues` and `jobRuns` draw.
274
+ */
275
+ async backfills(): Promise<readonly BackfillFact[]> {
276
+ const { inspectBackfills, jobDriver } = await import('@ultimat3/jobs');
277
+ const driver = jobDriver();
278
+ if (driver === undefined) throw unavailable('backfills', 'jobs');
279
+ return (await inspectBackfills(driver)).map((pass) => ({
280
+ runId: pass.runId,
281
+ name: pass.name,
282
+ status: pass.status,
283
+ rows: pass.rows,
284
+ cursor: pass.cursor,
285
+ startedAt: pass.startedAt,
286
+ completedAt: pass.completedAt,
287
+ durationMs: pass.durationMs,
288
+ appVersion: pass.appVersion,
289
+ }));
290
+ },
291
+
230
292
  async tasks(): Promise<readonly TaskFact[]> {
231
293
  const { inspectManifest } = await import('@ultimat3/jobs');
232
294
  return inspectManifest().tasks.map((task) => ({
package/src/dev/facts.ts CHANGED
@@ -39,6 +39,28 @@ export interface RequestTrace {
39
39
  readonly spans: readonly TimelineSpan[];
40
40
  }
41
41
 
42
+ /**
43
+ * One statement shape repeated inside one request past the detector's threshold — a verdict,
44
+ * already carrying the error a host renders. The count, the attribution and the suppression rule
45
+ * are the detector's (`x dev`'s statement ledger); nothing here re-derives them from the spans,
46
+ * because a second count blind to `expectedQueryLoop` would disagree with the one that warns.
47
+ */
48
+ export interface StatementLoopFact {
49
+ /** The request the loop happened in — how a trace and its verdicts are matched up. */
50
+ readonly requestId: string;
51
+ /** `X_N_PLUS_ONE_QUERY` or `X_N_PLUS_ONE_WRITE`. */
52
+ readonly code: string;
53
+ readonly cause: string;
54
+ /** Runnable, and the whole point: the `preload`/`insertAll` line that ends the loop. */
55
+ readonly fix: string;
56
+ readonly docs: string | null;
57
+ /** What repeated: `members.findById` when a repository sent it, else the statement's own text. */
58
+ readonly subject: string;
59
+ readonly count: number;
60
+ /** One of the statements, verbatim. */
61
+ readonly sample: string;
62
+ }
63
+
42
64
  export interface LiveSubscriberFact {
43
65
  readonly id: string;
44
66
  readonly query: string;
@@ -54,7 +76,8 @@ export interface LiveQueryFact {
54
76
  readonly name: string;
55
77
  readonly live: boolean;
56
78
  readonly policy: string;
57
- readonly sql: string;
79
+ /** `null` when no sample input was supplied for this query — SQL depends on arguments. */
80
+ readonly sql: string | null;
58
81
  }
59
82
 
60
83
  export interface JobDefFact {
@@ -97,6 +120,28 @@ export interface TaskFact {
97
120
  readonly nextRunAt: string | null;
98
121
  }
99
122
 
123
+ /**
124
+ * One `x_backfills` pass. The queue's own facts answer "is this job moving"; only the ledger
125
+ * answers "how much of the table is behind it", which is the one question a sweep is watched for.
126
+ * `cursor` is where the pass had got to and `null` before its first batch — never a percentage:
127
+ * nothing counted the rows ahead of it, and a made-up denominator is the fact this panel exists
128
+ * not to invent.
129
+ */
130
+ export interface BackfillFact {
131
+ /** The pass, and the run id of the job sweeping it — the join back to `runs` on this panel. */
132
+ readonly runId: string;
133
+ readonly name: string;
134
+ readonly status: 'running' | 'completed' | 'failed';
135
+ readonly rows: number;
136
+ readonly cursor: string | null;
137
+ readonly startedAt: string;
138
+ readonly completedAt: string | null;
139
+ /** How long the pass took. `null` while it is still running. */
140
+ readonly durationMs: number | null;
141
+ /** The build that STARTED the pass — a redeploy mid-sweep does not rewrite it. */
142
+ readonly appVersion: string;
143
+ }
144
+
100
145
  export interface ColumnFact {
101
146
  readonly name: string;
102
147
  readonly type: string;
@@ -163,11 +208,13 @@ export interface ManifestFact {
163
208
  export interface DevSources {
164
209
  routes(): Promise<readonly RouteFact[]>;
165
210
  traces(): Promise<readonly RequestTrace[]>;
211
+ statementLoops(): Promise<readonly StatementLoopFact[]>;
166
212
  liveQueries(): Promise<readonly LiveQueryFact[]>;
167
213
  subscribers(): Promise<readonly LiveSubscriberFact[]>;
168
214
  jobDefs(): Promise<readonly JobDefFact[]>;
169
215
  queues(): Promise<readonly QueueFact[]>;
170
216
  jobRuns(): Promise<readonly JobRunFact[]>;
217
+ backfills(): Promise<readonly BackfillFact[]>;
171
218
  tasks(): Promise<readonly TaskFact[]>;
172
219
  tables(): Promise<readonly TableFact[]>;
173
220
  drift(): Promise<readonly DriftFact[]>;
package/src/dev/index.ts CHANGED
@@ -5,6 +5,7 @@
5
5
 
6
6
  export { type DevSourceOptions, defaultDevSources, staticDevSources } from './data';
7
7
  export type {
8
+ BackfillFact,
8
9
  CacheEdgeFact,
9
10
  ColumnFact,
10
11
  DevSources,
@@ -23,6 +24,7 @@ export type {
23
24
  RouteFact,
24
25
  SpanKind,
25
26
  SqlResult,
27
+ StatementLoopFact,
26
28
  TableFact,
27
29
  TaskFact,
28
30
  TimelineSpan,
@@ -44,4 +46,5 @@ export {
44
46
  type DevDashboard,
45
47
  type DevDashboardOptions,
46
48
  devDashboard,
49
+ devShellStyle,
47
50
  } from './server';