@ultimat3/admin 9.0.0 → 10.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
@@ -28,6 +28,60 @@ Two products, one package, **two entry points**: `@ultimat3/admin/dev` (`src/dev
28
28
  - **`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 `/`.
29
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.
30
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.
31
+ - **`assertReadOnly` answers a VERDICT carrying the runnable string, and the panel executes THAT.**
32
+ `assertReadOnlyQuery` documents that what it returns is what the caller must execute — every
33
+ check ran on a stripped form and the return is the reconciled one — and `panel-db.ts` discarded
34
+ it and ran the textarea's bytes. Benign only for as long as `verbatim()` normalises nothing more
35
+ than a trailing `;`, which is a promise no other file is keeping; `@ultimat3/mcp`'s own
36
+ `dev-server.ts` honours the contract, and a `string | null` return here made it impossible to.
37
+ `ReadOnlyVerdict` is exported from `@ultimat3/admin/dev` beside it.
38
+ - **`decideAll([])` DENIES.** `permissions[length - 1] ?? ''` fell through to `allowed('')`, so a
39
+ declared-but-empty gate opened for every actor, anonymous included, and named no permission at
40
+ all. `visibleNav` hands an author's `item.permissions` straight to it, so `permissions: []` on a
41
+ nav item was that gate. `pages.ts` already refuses an empty PAGE list at declaration
42
+ (`X_ADMIN_PAGE_UNGUARDED`); this is the same rule at the seam every surface shares, which is
43
+ where the ones that never pass through `defineAdmin` are decided. Reason
44
+ `admin.policy.none-declared`.
45
+ - **Two resources may not claim one `path:`** — `assertUniqueResourcePaths` in `admin.ts`, refused
46
+ at `defineAdmin` with `X_ADMIN_PAGE_PATH_INVALID` (`subject: 'resource'`). `adminRouteFor`
47
+ resolves by `.find()`, so a duplicate produced EIGHT routes over FOUR paths and the second
48
+ resource's four screens were unreachable, silently, with the dashboard rendering. Identical
49
+ argument to the duplicate action NAME one line below it and to `pages.ts`'s shadow check — the
50
+ same `taken` set, one step earlier. **A currently-booting app with a duplicate path now refuses
51
+ at boot.**
52
+ - **The audit diff is TOTAL over every value a row holds.** `same()` compared with
53
+ `JSON.stringify`, which THROWS on a bigint — and `money()` puts one on the row
54
+ (`widget-value.ts`), so every update of a money-bearing row raised, AFTER `repo.update()` had
55
+ committed: the write landed, the caller got an uncoded `TypeError`, and the log stayed empty.
56
+ `canonicalJson` from `@ultimat3/core`, the same answer `packages/manifest/src/diff-routes.ts`
57
+ gives to the same question. `crud.test.ts`'s fixture entity carries a `money()` column and its
58
+ repo CLONES on read, because two reads of one row are two objects and a shared reference
59
+ short-circuits the comparison the diff exists to make.
60
+ - **A repo that THROWS leaves a `failed` entry.** `AuditOutcome` declared the member and `crud.ts`
61
+ emitted it in exactly one place — `invalid()`, for a validation issue — so a constraint
62
+ violation, a statement that timed out after committing and a dropped connection each left nothing
63
+ at all. `auditedWrite` wraps the three repo writes: try, record, re-throw UNCHANGED. A mutation
64
+ cannot append BEFORE the call the way `search.ts` does for a read; that would record a write
65
+ which may never have happened. The reason is a key (`admin.audit.write-failed`), never anything
66
+ read off the thrown value.
67
+ - **Nothing is read off a caught value in `action-gate.ts`, and the append runs first.** It built an
68
+ `AdminDecision` whose `trace` was `String(error)` — and a `catch` binding is annotated by nobody,
69
+ so `Object.create(null)` raised `TypeError: No default value` from inside the block that owed the
70
+ auditor an entry: measured, ZERO entries and the caller received the TypeError. The decision
71
+ object was DEAD anyway (only its `reason` was ever read; `append` takes no trace), so there is no
72
+ destination for a rendered value and `renderThrowable` is not needed either.
73
+ - **`decideAll`'s and `/_x`'s record indexing is `Object.hasOwn` / a `Map`, never a bare index.**
74
+ `ADMIN_PERMISSION_SPEC[permission]` consulted the PROTOTYPE CHAIN, so a polluted
75
+ `Object.prototype` gave any granted permission an `implies` the table never declared — and
76
+ `expandPermissions` walks it. `panel-cache.ts`, `panel-routes.ts` and `panel-policy.ts` counted
77
+ into plain objects, where `__proto__` reads a prototype (so `?? 0` never fires) and WRITES through
78
+ the setter, dropping the row: the policy matrix reported a permission unreachable while an actor
79
+ held it. `Map` + `Object.fromEntries`, which DEFINES each key.
80
+ - **The locale picker reads `registeredLocales()`, not a bundled list.** It was
81
+ `['en','es','de','fr','pt','ja']`, one line under a comment forbidding exactly that for IANA
82
+ zones: an app registering `it` could not pick it, and an app with only `en` was offered five
83
+ locales it renders `⟦key⟧` for. No fallback — an app with no catalog has no locale to offer, and
84
+ inventing one is the admin declaring what the app did not.
31
85
  - **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.
32
86
  - **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.
33
87
  - Money through `assertMoney`, timestamps through `assertZone`, pagination through `pagination.ts`. No `offset`, ever.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ultimat3/admin",
3
- "version": "9.0.0",
3
+ "version": "10.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": "9.0.0",
36
- "@ultimat3/ai": "9.0.0",
37
- "@ultimat3/cache": "9.0.0",
38
- "@ultimat3/core": "9.0.0",
39
- "@ultimat3/db": "9.0.0",
40
- "@ultimat3/entity": "9.0.0",
41
- "@ultimat3/i18n": "9.0.0",
42
- "@ultimat3/jobs": "9.0.0",
43
- "@ultimat3/mcp": "9.0.0",
44
- "@ultimat3/money": "9.0.0",
45
- "@ultimat3/policy": "9.0.0",
46
- "@ultimat3/query": "9.0.0",
47
- "@ultimat3/render": "9.0.0",
48
- "@ultimat3/schema": "9.0.0",
49
- "@ultimat3/ui": "9.0.0"
35
+ "@ultimat3/action": "10.0.0",
36
+ "@ultimat3/ai": "10.0.0",
37
+ "@ultimat3/cache": "10.0.0",
38
+ "@ultimat3/core": "10.0.0",
39
+ "@ultimat3/db": "10.0.0",
40
+ "@ultimat3/entity": "10.0.0",
41
+ "@ultimat3/i18n": "10.0.0",
42
+ "@ultimat3/jobs": "10.0.0",
43
+ "@ultimat3/mcp": "10.0.0",
44
+ "@ultimat3/money": "10.0.0",
45
+ "@ultimat3/policy": "10.0.0",
46
+ "@ultimat3/query": "10.0.0",
47
+ "@ultimat3/render": "10.0.0",
48
+ "@ultimat3/schema": "10.0.0",
49
+ "@ultimat3/ui": "10.0.0"
50
50
  }
