New:Socket for Asana Is Now Available.Learn more
Sign In

@ultimat3/admin

Package Overview
Dependencies
Maintainers
1
Versions
22
Alerts
File Explorer

Advanced tools

Socket logo

Install Socket

Detect and block malicious and high-risk dependencies

Install

@ultimat3/admin - npm Package Compare versions

Comparing version
9.0.0
to
10.0.0
+54
-0
CLAUDE.md

@@ -31,2 +31,56 @@ # @ultimat3/admin — boundary

- **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.
- **`assertReadOnly` answers a VERDICT carrying the runnable string, and the panel executes THAT.**
`assertReadOnlyQuery` documents that what it returns is what the caller must execute — every
check ran on a stripped form and the return is the reconciled one — and `panel-db.ts` discarded
it and ran the textarea's bytes. Benign only for as long as `verbatim()` normalises nothing more
than a trailing `;`, which is a promise no other file is keeping; `@ultimat3/mcp`'s own
`dev-server.ts` honours the contract, and a `string | null` return here made it impossible to.
`ReadOnlyVerdict` is exported from `@ultimat3/admin/dev` beside it.
- **`decideAll([])` DENIES.** `permissions[length - 1] ?? ''` fell through to `allowed('')`, so a
declared-but-empty gate opened for every actor, anonymous included, and named no permission at
all. `visibleNav` hands an author's `item.permissions` straight to it, so `permissions: []` on a
nav item was that gate. `pages.ts` already refuses an empty PAGE list at declaration
(`X_ADMIN_PAGE_UNGUARDED`); this is the same rule at the seam every surface shares, which is
where the ones that never pass through `defineAdmin` are decided. Reason
`admin.policy.none-declared`.
- **Two resources may not claim one `path:`** — `assertUniqueResourcePaths` in `admin.ts`, refused
at `defineAdmin` with `X_ADMIN_PAGE_PATH_INVALID` (`subject: 'resource'`). `adminRouteFor`
resolves by `.find()`, so a duplicate produced EIGHT routes over FOUR paths and the second
resource's four screens were unreachable, silently, with the dashboard rendering. Identical
argument to the duplicate action NAME one line below it and to `pages.ts`'s shadow check — the
same `taken` set, one step earlier. **A currently-booting app with a duplicate path now refuses
at boot.**
- **The audit diff is TOTAL over every value a row holds.** `same()` compared with
`JSON.stringify`, which THROWS on a bigint — and `money()` puts one on the row
(`widget-value.ts`), so every update of a money-bearing row raised, AFTER `repo.update()` had
committed: the write landed, the caller got an uncoded `TypeError`, and the log stayed empty.
`canonicalJson` from `@ultimat3/core`, the same answer `packages/manifest/src/diff-routes.ts`
gives to the same question. `crud.test.ts`'s fixture entity carries a `money()` column and its
repo CLONES on read, because two reads of one row are two objects and a shared reference
short-circuits the comparison the diff exists to make.
- **A repo that THROWS leaves a `failed` entry.** `AuditOutcome` declared the member and `crud.ts`
emitted it in exactly one place — `invalid()`, for a validation issue — so a constraint
violation, a statement that timed out after committing and a dropped connection each left nothing
at all. `auditedWrite` wraps the three repo writes: try, record, re-throw UNCHANGED. A mutation
cannot append BEFORE the call the way `search.ts` does for a read; that would record a write
which may never have happened. The reason is a key (`admin.audit.write-failed`), never anything
read off the thrown value.
- **Nothing is read off a caught value in `action-gate.ts`, and the append runs first.** It built an
`AdminDecision` whose `trace` was `String(error)` — and a `catch` binding is annotated by nobody,
so `Object.create(null)` raised `TypeError: No default value` from inside the block that owed the
auditor an entry: measured, ZERO entries and the caller received the TypeError. The decision
object was DEAD anyway (only its `reason` was ever read; `append` takes no trace), so there is no
destination for a rendered value and `renderThrowable` is not needed either.
- **`decideAll`'s and `/_x`'s record indexing is `Object.hasOwn` / a `Map`, never a bare index.**
`ADMIN_PERMISSION_SPEC[permission]` consulted the PROTOTYPE CHAIN, so a polluted
`Object.prototype` gave any granted permission an `implies` the table never declared — and
`expandPermissions` walks it. `panel-cache.ts`, `panel-routes.ts` and `panel-policy.ts` counted
into plain objects, where `__proto__` reads a prototype (so `?? 0` never fires) and WRITES through
the setter, dropping the row: the policy matrix reported a permission unreachable while an actor
held it. `Map` + `Object.fromEntries`, which DEFINES each key.
- **The locale picker reads `registeredLocales()`, not a bundled list.** It was
`['en','es','de','fr','pt','ja']`, one line under a comment forbidding exactly that for IANA
zones: an app registering `it` could not pick it, and an app with only `en` was offered five
locales it renders `⟦key⟧` for. No fallback — an app with no catalog has no locale to offer, and
inventing one is the admin declaring what the app did not.
- **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.

