@ultimat3/admin 8.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 +54 -0
- package/package.json +16 -16
- package/src/action-gate.ts +21 -7
- package/src/admin.ts +33 -1
- package/src/audit.ts +16 -4
- package/src/authz.ts +23 -2
- package/src/crud.ts +52 -3
- package/src/dev/index.ts +1 -1
- package/src/dev/panel-cache.ts +8 -2
- package/src/dev/panel-db.ts +29 -9
- package/src/dev/panel-policy.ts +10 -5
- package/src/dev/panel-routes.ts +6 -4
- package/src/errors.ts +14 -11
- package/src/mcp.ts +11 -1
- package/src/widgets.tsx +13 -2
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": "
|
|
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": "
|
|
36
|
-
"@ultimat3/ai": "
|
|
37
|
-
"@ultimat3/cache": "
|
|
38
|
-
"@ultimat3/core": "
|
|
39
|
-
"@ultimat3/db": "
|
|
40
|
-
"@ultimat3/entity": "
|
|
41
|
-
"@ultimat3/i18n": "
|
|
42
|
-
"@ultimat3/jobs": "
|
|
43
|
-
"@ultimat3/mcp": "
|
|
44
|
-
"@ultimat3/money": "
|
|
45
|
-
"@ultimat3/policy": "
|
|
46
|
-
"@ultimat3/query": "
|
|
47
|
-
"@ultimat3/render": "
|
|
48
|
-
"@ultimat3/schema": "
|
|
49
|
-
"@ultimat3/ui": "
|
|
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
|
}
|
package/src/action-gate.ts
CHANGED
|
@@ -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
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
-
/**
|
|
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
|
-
//
|
|
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
|
|
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
|
|
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';
|
package/src/dev/panel-cache.ts
CHANGED
|
@@ -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
|
-
|
|
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)
|
|
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,
|
package/src/dev/panel-db.ts
CHANGED
|
@@ -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
|
|
83
|
-
|
|
84
|
-
|
|
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
|
|
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
|
-
|
|
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:
|
|
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
|
},
|
package/src/dev/panel-policy.ts
CHANGED
|
@@ -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
|
-
|
|
36
|
-
|
|
37
|
-
|
|
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
|
-
|
|
40
|
-
|
|
43
|
+
false,
|
|
44
|
+
]),
|
|
45
|
+
);
|
|
41
46
|
return { permission, byActor };
|
|
42
47
|
});
|
|
43
48
|
|
package/src/dev/panel-routes.ts
CHANGED
|
@@ -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
|
-
|
|
33
|
-
|
|
34
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
276
|
+
return registeredLocales();
|
|
266
277
|
}
|
|
267
278
|
|
|
268
279
|
export function Widget(input: WidgetInput): JSX.Element {
|