51
51
  }
@@ -24,6 +24,12 @@ export interface AdminActionButton {
24
24
  readonly decision: AdminDecision;
25
25
  }
26
26
 
27
+ /**
28
+ * The reason on a `failed` action entry. A key the view renders — never a sentence carried out of
29
+ * a caught value, which is how a database message or an attacker's string reaches an audit log.
30
+ */
31
+ const ACTION_FAILED_REASON = 'admin.error.action-failed';
32
+
27
33
  /** The permissions an action needs: the admin-level gate, then the action's own policy. */
28
34
  export function permissionsForAction<Input, Output>(
29
35
  action: AdminAction<Input, Output>,
@@ -179,12 +185,20 @@ export async function invokeAdminAction<Input, Output>(
179
185
  }),
180
186
  };
181
187
  } catch (error) {
182
- const failed: AdminDecision = {
183
- allowed: false,
184
- permission: action.permission,
185
- reason: 'admin.error.action-failed',
186
- trace: [error instanceof Error ? `${error.name}: ${error.message}` : String(error)],
187
- };
188
+ // NOTHING is read off `error` before the append, and nothing is read off it at all.
189
+ //
190
+ // This built an `AdminDecision` first, whose `trace` rendered the caught value with
191
+ // `String(error)` — and a `catch` binding is annotated by nobody, so it holds whatever an
192
+ // app's handler threw. `Object.create(null)` has no `toString`, no `valueOf` and no
193
+ // `Symbol.toPrimitive`, so `String(it)` raises `TypeError: No default value` from inside the
194
+ // block that owes the auditor an entry: measured, ZERO entries, and the caller received the
195
+ // TypeError instead of what was thrown. Ordering was the second half — the render ran BEFORE
196
+ // `append`, so its throw skipped the append rather than merely spoiling one field.
197
+ //
198
+ // The decision object was also DEAD: only its `reason` was ever read, and `append` takes no
199
+ // trace. So there is no destination for a rendered value here and `renderThrowable` is not
200
+ // needed either — an audit reason is a key the view renders, never a sentence from a
201
+ // database, an upstream or an attacker.
188
202
  await audit.append({
189
203
  requestId,
190
204
  actor,
@@ -194,7 +208,7 @@ export async function invokeAdminAction<Input, Output>(
194
208
  entityId,
195
209
  permission: action.permission,
196
210
  outcome: 'failed',
197
- reason: failed.reason,
211
+ reason: ACTION_FAILED_REASON,
198
212
  diff: [],
199
213
  });
200
214
  throw error;
package/src/admin.ts CHANGED
@@ -6,7 +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
+ import { AdminActionDuplicateError, AdminPagePathInvalidError } from './errors';
10
10
  import { adminNav, type NavGroup, type NavItem, type NavOptions, visibleNav } from './nav';
11
11
  import { type AdminCustomPage, type AdminPageComponent, pageNavItems, pageRoutes } from './pages';
12
12
  import type { AdminOperation } from './permissions';
@@ -125,6 +125,37 @@ function resourceRoutes(basePath: string, resource: AdminResource): readonly Adm
125
125
  return routes;
126
126
  }
127
127
 
128
+ /**
129
+ * One URL, one claimant — checked across RESOURCES, which was the last claim on an admin path
130
+ * that nothing verified.
131
+ *
132
+ * `adminRouteFor` resolves by `.find()`, so two resources declaring one `path:` produced eight
133
+ * routes over four paths and the second resource's four screens were simply unreachable: the app
134
+ * booted, the dashboard rendered, and nothing said so. That is the identical argument
135
+ * `assertUniqueActionNames` below makes for a duplicate action name, and the one `pages.ts` makes
136
+ * for a page shadowing a generated route — the same `taken` set, one step earlier.
137
+ *
138
+ * Every generated path is collected, not just the list root: `/posts` and `/posts/:id` are two
139
+ * claims and a resource colliding on either is the same broken route table.
140
+ */
141
+ function assertUniqueResourcePaths(basePath: string, resources: readonly AdminResource[]): void {
142
+ const claimed = new Map<string, string>();
143
+ for (const resource of resources) {
144
+ for (const route of resourceRoutes(basePath, resource)) {
145
+ const owner = claimed.get(route.path);
146
+ if (owner !== undefined) {
147
+ throw new AdminPagePathInvalidError({
148
+ subject: 'resource',
149
+ path: route.path,
150
+ cause: `is already claimed by the resource "${owner}", so "${resource.name}" would be unreachable there`,
151
+ fix: `give one of them its own path: resources: { ${resource.name}: { path: '${resource.path}-2' } }`,
152
+ });
153
+ }
154
+ claimed.set(route.path, resource.name);
155
+ }
156
+ }
157
+ }
158
+
128
159
  /**
129
160
  * One `AdminAction.name`, one handler. The name is the MCP tool name (`admin.action.<name>`), the
130
161
  * default label key (`admin.action.<name>`) AND the key `callAdminTool` resolves a handler by, so
@@ -182,6 +213,7 @@ export function defineAdmin(input: DefineAdminInput): AdminApp {
182
213
  ...pageNavItems(pages),
183
214
  ];
184
215
  const nav = adminNav(resources, { ...navOptions, extra });
216
+ assertUniqueResourcePaths(basePath, resources);
185
217
  const generated: AdminRoute[] = [
186
218
  {
187
219
  path: basePath,
package/src/audit.ts CHANGED
@@ -2,6 +2,7 @@
2
2
  // If it isn't logged, it didn't happen — so denied and failed attempts are logged too, and
3
3
  // there is deliberately no update or delete on this interface.
4
4
 
5
+ import { canonicalJson } from '@ultimat3/core';
5
6
  import type { AdminActor, AdminDecision } from './authz';
6
7
  import type { AdminRow } from './registry';
7
8
 
@@ -117,14 +118,25 @@ export function memoryAuditLog(opts: AuditLogOptions = {}): AuditLog {
117
118
  };
118
119
  }
119
120
 
121
+ /**
122
+ * "Is this field unchanged?", TOTAL over every value a row can hold.
123
+ *
124
+ * `JSON.stringify(a) === JSON.stringify(b)` was neither: it THROWS on a bigint, and `money()` puts
125
+ * one on the row (`widget-value.ts` — Postgres `bigint` minor units). Two distinct
126
+ * `{ minor, currency }` objects are never `===`, so every update of a money-bearing row reached
127
+ * that branch and raised. `crud.ts` calls `diffRows` inside the argument to `ctx.audit.append`,
128
+ * AFTER `repo.update()` has committed — so the write landed, the caller got an uncoded
129
+ * `TypeError`, and the audit log recorded nothing at all.
130
+ *
131
+ * `canonicalJson` is tier 0, already a dependency, and already this repo's answer to exactly this
132
+ * question (`packages/manifest/src/diff-routes.ts` asks it of a route descriptor). It is injective
133
+ * per type, so `1000n` and `1000` stay two values rather than folding into one unchanged field.
134
+ */
120
135
  const same = (a: unknown, b: unknown): boolean => {
121
136
  if (a === b) return true;
122
137
  if (a instanceof Date && b instanceof Date) return a.getTime() === b.getTime();
123
138
  if (a === null || b === null || a === undefined || b === undefined) return false;
124
- if (typeof a === 'object' && typeof b === 'object') {
125
- return JSON.stringify(a) === JSON.stringify(b);
126
- }
127
- return false;
139
+ return canonicalJson(a) === canonicalJson(b);
128
140
  };
129
141
 
130
142
  /**
package/src/authz.ts CHANGED
@@ -78,13 +78,28 @@ export function denied(
78
78
  return { allowed: false, permission, reason, trace };
79
79
  }
80
80
 
81
- /** Every permission must hold. The first denial wins, and carries its own reason. */
81
+ /**
82
+ * Every permission must hold. The first denial wins, and carries its own reason.
83
+ *
84
+ * An EMPTY list is refused, never granted. `permissions[length - 1] ?? ''` used to fall through to
85
+ * `allowed('')`, so a declared-but-empty gate opened for every actor, anonymous included — and the
86
+ * decision it returned named no permission at all. `visibleNav` hands an author's
87
+ * `item.permissions` straight here, so `permissions: []` on a nav item was that gate. `pages.ts`
88
+ * already refuses an empty page list at declaration time (`X_ADMIN_PAGE_UNGUARDED`); this is the
89
+ * same rule at the seam every surface shares, which is where the ones that never pass through
90
+ * `defineAdmin` are decided.
91
+ */
82
92
  export function decideAll(
83
93
  authz: AdminAuthz,
84
94
  permissions: readonly string[],
85
95
  actor: AdminActor,
86
96
  subject?: AdminSubject,
87
97
  ): AdminDecision {
98
+ if (permissions.length === 0) {
99
+ return denied('', 'admin.policy.none-declared', [
100
+ 'no permission was declared for this surface, so there is nothing to satisfy',
101
+ ]);
102
+ }
88
103
  const trace: string[] = [];
89
104
  for (const permission of permissions) {
90
105
  const decision = authz.decide(
@@ -108,7 +123,13 @@ export function isAllowed(
108
123
  }
109
124
 
110
125
  const impliedBy = (permission: string): readonly string[] => {
111
- // Widened: `permission` is any string at runtime, so the lookup really can miss.
126
+ // `Object.hasOwn` first, never the bare index read. `permission` is any string at runtime and
127
+ // the table is a plain object, so the read consulted the PROTOTYPE CHAIN: an app that merges
128
+ // untrusted JSON (the ordinary prototype-pollution shape) could give any granted permission an
129
+ // `implies` the spec table never declared, and `expandPermissions` walks it. Fourth instance of
130
+ // the class in the framework, after i18n's catalog lookup, schema's `coerce` and mcp's
131
+ // `validate-args`. The cast was the tell that the key is not known to be a member.
132
+ if (!Object.hasOwn(ADMIN_PERMISSION_SPEC, permission)) return [];
112
133
  const spec: { readonly implies: readonly string[] } | undefined =
113
134
  ADMIN_PERMISSION_SPEC[permission as AdminPermission];
114
135
  return spec?.implies ?? [];
package/src/crud.ts CHANGED
@@ -173,6 +173,51 @@ export async function adminDetail<Row extends AdminRow>(
173
173
  };
174
174
  }
175
175
 
176
+ /** The reason on a `failed` entry. A key, never the database's message. */
177
+ const WRITE_FAILED_REASON = 'admin.audit.write-failed';
178
+
179
+ /**
180
+ * Run a repo WRITE, and leave a `failed` entry behind if it throws.
181
+ *
182
+ * `AuditOutcome` has declared a `failed` member all along and this file emitted it in exactly one
183
+ * place — `invalid()`, for a VALIDATION issue. A constraint violation, a statement that timed out
184
+ * after committing, a connection dropped mid-write: each left no entry at all, which is the case
185
+ * an auditor opens the log for. Both siblings already do this and each states the rule
186
+ * (`search.ts`, `action-gate.ts`).
187
+ *
188
+ * A mutation cannot append BEFORE the call the way a read does — that would record a write which
189
+ * may never have happened. So: try, record, re-throw UNCHANGED. Nothing about the thrown value is
190
+ * read or rendered; the caller owns it, and an audit reason is a key, not a database message.
191
+ */
192
+ async function auditedWrite<T>(
193
+ // Only the NAME is read, so this stays invariance-free: `AdminResource<Row>` at four call
194
+ // sites would need the generic threaded through for nothing.
195
+ resource: { readonly name: string },
196
+ op: AdminOperation,
197
+ ctx: CrudCtx,
198
+ entityId: string | null,
199
+ decision: AdminDecision,
200
+ run: () => Promise<T>,
201
+ ): Promise<T> {
202
+ try {
203
+ return await run();
204
+ } catch (error) {
205
+ await ctx.audit.append({
206
+ requestId: ctx.requestId,
207
+ actor: ctx.actor,
208
+ operation: op,
209
+ kind: 'operation',
210
+ entity: resource.name,
211
+ entityId,
212
+ permission: decision.permission,
213
+ outcome: 'failed',
214
+ reason: WRITE_FAILED_REASON,
215
+ diff: [],
216
+ });
217
+ throw error;
218
+ }
219
+ }
220
+
176
221
  export async function adminCreate<Row extends AdminRow>(
177
222
  resource: AdminResource<Row>,
178
223
  ctx: CrudCtx,
@@ -184,7 +229,9 @@ export async function adminCreate<Row extends AdminRow>(
184
229
  const parsed = await validateInput(resource.entity.$schema, input);
185
230
  if (!parsed.ok) return invalid(resource, 'create', ctx, null, parsed.issues, decision);
186
231
 
187
- const row = await repoOf(resource).create(parsed.value);
232
+ const row = await auditedWrite(resource, 'create', ctx, null, decision, () =>
233
+ repoOf(resource).create(parsed.value),
234
+ );
188
235
  return {
189
236
  ok: true,
190
237
  row,
@@ -234,7 +281,9 @@ export async function adminUpdate<Row extends AdminRow>(
234
281
  .filter((key) => Object.hasOwn(parsed.value, key))
235
282
  .map((key) => [key, parsed.value[key]]),
236
283
  );
237
- const after = await repo.update(id, validatedPatch);
284
+ const after = await auditedWrite(resource, 'update', ctx, id, decision, () =>
285
+ repo.update(id, validatedPatch),
286
+ );
238
287
  return {
239
288
  ok: true,
240
289
  row: after,
@@ -282,7 +331,7 @@ export async function adminDestroy<Row extends AdminRow>(
282
331
  );
283
332
  }
284
333
 
285
- await repo.destroy(id);
334
+ await auditedWrite(resource, 'delete', ctx, id, decision, () => repo.destroy(id));
286
335
  return {
287
336
  ok: true,
288
337
  row: null,
package/src/dev/index.ts CHANGED
@@ -31,7 +31,7 @@ export type {
31
31
  } from './facts';
32
32
  export { type DevPanel, type PanelPayload, panelPayload } from './panel';
33
33
  export { type CachePanelData, cachePanel } from './panel-cache';
34
- export { assertReadOnly, type DbPanelData, dbPanel } from './panel-db';
34
+ export { assertReadOnly, type DbPanelData, dbPanel, type ReadOnlyVerdict } from './panel-db';
35
35
  export { type JobsPanelData, jobsPanel } from './panel-jobs';
36
36
  export { type LivePanelData, livePanel } from './panel-live';
37
37
  export { type MailPanelData, mailPanel } from './panel-mail';
@@ -30,10 +30,16 @@ export const cachePanel: DevPanel<CachePanelData> = {
30
30
  .catch((): readonly InvalidationFact[] => []);
31
31
 
32
32
  const busted = new Set(invalidations.flatMap((event) => event.busted));
33
- const byKind: Record<string, number> = {};
33
+ // A `Map`, then `Object.fromEntries` — never `count[key] = (count[key] ?? 0) + 1` on a plain
34
+ // object. `dep.kind` is a plain `string` in the fact type, so `__proto__` reaches it: the read
35
+ // answers `Object.prototype` (so `?? 0` never fires) and the write runs the setter, which
36
+ // re-prototypes the record instead of adding a key and drops the row from the panel. A `Map`
37
+ // has no prototype chain to consult, and `fromEntries` DEFINES each key rather than assigning.
38
+ const counts = new Map<string, number>();
34
39
  for (const edge of graph) {
35
- for (const dep of edge.dependents) byKind[dep.kind] = (byKind[dep.kind] ?? 0) + 1;
40
+ for (const dep of edge.dependents) counts.set(dep.kind, (counts.get(dep.kind) ?? 0) + 1);
36
41
  }
42
+ const byKind = Object.fromEntries(counts);
37
43
 
38
44
  return {
39
45
  graph,
@@ -78,13 +78,28 @@ function sanitize(sql: string): string {
78
78
  * What stays local is the emptiness test and the WAY OUT: `@ultimat3/mcp` tells its caller to
79
79
  * expose an action, which is not advice a developer standing at `/_x` can act on. Only this file
80
80
  * knows which reader it is talking to.
81
+ *
82
+ * The verdict carries the RUNNABLE STRING, never just a yes. `assertReadOnlyQuery` documents that
83
+ * what it returns is what the caller must execute — every check it made ran on a stripped form,
84
+ * and the return is the reconciled one. This panel threw that value away and executed the
85
+ * textarea's own bytes, so the two callers of one guard disagreed about which string runs.
86
+ * `@ultimat3/mcp`'s own `dev-server.ts` honours the contract; a `string | null` return here made
87
+ * it impossible to.
81
88
  */
82
- export function assertReadOnly(sql: string): string | null {
83
- // Nothing to run — a blank box or a comment the developer is still writing, not a refusal.
84
- if (sanitize(sql).trim() === '') return null;
89
+ export type ReadOnlyVerdict =
90
+ /** Safe to execute — and `sql` is the string to execute, not the one that was typed. */
91
+ | { readonly kind: 'runnable'; readonly sql: string }
92
+ /** The sentence to show instead of a result grid. */
93
+ | { readonly kind: 'refused'; readonly refused: string };
94
+
95
+ export function assertReadOnly(sql: string): ReadOnlyVerdict {
96
+ // Nothing to run — a blank box or a comment the developer is still writing, not a refusal, and
97
+ // not something the guard can hand a statement back for either (it refuses an empty one). The
98
+ // caller's own text goes through: a comment against a database is a no-op, and flashing a
99
+ // refusal mid-keystroke is the failure this branch exists to avoid.
100
+ if (sanitize(sql).trim() === '') return { kind: 'runnable', sql };
85
101
  try {
86
- assertReadOnlyQuery(sql);
87
- return null;
102
+ return { kind: 'runnable', sql: assertReadOnlyQuery(sql) };
88
103
  } catch (error) {
89
104
  // Structurally, never `String(error)`: the guard throws an `UltimateError` whose `cause` is
90
105
  // the sentence a developer needs, and anything else here is a bug in the guard, not a verdict.
@@ -93,7 +108,10 @@ export function assertReadOnly(sql: string): string | null {
93
108
  // another package's prose — so the way out is phrased to be true for both. It used to assert
94
109
  // `Run it with: x db psql --write`, which is the wrong instruction for a missing quote: that
95
110
  // flag grants writes, it does not close a delimiter.
96
- return `refused: ${error.cause}. Fix the statement, or — if it is meant to write — run it with: x db psql --write`;
111
+ return {
112
+ kind: 'refused',
113
+ refused: `refused: ${error.cause}. Fix the statement, or — if it is meant to write — run it with: x db psql --write`,
114
+ };
97
115
  }
98
116
  }
99
117
 
@@ -117,13 +135,15 @@ export const dbPanel: DevPanel<DbPanelData> = {
117
135
  return { tables, drift, sql: null, result: null, refused: null, readOnly: true };
118
136
  }
119
137
 
120
- const refused = assertReadOnly(sql);
138
+ // `verdict.sql`, never `sql`: what the guard proved read-only is what runs. `sql` below is
139
+ // what stays in the textarea for the operator to edit, which is a different question.
140
+ const verdict = assertReadOnly(sql);
121
141
  return {
122
142
  tables,
123
143
  drift,
124
144
  sql,
125
- result: refused === null ? await sources.runSql(sql) : null,
126
- refused,
145
+ result: verdict.kind === 'runnable' ? await sources.runSql(verdict.sql) : null,
146
+ refused: verdict.kind === 'refused' ? verdict.refused : null,
127
147
  readOnly: true,
128
148
  };
129
149
  },
@@ -32,12 +32,17 @@ export const policyPanel: DevPanel<PolicyPanelData> = {
32
32
  const permissions = [...new Set(facts.map((fact) => fact.permission))].sort();
33
33
 
34
34
  const matrix = permissions.map((permission) => {
35
- const byActor: Record<string, boolean> = {};
36
- for (const actor of actors) {
37
- byActor[actor] =
35
+ // `Object.fromEntries`, never `byActor[actor] = …`: an actor id is a plain string, so
36
+ // `__proto__` reaches the index, and the assignment ran the prototype's SETTER instead of
37
+ // adding a key. The cell then vanished from `Object.values(byActor)` and the permission was
38
+ // reported unreachable — held by nobody — while an actor held it.
39
+ const byActor = Object.fromEntries(
40
+ actors.map((actor): readonly [string, boolean] => [
41
+ actor,
38
42
  facts.find((fact) => fact.permission === permission && fact.actorId === actor)?.allowed ??
39
- false;
40
- }
43
+ false,
44
+ ]),
45
+ );
41
46
  return { permission, byActor };
42
47
  });
43
48
 
@@ -29,10 +29,12 @@ export const routesPanel: DevPanel<RoutesPanelData> = {
29
29
  questionKey: 'dev.panel.routes.question',
30
30
  async data(sources): Promise<RoutesPanelData> {
31
31
  const routes = await sources.routes();
32
- const byRenderMode: Record<string, number> = {};
33
- for (const route of routes) {
34
- byRenderMode[route.render] = (byRenderMode[route.render] ?? 0) + 1;
35
- }
32
+ // A `Map`, then `Object.fromEntries`. See `panel-cache.ts` for why the plain-object counter is
33
+ // wrong: an inherited name reads a prototype value instead of `undefined`, and `__proto__`
34
+ // writes through the setter rather than adding a key.
35
+ const counts = new Map<string, number>();
36
+ for (const route of routes) counts.set(route.render, (counts.get(route.render) ?? 0) + 1);
37
+ const byRenderMode = Object.fromEntries(counts);
36
38
  return {
37
39
  routes: [...routes].sort((a, b) => a.path.localeCompare(b.path)),
38
40
  byRenderMode,
package/src/errors.ts CHANGED
@@ -61,7 +61,12 @@ registerErrorCodes(
61
61
  Object.fromEntries(Object.entries(ADMIN_ERROR_TITLES).map(([code, title]) => [code, { title }])),
62
62
  );
63
63
 
64
- const docsFor = (code: AdminErrorCode): string => `https://ultimate.dev/errors/${code}`;
64
+ // No `docs:` on the subclasses below. `UltimateError` fills it from `describeErrorCode(code).docs`,
65
+ // which is `@ultimat3/core`'s `ERROR_DOCS_URL` — one page for every code, never one per code, because
66
+ // `wiki/` is the framework's only public documentation surface and a code lives there in a TABLE ROW,
67
+ // which has no anchor. The `https://ultimate.dev/errors/<code>` links this file built until 9.x
68
+ // answered 404, host included, on every error it has ever thrown; restating the replacement here
69
+ // would be the same constant in eight places waiting to drift again.
65
70
 
66
71
  /** A resource, nav item, or MCP tool named an entity the registry does not have. */
67
72
  export class AdminEntityUnknownError extends UltimateError {
@@ -74,7 +79,6 @@ export class AdminEntityUnknownError extends UltimateError {
74
79
  input.known.length > 0 ? input.known.join(', ') : 'none'
75
80
  })`,
76
81
  fix: `x g entity ${input.entity} # then: x manifest`,
77
- docs: docsFor('X_ADMIN_ENTITY_UNKNOWN'),
78
82
  });
79
83
  }
80
84
  }
@@ -90,7 +94,6 @@ export class AdminFieldUnsupportedError extends UltimateError {
90
94
  code: 'X_ADMIN_FIELD_UNSUPPORTED',
91
95
  cause: `${input.entity}.${input.field}: ${input.cause}`,
92
96
  fix: input.fix,
93
- docs: docsFor('X_ADMIN_FIELD_UNSUPPORTED'),
94
97
  });
95
98
  }
96
99
  }
@@ -109,7 +112,6 @@ export class AdminActionDuplicateError extends UltimateError {
109
112
  // The convention the framework's own examples already follow, made into the instruction:
110
113
  // an entity-qualified name is unique by construction.
111
114
  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
115
  });
114
116
  }
115
117
  }
@@ -129,7 +131,6 @@ export class AdminPolicyMissingError extends UltimateError {
129
131
  code: 'X_ADMIN_POLICY_MISSING',
130
132
  cause: `${input.kind} "${input.subject}" is exposed in the admin with no policy`,
131
133
  fix: `add \`policy: can('<resource>:<verb>')\` to the ${input.kind} "${input.subject}" — a permission your definePermissions() call declares, never the ${input.kind}'s own name`,
132
- docs: docsFor('X_ADMIN_POLICY_MISSING'),
133
134
  });
134
135
  }
135
136
  }
@@ -145,19 +146,23 @@ export class AdminPageUnguardedError extends UltimateError {
145
146
  code: 'X_ADMIN_PAGE_UNGUARDED',
146
147
  cause: `the admin page "${input.path}" declares no permissions, so nothing gates it`,
147
148
  fix: `add permissions: ['${input.path.replace(/^\//, '').split('/')[0] ?? 'ops'}:read'] to the pages entry for "${input.path}"`,
148
- docs: docsFor('X_ADMIN_PAGE_UNGUARDED'),
149
149
  });
150
150
  }
151
151
  }