@@ -33,0 +87,0 @@ - **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.

+16
-16
{
"name": "@ultimat3/admin",
"version": "9.0.0",
"version": "10.0.0",
"description": "Two dashboards: the /_x framework dev panels and the generated, AI-first app admin",

@@ -35,18 +35,18 @@ "license": "MIT",

"dependencies": {
"@ultimat3/action": "9.0.0",
"@ultimat3/ai": "9.0.0",
"@ultimat3/cache": "9.0.0",
"@ultimat3/core": "9.0.0",
"@ultimat3/db": "9.0.0",
"@ultimat3/entity": "9.0.0",
"@ultimat3/i18n": "9.0.0",
"@ultimat3/jobs": "9.0.0",
"@ultimat3/mcp": "9.0.0",
"@ultimat3/money": "9.0.0",
"@ultimat3/policy": "9.0.0",
"@ultimat3/query": "9.0.0",
"@ultimat3/render": "9.0.0",
"@ultimat3/schema": "9.0.0",
"@ultimat3/ui": "9.0.0"
"@ultimat3/action": "10.0.0",
"@ultimat3/ai": "10.0.0",
"@ultimat3/cache": "10.0.0",
"@ultimat3/core": "10.0.0",
"@ultimat3/db": "10.0.0",
"@ultimat3/entity": "10.0.0",
"@ultimat3/i18n": "10.0.0",
"@ultimat3/jobs": "10.0.0",
"@ultimat3/mcp": "10.0.0",
"@ultimat3/money": "10.0.0",
"@ultimat3/policy": "10.0.0",
"@ultimat3/query": "10.0.0",
"@ultimat3/render": "10.0.0",
"@ultimat3/schema": "10.0.0",
"@ultimat3/ui": "10.0.0"
}
}

@@ -27,2 +27,8 @@ // One decision, two consumers. `actionButtons()` decides what renders; `invokeAdminAction()`

/**
* The reason on a `failed` action entry. A key the view renders — never a sentence carried out of
* a caught value, which is how a database message or an attacker's string reaches an audit log.
*/
const ACTION_FAILED_REASON = 'admin.error.action-failed';
/** The permissions an action needs: the admin-level gate, then the action's own policy. */