152
152
 
153
153
  /** A page path that cannot be mounted: not rooted, malformed, or already served. */
154
154
  export class AdminPagePathInvalidError extends UltimateError {
155
- constructor(input: { path: string; cause: string; fix: string }) {
155
+ /**
156
+ * `subject` names WHICH declaration owns the bad path — `'page'` by default, `'resource'` when
157
+ * two resources claim one URL. One code, because it is one failure (an admin URL with more than
158
+ * one claimant), and a reader told "page path" while looking at a `resources:` entry goes to the
159
+ * wrong file.
160
+ */
161
+ constructor(input: { path: string; cause: string; fix: string; subject?: string }) {
156
162
  super({
157
163
  code: 'X_ADMIN_PAGE_PATH_INVALID',
158
- cause: `the admin page path "${input.path}" ${input.cause}`,
164
+ cause: `the admin ${input.subject ?? 'page'} path "${input.path}" ${input.cause}`,
159
165
  fix: input.fix,
160
- docs: docsFor('X_ADMIN_PAGE_PATH_INVALID'),
161
166
  });
162
167
  }
163
168
  }
@@ -178,7 +183,6 @@ export class DevSourceUnavailableError extends UltimateError {
178
183
  // that call site passes its own `wiring` rather than render `hooks: { authz + actors }`,
179
184
  // which is not syntax an agent could run.
180
185
  fix: `devDashboard({ sources: defaultDevSources(${input.wiring ?? `{ hooks: { ${input.source} } }`}) })`,
181
- docs: docsFor('X_NOT_IMPLEMENTED'),
182
186
  });
183
187
  }
184
188
  }
@@ -190,7 +194,6 @@ export class DevDashboardInProdError extends UltimateError {
190
194
  code: 'X_DEV_DASHBOARD_IN_PROD',
191
195
  cause: `devDashboard() was called with role="${input.role}" env="${input.env}"; /_x exposes SQL, policy traces, and caught mail`,
192
196
  fix: 'delete the /_x mount from the production entrypoint; run `x dev` locally instead',
193
- docs: docsFor('X_DEV_DASHBOARD_IN_PROD'),
194
197
  });
195
198
  }
196
199
  }
package/src/mcp.ts CHANGED
@@ -307,7 +307,17 @@ function toMcpTool(opts: AdminMcpOptions, requestId: () => string, tool: AdminMc
307
307
  if (result.ok) return jsonResult(result.data);
308
308
  // An expected outcome the model should reason about (a policy said no), not a
309
309
  // protocol error: the transport still answers 200 with the denial in the body.
310
- return { ...jsonResult({ error: result.error, reason: result.reason }), isError: true };
310
+ //
311
+ // `code` is for the AUDIT line only and never reaches the wire — it is already in the
312
+ // body above. Without it the server classified every refusal here as `policy-denied` at
313
+ // `warn`, so a malformed `create` sat in the bucket a prober's name walk is alerted from,
314
+ // beside real denials. `X_ADMIN_INVALID` is a client misreading a schema that publishes a
315
+ // `type` per field and nothing else; `X_ADMIN_DENIED` is authz, and stays outcome 3.
316
+ return {
317
+ ...jsonResult({ error: result.error, reason: result.reason }),
318
+ isError: true,
319
+ code: result.error,
320
+ };
311
321
  },