@@ -183,8 +189,16 @@ export function permissionsForAction<Input, Output>(

} catch (error) {
const failed: AdminDecision = {
allowed: false,
permission: action.permission,
reason: 'admin.error.action-failed',
trace: [error instanceof Error ? `${error.name}: ${error.message}` : String(error)],
};
// NOTHING is read off `error` before the append, and nothing is read off it at all.
//
// This built an `AdminDecision` first, whose `trace` rendered the caught value with
// `String(error)` — and a `catch` binding is annotated by nobody, so it holds whatever an
// app's handler threw. `Object.create(null)` has no `toString`, no `valueOf` and no
// `Symbol.toPrimitive`, so `String(it)` raises `TypeError: No default value` from inside the
// block that owes the auditor an entry: measured, ZERO entries, and the caller received the
// TypeError instead of what was thrown. Ordering was the second half — the render ran BEFORE
// `append`, so its throw skipped the append rather than merely spoiling one field.
//
// The decision object was also DEAD: only its `reason` was ever read, and `append` takes no
// trace. So there is no destination for a rendered value here and `renderThrowable` is not
// needed either — an audit reason is a key the view renders, never a sentence from a
// database, an upstream or an attacker.
await audit.append({

@@ -199,3 +213,3 @@ requestId,

outcome: 'failed',
reason: failed.reason,
reason: ACTION_FAILED_REASON,
diff: [],

@@ -202,0 +216,0 @@ });

@@ -9,3 +9,3 @@ // `defineAdmin()` — one call, a working dashboard. It derives a resource per entity, hangs

import { permissionsForOperation } from './crud';
import { AdminActionDuplicateError } from './errors';
import { AdminActionDuplicateError, AdminPagePathInvalidError } from './errors';
import { adminNav, type NavGroup, type NavItem, type NavOptions, visibleNav } from './nav';

@@ -130,2 +130,33 @@ import { type AdminCustomPage, type AdminPageComponent, pageNavItems, pageRoutes } from './pages';

/**
* One URL, one claimant — checked across RESOURCES, which was the last claim on an admin path
* that nothing verified.
*
* `adminRouteFor` resolves by `.find()`, so two resources declaring one `path:` produced eight
* routes over four paths and the second resource's four screens were simply unreachable: the app
* booted, the dashboard rendered, and nothing said so. That is the identical argument
* `assertUniqueActionNames` below makes for a duplicate action name, and the one `pages.ts` makes
* for a page shadowing a generated route — the same `taken` set, one step earlier.
*
* Every generated path is collected, not just the list root: `/posts` and `/posts/:id` are two
* claims and a resource colliding on either is the same broken route table.
*/
function assertUniqueResourcePaths(basePath: string, resources: readonly AdminResource[]): void {
const claimed = new Map<string, string>();
for (const resource of resources) {
for (const route of resourceRoutes(basePath, resource)) {
const owner = claimed.get(route.path);
if (owner !== undefined) {
throw new AdminPagePathInvalidError({
subject: 'resource',
path: route.path,
cause: `is already claimed by the resource "${owner}", so "${resource.name}" would be unreachable there`,
fix: `give one of them its own path: resources: { ${resource.name}: { path: '${resource.path}-2' } }`,
});
}
claimed.set(route.path, resource.name);
}
}
}
/**
* One `AdminAction.name`, one handler. The name is the MCP tool name (`admin.action.<name>`), the

@@ -187,2 +218,3 @@ * default label key (`admin.action.<name>`) AND the key `callAdminTool` resolves a handler by, so

const nav = adminNav(resources, { ...navOptions, extra });
assertUniqueResourcePaths(basePath, resources);
const generated: AdminRoute[] = [

@@ -189,0 +221,0 @@ {

@@ -5,2 +5,3 @@ // Append-only audit log: actor, operation, entity, before/after diff, requestId, timestamp.

import { canonicalJson } from '@ultimat3/core';
import type { AdminActor, AdminDecision } from './authz';

@@ -121,2 +122,16 @@ import type { AdminRow } from './registry';

/**
* "Is this field unchanged?", TOTAL over every value a row can hold.
*
* `JSON.stringify(a) === JSON.stringify(b)` was neither: it THROWS on a bigint, and `money()` puts
* one on the row (`widget-value.ts` — Postgres `bigint` minor units). Two distinct
* `{ minor, currency }` objects are never `===`, so every update of a money-bearing row reached
* that branch and raised. `crud.ts` calls `diffRows` inside the argument to `ctx.audit.append`,
* AFTER `repo.update()` has committed — so the write landed, the caller got an uncoded
* `TypeError`, and the audit log recorded nothing at all.
*
* `canonicalJson` is tier 0, already a dependency, and already this repo's answer to exactly this
* question (`packages/manifest/src/diff-routes.ts` asks it of a route descriptor). It is injective
* per type, so `1000n` and `1000` stay two values rather than folding into one unchanged field.
*/
const same = (a: unknown, b: unknown): boolean => {

@@ -126,6 +141,3 @@ if (a === b) return true;

if (a === null || b === null || a === undefined || b === undefined) return false;
if (typeof a === 'object' && typeof b === 'object') {
return JSON.stringify(a) === JSON.stringify(b);
}
return false;
return canonicalJson(a) === canonicalJson(b);
};

@@ -132,0 +144,0 @@

@@ -81,3 +81,13 @@ // ONE authz seam for the whole admin. Every surface — the rendered button, the HTTP call

/** Every permission must hold. The first denial wins, and carries its own reason. */
/**
* Every permission must hold. The first denial wins, and carries its own reason.
*
* An EMPTY list is refused, never granted. `permissions[length - 1] ?? ''` used to fall through to
* `allowed('')`, so a declared-but-empty gate opened for every actor, anonymous included — and the
* decision it returned named no permission at all. `visibleNav` hands an author's
* `item.permissions` straight here, so `permissions: []` on a nav item was that gate. `pages.ts`
* already refuses an empty page list at declaration time (`X_ADMIN_PAGE_UNGUARDED`); this is the
* same rule at the seam every surface shares, which is where the ones that never pass through
* `defineAdmin` are decided.
*/
export function decideAll(

@@ -89,2 +99,7 @@ authz: AdminAuthz,

): AdminDecision {
if (permissions.length === 0) {
return denied('', 'admin.policy.none-declared', [
'no permission was declared for this surface, so there is nothing to satisfy',
]);
}
const trace: string[] = [];

@@ -113,3 +128,9 @@ for (const permission of permissions) {

const impliedBy = (permission: string): readonly string[] => {
// Widened: `permission` is any string at runtime, so the lookup really can miss.
// `Object.hasOwn` first, never the bare index read. `permission` is any string at runtime and
// the table is a plain object, so the read consulted the PROTOTYPE CHAIN: an app that merges
// untrusted JSON (the ordinary prototype-pollution shape) could give any granted permission an
// `implies` the spec table never declared, and `expandPermissions` walks it. Fourth instance of
// the class in the framework, after i18n's catalog lookup, schema's `coerce` and mcp's
// `validate-args`. The cast was the tell that the key is not known to be a member.
if (!Object.hasOwn(ADMIN_PERMISSION_SPEC, permission)) return [];
const spec: { readonly implies: readonly string[] } | undefined =

@@ -116,0 +137,0 @@ ADMIN_PERMISSION_SPEC[permission as AdminPermission];

@@ -176,2 +176,47 @@ // The five CRUD operations, each one: policy → confirmation → validation → repo → audit.

/** The reason on a `failed` entry. A key, never the database's message. */
const WRITE_FAILED_REASON = 'admin.audit.write-failed';
/**
* Run a repo WRITE, and leave a `failed` entry behind if it throws.
*
* `AuditOutcome` has declared a `failed` member all along and this file emitted it in exactly one
* place — `invalid()`, for a VALIDATION issue. A constraint violation, a statement that timed out
* after committing, a connection dropped mid-write: each left no entry at all, which is the case
* an auditor opens the log for. Both siblings already do this and each states the rule
* (`search.ts`, `action-gate.ts`).
*
* A mutation cannot append BEFORE the call the way a read does — that would record a write which
* may never have happened. So: try, record, re-throw UNCHANGED. Nothing about the thrown value is
* read or rendered; the caller owns it, and an audit reason is a key, not a database message.
*/
async function auditedWrite<T>(
// Only the NAME is read, so this stays invariance-free: `AdminResource<Row>` at four call
// sites would need the generic threaded through for nothing.
resource: { readonly name: string },
op: AdminOperation,
ctx: CrudCtx,
entityId: string | null,
decision: AdminDecision,
run: () => Promise<T>,
): Promise<T> {
try {
return await run();
} catch (error) {
await ctx.audit.append({
requestId: ctx.requestId,
actor: ctx.actor,
operation: op,
kind: 'operation',
entity: resource.name,
entityId,
permission: decision.permission,
outcome: 'failed',
reason: WRITE_FAILED_REASON,
diff: [],
});
throw error;
}
}
export async function adminCreate<Row extends AdminRow>(

@@ -188,3 +233,5 @@ resource: AdminResource<Row>,

const row = await repoOf(resource).create(parsed.value);
const row = await auditedWrite(resource, 'create', ctx, null, decision, () =>
repoOf(resource).create(parsed.value),
);
return {

@@ -239,3 +286,5 @@ ok: true,

);
const after = await repo.update(id, validatedPatch);
const after = await auditedWrite(resource, 'update', ctx, id, decision, () =>
repo.update(id, validatedPatch),
);
return {

@@ -288,3 +337,3 @@ ok: true,

await repo.destroy(id);
await auditedWrite(resource, 'delete', ctx, id, decision, () => repo.destroy(id));
return {

@@ -291,0 +340,0 @@ ok: true,

@@ -34,3 +34,3 @@ // The `/_x` dev dashboard's own door, reached as `@ultimat3/admin/dev`.

export { type CachePanelData, cachePanel } from './panel-cache';
export { assertReadOnly, type DbPanelData, dbPanel } from './panel-db';
export { assertReadOnly, type DbPanelData, dbPanel, type ReadOnlyVerdict } from './panel-db';
export { type JobsPanelData, jobsPanel } from './panel-jobs';

@@ -37,0 +37,0 @@ export { type LivePanelData, livePanel } from './panel-live';

@@ -33,6 +33,12 @@ // Panel: Cache.

const busted = new Set(invalidations.flatMap((event) => event.busted));
const byKind: Record<string, number> = {};
// A `Map`, then `Object.fromEntries` — never `count[key] = (count[key] ?? 0) + 1` on a plain
// object. `dep.kind` is a plain `string` in the fact type, so `__proto__` reaches it: the read
// answers `Object.prototype` (so `?? 0` never fires) and the write runs the setter, which
// re-prototypes the record instead of adding a key and drops the row from the panel. A `Map`
// has no prototype chain to consult, and `fromEntries` DEFINES each key rather than assigning.
const counts = new Map<string, number>();
for (const edge of graph) {
for (const dep of edge.dependents) byKind[dep.kind] = (byKind[dep.kind] ?? 0) + 1;
for (const dep of edge.dependents) counts.set(dep.kind, (counts.get(dep.kind) ?? 0) + 1);
}
const byKind = Object.fromEntries(counts);

@@ -39,0 +45,0 @@ return {

@@ -81,9 +81,24 @@ // Panel: DB.

* knows which reader it is talking to.
*
* The verdict carries the RUNNABLE STRING, never just a yes. `assertReadOnlyQuery` documents that
* what it returns is what the caller must execute — every check it made ran on a stripped form,
* and the return is the reconciled one. This panel threw that value away and executed the
* textarea's own bytes, so the two callers of one guard disagreed about which string runs.
* `@ultimat3/mcp`'s own `dev-server.ts` honours the contract; a `string | null` return here made
* it impossible to.
*/
export function assertReadOnly(sql: string): string | null {
// Nothing to run — a blank box or a comment the developer is still writing, not a refusal.
if (sanitize(sql).trim() === '') return null;
export type ReadOnlyVerdict =
/** Safe to execute — and `sql` is the string to execute, not the one that was typed. */
| { readonly kind: 'runnable'; readonly sql: string }
/** The sentence to show instead of a result grid. */
| { readonly kind: 'refused'; readonly refused: string };
export function assertReadOnly(sql: string): ReadOnlyVerdict {
// Nothing to run — a blank box or a comment the developer is still writing, not a refusal, and
// not something the guard can hand a statement back for either (it refuses an empty one). The
// caller's own text goes through: a comment against a database is a no-op, and flashing a
// refusal mid-keystroke is the failure this branch exists to avoid.
if (sanitize(sql).trim() === '') return { kind: 'runnable', sql };
try {
assertReadOnlyQuery(sql);
return null;
return { kind: 'runnable', sql: assertReadOnlyQuery(sql) };
} catch (error) {

@@ -97,3 +112,6 @@ // Structurally, never `String(error)`: the guard throws an `UltimateError` whose `cause` is

// flag grants writes, it does not close a delimiter.
return `refused: ${error.cause}. Fix the statement, or — if it is meant to write — run it with: x db psql --write`;
return {
kind: 'refused',
refused: `refused: ${error.cause}. Fix the statement, or — if it is meant to write — run it with: x db psql --write`,
};
}

@@ -122,3 +140,5 @@ }

const refused = assertReadOnly(sql);
// `verdict.sql`, never `sql`: what the guard proved read-only is what runs. `sql` below is
// what stays in the textarea for the operator to edit, which is a different question.
const verdict = assertReadOnly(sql);
return {

@@ -128,4 +148,4 @@ tables,

sql,
result: refused === null ? await sources.runSql(sql) : null,
refused,
result: verdict.kind === 'runnable' ? await sources.runSql(verdict.sql) : null,
refused: verdict.kind === 'refused' ? verdict.refused : null,
readOnly: true,

@@ -132,0 +152,0 @@ };

@@ -35,8 +35,13 @@ // Panel: Policy.

const matrix = permissions.map((permission) => {
const byActor: Record<string, boolean> = {};
for (const actor of actors) {
byActor[actor] =
// `Object.fromEntries`, never `byActor[actor] = …`: an actor id is a plain string, so
// `__proto__` reaches the index, and the assignment ran the prototype's SETTER instead of
// adding a key. The cell then vanished from `Object.values(byActor)` and the permission was
// reported unreachable — held by nobody — while an actor held it.
const byActor = Object.fromEntries(
actors.map((actor): readonly [string, boolean] => [
actor,
facts.find((fact) => fact.permission === permission && fact.actorId === actor)?.allowed ??
false;
}
false,
]),
);
return { permission, byActor };

@@ -43,0 +48,0 @@ });

@@ -32,6 +32,8 @@ // Panel: Routes.

const routes = await sources.routes();
const byRenderMode: Record<string, number> = {};
for (const route of routes) {
byRenderMode[route.render] = (byRenderMode[route.render] ?? 0) + 1;
}
// A `Map`, then `Object.fromEntries`. See `panel-cache.ts` for why the plain-object counter is
// wrong: an inherited name reads a prototype value instead of `undefined`, and `__proto__`
// writes through the setter rather than adding a key.
const counts = new Map<string, number>();
for (const route of routes) counts.set(route.render, (counts.get(route.render) ?? 0) + 1);
const byRenderMode = Object.fromEntries(counts);
return {

@@ -38,0 +40,0 @@ routes: [...routes].sort((a, b) => a.path.localeCompare(b.path)),

@@ -64,3 +64,8 @@ // The X_* codes owned by @ultimat3/admin. Every one names the exact edit that fixes it,

const docsFor = (code: AdminErrorCode): string => `https://ultimate.dev/errors/${code}`;
// No `docs:` on the subclasses below. `UltimateError` fills it from `describeErrorCode(code).docs`,
// which is `@ultimat3/core`'s `ERROR_DOCS_URL` — one page for every code, never one per code, because
// `wiki/` is the framework's only public documentation surface and a code lives there in a TABLE ROW,
// which has no anchor. The `https://ultimate.dev/errors/<code>` links this file built until 9.x
// answered 404, host included, on every error it has ever thrown; restating the replacement here
// would be the same constant in eight places waiting to drift again.

@@ -78,3 +83,2 @@ /** A resource, nav item, or MCP tool named an entity the registry does not have. */

fix: `x g entity ${input.entity} # then: x manifest`,
docs: docsFor('X_ADMIN_ENTITY_UNKNOWN'),
});

@@ -95,3 +99,2 @@ }

fix: input.fix,
docs: docsFor('X_ADMIN_FIELD_UNSUPPORTED'),
});

@@ -115,3 +118,2 @@ }

fix: `rename one in defineAdmin's actions — name: '<entity>.${input.name}' — so "${input.name}" belongs to one of them`,
docs: docsFor('X_ADMIN_ACTION_DUPLICATE'),
});

@@ -136,3 +138,2 @@ }

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`,
docs: docsFor('X_ADMIN_POLICY_MISSING'),
});

@@ -153,3 +154,2 @@ }

fix: `add permissions: ['${input.path.replace(/^\//, '').split('/')[0] ?? 'ops'}:read'] to the pages entry for "${input.path}"`,
docs: docsFor('X_ADMIN_PAGE_UNGUARDED'),
});

@@ -161,8 +161,13 @@ }

export class AdminPagePathInvalidError extends UltimateError {
constructor(input: { path: string; cause: string; fix: string }) {
/**
* `subject` names WHICH declaration owns the bad path — `'page'` by default, `'resource'` when
* two resources claim one URL. One code, because it is one failure (an admin URL with more than
* one claimant), and a reader told "page path" while looking at a `resources:` entry goes to the
* wrong file.
*/
constructor(input: { path: string; cause: string; fix: string; subject?: string }) {
super({
code: 'X_ADMIN_PAGE_PATH_INVALID',
cause: `the admin page path "${input.path}" ${input.cause}`,
cause: `the admin ${input.subject ?? 'page'} path "${input.path}" ${input.cause}`,
fix: input.fix,
docs: docsFor('X_ADMIN_PAGE_PATH_INVALID'),
});

@@ -188,3 +193,2 @@ }

fix: `devDashboard({ sources: defaultDevSources(${input.wiring ?? `{ hooks: { ${input.source} } }`}) })`,
docs: docsFor('X_NOT_IMPLEMENTED'),
});

@@ -201,3 +205,2 @@ }

fix: 'delete the /_x mount from the production entrypoint; run `x dev` locally instead',
docs: docsFor('X_DEV_DASHBOARD_IN_PROD'),
});

@@ -204,0 +207,0 @@ }

@@ -310,3 +310,13 @@ // The AI-first surface: the admin's resources and actions as MCP tools, wired through

// protocol error: the transport still answers 200 with the denial in the body.
return { ...jsonResult({ error: result.error, reason: result.reason }), isError: true };
//
// `code` is for the AUDIT line only and never reaches the wire — it is already in the
// body above. Without it the server classified every refusal here as `policy-denied` at
// `warn`, so a malformed `create` sat in the bucket a prober's name walk is alerted from,
// beside real denials. `X_ADMIN_INVALID` is a client misreading a schema that publishes a
// `type` per field and nothing else; `X_ADMIN_DENIED` is authz, and stays outcome 3.
return {
...jsonResult({ error: result.error, reason: result.reason }),
isError: true,
code: result.error,
};
},

@@ -313,0 +323,0 @@ };

@@ -6,3 +6,3 @@ // The one widget dispatch. List cells, detail rows, and form inputs all render through this

import { safeUrl } from '@ultimat3/core';
import { t } from '@ultimat3/i18n';
import { registeredLocales, t } from '@ultimat3/i18n';
import {

@@ -265,4 +265,15 @@ Checkbox,

/**
* The locales THIS APP registered — never a bundled copy, the same rule `ianaZones` above states
* one line up for IANA zones.
*
* It was `['en','es','de','fr','pt','ja']`: an app registering `it` could not pick it, and an app
* with only `en` and `fr` was offered four locales every user-facing string of which it renders
* `⟦key⟧` for. `@ultimat3/i18n` is tier 1 and already imported here for `t`.
*
* No fallback, deliberately: an app with no catalog registered has no locale to offer, and
* inventing one is the admin declaring something the app did not. `x i18n check` refuses that app.
*/
function locales(): readonly string[] {
return ['en', 'es', 'de', 'fr', 'pt', 'ja'];
return registeredLocales();
}

@@ -269,0 +280,0 @@