312
322
  };
313
323
  }
package/src/widgets.tsx CHANGED
@@ -3,7 +3,7 @@
3
3
  // every one of those places — there is no second place to get it wrong.
4
4
 
5
5
  import { safeUrl } from '@ultimat3/core';
6
- import { t } from '@ultimat3/i18n';
6
+ import { registeredLocales, t } from '@ultimat3/i18n';
7
7
  import {
8
8
  Checkbox,
9
9
  DateTime,
@@ -261,8 +261,19 @@ function ianaZones(): readonly string[] {
261
261
  return intl.supportedValuesOf?.('timeZone') ?? ['UTC'];
262
262
  }
263
263
 
264
+ /**
265
+ * The locales THIS APP registered — never a bundled copy, the same rule `ianaZones` above states
266
+ * one line up for IANA zones.
267
+ *
268
+ * It was `['en','es','de','fr','pt','ja']`: an app registering `it` could not pick it, and an app
269
+ * with only `en` and `fr` was offered four locales every user-facing string of which it renders
270
+ * `⟦key⟧` for. `@ultimat3/i18n` is tier 1 and already imported here for `t`.
271
+ *
272
+ * No fallback, deliberately: an app with no catalog registered has no locale to offer, and
273
+ * inventing one is the admin declaring something the app did not. `x i18n check` refuses that app.
274
+ */
264
275
  function locales(): readonly string[] {
265
- return ['en', 'es', 'de', 'fr', 'pt', 'ja'];
276
+ return registeredLocales();
266
277
  }
267
278
 
268
279
  export function Widget(input: WidgetInput): JSX.Element {