@ultimat3/admin 3.0.0 → 4.1.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 +6 -1
- package/README.md +6 -3
- package/package.json +16 -16
- package/src/admin.ts +34 -0
- package/src/authz.ts +18 -0
- package/src/crud.ts +31 -9
- package/src/detail.tsx +13 -1
- package/src/dev/data.ts +86 -101
- package/src/dev/facts.ts +24 -1
- package/src/dev/panel-db.ts +18 -2
- package/src/dev/panel-routes.ts +5 -4
- package/src/dev/panel.ts +9 -15
- package/src/errors.ts +24 -0
- package/src/index.ts +1 -0
- package/src/inert-jsx.ts +187 -0
- package/src/mcp.ts +35 -3
- package/src/policy-bridge.ts +9 -2
- package/src/resource.ts +14 -9
- package/src/search.ts +55 -5
- package/src/widget-value.ts +2 -1
package/CLAUDE.md
CHANGED
|
@@ -7,8 +7,13 @@ Two products, one package, **two entry points**: `@ultimat3/admin/dev` (`src/dev
|
|
|
7
7
|
## Rules
|
|
8
8
|
|
|
9
9
|
- **One authz.** `AdminAuthz` (`authz.ts`) is the only decision path. `action-gate.ts`, `crud.ts` and `page-guard.tsx` call `decideAll`; views render what the gate returned. Never add a second check in a view or an MCP handler.
|
|
10
|
+
- **The subject carries the tenant and the loaded row.** `AdminActor.orgId` reaches `policyActor` and `AdminSubject.row` reaches `evaluate`, so an org-scoped or ownership rule can fire at all. It could not: every admin decision was evaluated with `actor.orgId === undefined` and `row === null`, so a role-only rule allowed and the coarse `admin:read` + `<entity>:read` pair was the only gate on a row — while `adminList`/`adminSearch` add no tenant predicate of their own. `adminDetail`, `adminUpdate` and `adminDestroy` load the row BEFORE the guard, the shape `packages/action/src/invoke.ts` uses; `undefined` means "not loaded" and is left off the subject, `null` means "looked and found none" and fails a row rule closed. Pinned in `policy-subject.test.ts`, which asserts the SUBJECT a policy receives — `staticAuthz` is a grant-list stub and no suite driving it can see any of this.
|
|
11
|
+
- **The MCP caller is the ambient actor for the whole call.** `mcp.ts` wraps `callAdminTool` in a child context (or a fresh root one over stdio, where there is no surrounding request), the same rule `@ultimat3/mcp`'s `app-tool.ts` states. `AdminApp.ctx()` builds a plain `CrudCtx` and touches nothing async-local, so everything deriving from `tryUseContext()` — entity's tenant guard, the query cache authority, the jit-preload store — read the TRANSPORT's actor: an agent token authorized as agent X while the repo reads ran as the cookie user's tenant, and over stdio `actorTenant` was `undefined` and `assertRowTenant` a no-op. Pinned in `mcp-context.test.ts`.
|
|
10
12
|
- **A custom page's guard is composed, never written.** `pages.ts` turns a `pages:` entry into an `AdminRoute` carrying `[admin:read, …declared]`; `routes.ts` is the only thing that may hand a page component to a router and it hands the `guardedPage()` wrapper, with `permissions[0]` already in `defineRoute({ policy })`. `AdminPageProps.ctx` is required by the type so the wrapper cannot be bypassed by calling the component directly. An empty permission list is `X_ADMIN_PAGE_UNGUARDED` at `defineAdmin` time — the one place an unauthenticated admin screen could have been born.
|
|
13
|
+
- **One `AdminAction.name`, one handler.** The name is the MCP tool name (`admin.action.<name>`), the default label key, AND the key `callAdminTool` resolves a handler by — three addresses, one string. Two actions sharing it is `X_ADMIN_ACTION_DUPLICATE` at `defineAdmin` time (`admin.ts`, `mcp.test.ts`), not at the first agent call: `.find()` on a name dispatches to whichever resource came first, which is a call that SUCCEEDS against the wrong action and reports nothing. Refused at declaration rather than in `adminMcp()` because an app that renders the dashboard and never wires MCP has the same two broken label keys and the same ambiguous dispatch. The framework's own examples already qualify the name with the entity (`post.publish`) — the `fix:` line is that convention, made into the instruction. The same object attached through both `actions:` and `resources[e].actions` is one action, not two: identity, not name, is what "already seen" means.
|
|
11
14
|
- **A host that serves an admin URL itself reads its gate, never restates it.** `adminRouteFor(app, path)` (`routes.ts`) is that read, and `AdminRouteConfig.policy` is what it hands over — the same object `config.policy` carries, non-optional so a caller need not prove it exists. Typing `policy: { permission: 'admin:read' }` into a page file beside a route table that already declares one for that URL is two declarations of one URL's authz; they agree until somebody edits one. The deployed demo shipped exactly that on five pages until 1.2.0, and its `apps/admin/app/admin/route-policy.test.ts` is the rule made executable: a quoted permission anywhere under `app/admin/**/page.tsx` fails the suite. An undeclared path is `X_ADMIN_PAGE_PATH_INVALID`, listing the paths that would have worked.
|
|
15
|
+
- **A `/_x` source reads a registry through its DESCRIPTOR TYPE, never as an untyped bag.** `import type { RouteDescriptor }` beside the dynamic `import()` — the type is erased, so /_x still costs the production graph nothing, and a renamed descriptor field becomes a typecheck failure in `dev/data.ts` naming the field. The bag was defended here as tolerance and bought three panels that were wrong for every row and could not go red: `route['render']`, `route['budget']`, `route['revalidate']`, `job['idempotencyKey']` and `entity['drift']` are names no descriptor has ever published, so a read answered `undefined`, took the fallback, and shipped the fallback as a fact — every route `stream` with no budget and no tag, every job non-idempotent, every schema drift-free. `dev/published-keys.test.ts` is the runtime half a type cannot answer: it walks what the real registries EMIT, so a field the type declares and the projection never writes is a red test.
|
|
16
|
+
- **A fact `/_x` cannot know is absent or `null` — never a value that happens to be constant.** `hasMeta` was dropped because `defineRoute()` refuses a route with no `meta`, so `missingMeta` was a defect list with no member it could hold; `JobDescriptor.idempotent` is published *because* `job()` refuses a definition without a key — a guarantee shown where the question is asked. `DbPanelData.drift` is `null` when nothing wired the check, because drift is the entities against the DATABASE and `[]` claims a match nobody verified.
|
|
12
17
|
- **One bridge per foreign package.** `policy-bridge.ts` is the only file calling `evaluate`/`definePermissions`; `routes.ts` the only one calling `defineRoute`; `mcp.ts` the only one calling `defineAppMcp`; `dev/data.ts` the only one importing introspection (dynamically — `/_x` must stay out of the production graph). Source only: a **test** declares the permissions its own `can()` fixtures use, because `definePermissions` writes a process-global registry and `bun test` seats several files in one process — `dev/data.test.ts` relied on the empty-registry-means-permissive fallback and went red the first time it shared a process with anything that imports this package.
|
|
13
18
|
- **One entity surface** (`registry.ts`) — the admin reads what `entity()` actually exposes: `$name`, `$primaryKey`, `$columns[c].$meta`, `$schema`, `$describe()`. It is a structural subset so a new column kind still derives, and `RegisteredEntity` is the `tsc`-checked proof that a real `entity()` result satisfies it — never a comment claiming it does.
|
|
14
19
|
- **One flattener.** `entity-columns.ts` is the only file that reads `$meta` or calls `$describe()`; everything downstream takes `AdminColumnFacts`. Money stays one property (the admin renders rows, not tables), a FK target comes back resolved from `$describe()`, and only a **generated** default (`uuid`, `now`) is read-only — `.default('free')` is a starting value.
|
|
@@ -24,7 +29,7 @@ Two products, one package, **two entry points**: `@ultimat3/admin/dev` (`src/dev
|
|
|
24
29
|
- **A dev panel catches only the error that means "not wired".** `DevSourceUnavailableError` and nothing wider: a bare `catch` in `dev/panel-live.ts` reported an authz refusal and a dropped NATS connection from a *running* sync node as `dev.live.no-sync-node`, telling the reader to install a tier they already had. Everything else reaches `panelPayload`, which renders its code and its fix.
|
|
25
30
|
- **What an entity does not declare, the admin does not invent.** `sensitive`, a fixed `currency` and `labelField` come from `AdminResourceOptions` or they are absent. Same rule for a URL: the reference widget links through `WidgetContext.hrefFor` or renders plain text — it used to build `/${entity}s/${id}`, which is English pluralisation by concatenation and drops `basePath`. The route table is `AdminApp`'s; a widget three layers down does not get to guess it.
|
|
26
31
|
- **One read-only SQL guard, and it is `@ultimat3/mcp`'s — the whole verdict, with nothing held back.** `dev/panel-db.ts` calls `assertReadOnlyQuery` (tier 5 → tier 4, already a dependency) rather than keeping a second keyword scan: that guard also refuses a batch, a call into the `pg_read_*`/`pg_advisory_*`/`pg_sleep`/`set_config` families, `FOR UPDATE`, and a delimiter that never closes in all five forms (`'`, `E'`, `"`, `$tag$`, slash-star). A local unterminated-delimiter refusal lived here for one revision and was **deleted**: it tested for a surviving `'`/`"`, so it covered three of the five and called a dollar-quoted body "a quote" — one failure mode, two explanations, and a second detector for a property the guard below already tests. What stays local is the emptiness test and the **way out**: `@ultimat3/mcp` tells its caller to expose an action, which a developer at `/_x` cannot act on. The panel says "Fix the statement, or — if it is meant to write — run it with `x db psql --write`", conditional on purpose: `--write` grants writes, it does not close a delimiter, and it used to be printed as *the* fix for a syntax error.
|
|
27
|
-
- **Every admin operation is audited, reads included.** `adminList` was the one call that logged nothing in either direction; `ListResult` now carries its `AuditEntry` on both branches, keyed on the table (`entityId: null`), because the subject of a listing is not a row.
|
|
32
|
+
- **Every admin operation is audited, reads included.** `adminList` was the one call that logged nothing in either direction; `ListResult` now carries its `AuditEntry` on both branches, keyed on the table (`entityId: null`), because the subject of a listing is not a row. `adminSearch` was the second, and it read rows out of EVERY readable entity: `AdminSearchResult.audit` now carries one entry per resource it decided about — `allowed` per searched resource, a `deniedDraft` per refused one. A resource skipped for having no text field or no repo is not an authz event and writes none.
|
|
28
33
|
- Money through `assertMoney`, timestamps through `assertZone`, pagination through `pagination.ts`. No `offset`, ever.
|
|
29
34
|
- **One cursor codec.** `pagination.ts` is the only file calling core's `encodeCursor`/`decodeCursor`; it wraps them as `encodeAdminCursor`/`decodeAdminCursor`, scoped `admin:<resource>`. An invalid cursor is page one here, not an error page — but the signature is checked first, so a forged one cannot seek.
|
|
30
35
|
- Labels are i18n keys derived in `resource.ts` (`admin.<entity>.field.<name>`); only `.tsx` calls `t()`. MCP tool descriptions are literal English (protocol payload, not UI copy).
|
package/README.md
CHANGED
|
@@ -17,11 +17,11 @@ One panel per file. Each kills one question, and each is available as `--json`
|
|
|
17
17
|
|
|
18
18
|
| Panel | Kills |
|
|
19
19
|
|---|---|
|
|
20
|
-
| `routes` | which handler serves this? — render mode, offline strategy,
|
|
20
|
+
| `routes` | which handler serves this? — render mode, offline strategy, revalidate tags, budget |
|
|
21
21
|
| `timeline` | where did the time go? — flamegraph of SQL, cache, action, policy spans + the N+1 count |
|
|
22
22
|
| `live` | what does each subscriber receive, and **why** — the matcher's decision trace |
|
|
23
23
|
| `jobs` | queue depth, step traces, retry-from-step target, dead letter |
|
|
24
|
-
| `db` | psql in a tab (read-only; `assertReadOnly` refuses DML), schema
|
|
24
|
+
| `db` | psql in a tab (read-only; `assertReadOnly` refuses DML), schema + drift (`null` unless a host wires the check) |
|
|
25
25
|
| `mail` | caught mail, rendered, per locale, with the locale gaps listed |
|
|
26
26
|
| `cache` | the tag graph — what invalidated what, and which tags are orphans |
|
|
27
27
|
| `policy` | the permission matrix per actor, every cell carrying its trace |
|
|
@@ -201,7 +201,10 @@ shape already is, written down once so two apps do not invent two layouts. `x g
|
|
|
201
201
|
| Every mutation and every denial is on the audit log, with a before/after diff | `audit.ts` |
|
|
202
202
|
| Branding aliases tokens only — `accent: '#7c3aed'` is a compile error | `theme.ts` |
|
|
203
203
|
|
|
204
|
-
|
|
204
|
+
Reads are audited too, and on both branches: `adminDetail` keys its entry on the row,
|
|
205
|
+
`adminList` and `adminSearch` key theirs on the table (`entityId: null`), and a refusal is an
|
|
206
|
+
entry of its own. `AdminSearchResult.audit` carries one entry per resource the call decided
|
|
207
|
+
about — searched or refused — so a jump box that walked every readable entity leaves a trace.
|
|
205
208
|
|
|
206
209
|
## AI-first
|
|
207
210
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ultimat3/admin",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "4.1.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": "4.1.0",
|
|
36
|
+
"@ultimat3/ai": "4.1.0",
|
|
37
|
+
"@ultimat3/cache": "4.1.0",
|
|
38
|
+
"@ultimat3/core": "4.1.0",
|
|
39
|
+
"@ultimat3/db": "4.1.0",
|
|
40
|
+
"@ultimat3/entity": "4.1.0",
|
|
41
|
+
"@ultimat3/i18n": "4.1.0",
|
|
42
|
+
"@ultimat3/jobs": "4.1.0",
|
|
43
|
+
"@ultimat3/mcp": "4.1.0",
|
|
44
|
+
"@ultimat3/money": "4.1.0",
|
|
45
|
+
"@ultimat3/policy": "4.1.0",
|
|
46
|
+
"@ultimat3/query": "4.1.0",
|
|
47
|
+
"@ultimat3/render": "4.1.0",
|
|
48
|
+
"@ultimat3/schema": "4.1.0",
|
|
49
|
+
"@ultimat3/ui": "4.1.0"
|
|
50
50
|
}
|
|
51
51
|
}
|
package/src/admin.ts
CHANGED
|
@@ -6,6 +6,7 @@ import { type AuditLog, memoryAuditLog } from './audit';
|
|
|
6
6
|
import type { AdminActor, AdminAuthz } from './authz';
|
|
7
7
|
import type { CrudCtx } from './crud';
|
|
8
8
|
import { permissionsForOperation } from './crud';
|
|
9
|
+
import { AdminActionDuplicateError } from './errors';
|
|
9
10
|
import { adminNav, type NavGroup, type NavItem, type NavOptions, visibleNav } from './nav';
|
|
10
11
|
import { type AdminCustomPage, type AdminPageComponent, pageNavItems, pageRoutes } from './pages';
|
|
11
12
|
import type { AdminOperation } from './permissions';
|
|
@@ -124,6 +125,37 @@ function resourceRoutes(basePath: string, resource: AdminResource): readonly Adm
|
|
|
124
125
|
return routes;
|
|
125
126
|
}
|
|
126
127
|
|
|
128
|
+
/**
|
|
129
|
+
* One `AdminAction.name`, one handler. The name is the MCP tool name (`admin.action.<name>`), the
|
|
130
|
+
* default label key (`admin.action.<name>`) AND the key `callAdminTool` resolves a handler by, so
|
|
131
|
+
* two actions sharing it dispatch to whichever `.find()` reached first — a call that succeeds
|
|
132
|
+
* against the wrong action and reports nothing. Refused here rather than in `adminMcp()`, because
|
|
133
|
+
* an app that renders the dashboard and never wires MCP has the same two broken label keys and
|
|
134
|
+
* the same ambiguous dispatch.
|
|
135
|
+
*
|
|
136
|
+
* Identity, not name, is what "already seen" means: `defineAdmin` attaches `input.actions` to the
|
|
137
|
+
* entity each one names AND appends `resources[<entity>].actions`, so an author who spelled the
|
|
138
|
+
* same object in both meant one action, not two.
|
|
139
|
+
*/
|
|
140
|
+
function assertUniqueActionNames(
|
|
141
|
+
resources: readonly AdminResource[],
|
|
142
|
+
declared: readonly AdminAction[],
|
|
143
|
+
): void {
|
|
144
|
+
const seen = new Map<string, { readonly action: AdminAction; readonly entities: string[] }>();
|
|
145
|
+
// `declared` is every action the caller passed, not just the global ones: an action naming an
|
|
146
|
+
// entity that is not in `entities` reaches neither list, and its name still has to be unique.
|
|
147
|
+
for (const action of [...resources.flatMap((resource) => resource.actions), ...declared]) {
|
|
148
|
+
const found = seen.get(action.name);
|
|
149
|
+
if (found === undefined) {
|
|
150
|
+
seen.set(action.name, { action, entities: [action.entity ?? 'the global toolbar'] });
|
|
151
|
+
continue;
|
|
152
|
+
}
|
|
153
|
+
if (found.action === action) continue;
|
|
154
|
+
found.entities.push(action.entity ?? 'the global toolbar');
|
|
155
|
+
throw new AdminActionDuplicateError({ name: action.name, entities: found.entities });
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
127
159
|
/** Derive the whole admin from the registries. */
|
|
128
160
|
export function defineAdmin(input: DefineAdminInput): AdminApp {
|
|
129
161
|
const basePath = input.basePath ?? '/admin';
|
|
@@ -141,6 +173,8 @@ export function defineAdmin(input: DefineAdminInput): AdminApp {
|
|
|
141
173
|
});
|
|
142
174
|
});
|
|
143
175
|
|
|
176
|
+
assertUniqueActionNames(resources, actions);
|
|
177
|
+
|
|
144
178
|
const pages = input.pages ?? [];
|
|
145
179
|
const navOptions = input.nav ?? {};
|
|
146
180
|
const extra: readonly (NavItem & { readonly group: string })[] = [
|
package/src/authz.ts
CHANGED
|
@@ -11,6 +11,14 @@ import { ADMIN_PERMISSION_SPEC, type AdminPermission } from './permissions';
|
|
|
11
11
|
|
|
12
12
|
export interface AdminActor {
|
|
13
13
|
readonly id: string;
|
|
14
|
+
/**
|
|
15
|
+
* The tenant this operator acts under. NOT optional in practice on a multi-tenant app: the
|
|
16
|
+
* admin's subject reached `policyAuthz` with `orgId: undefined`, so an org-scoped rule could
|
|
17
|
+
* not fire and a role-only rule allowed — while `adminList`/`adminSearch` add no tenant
|
|
18
|
+
* predicate of their own. Absent still means "single-tenant app", which is why the type keeps
|
|
19
|
+
* it optional; a rule that reads it must fail closed on `undefined` like any other policy.
|
|
20
|
+
*/
|
|
21
|
+
readonly orgId?: string;
|
|
14
22
|
readonly roles?: readonly string[];
|
|
15
23
|
/** BCP-47. Drives every `t()` call and every `Intl` format in the admin. */
|
|
16
24
|
readonly locale?: string;
|
|
@@ -23,6 +31,16 @@ export interface AdminSubject {
|
|
|
23
31
|
readonly entity?: string;
|
|
24
32
|
readonly id?: string;
|
|
25
33
|
readonly input?: unknown;
|
|
34
|
+
/**
|
|
35
|
+
* The already-loaded row, for a row-level rule. `null` means "no row was loaded", which the
|
|
36
|
+
* policy layer treats as no evidence of permission — same contract as an `action`'s `row:`
|
|
37
|
+
* loader. Absent entirely means the surface has no row to load (a list page, a create form).
|
|
38
|
+
*
|
|
39
|
+
* Without it every admin decision evaluated with `row: null` and `input = { entity, id }`, so
|
|
40
|
+
* an ownership rule could not fire at all and the coarse `admin:read` + `<entity>:read` pair
|
|
41
|
+
* was the only gate on a single row.
|
|
42
|
+
*/
|
|
43
|
+
readonly row?: unknown;
|
|
26
44
|
}
|
|
27
45
|
|
|
28
46
|
export interface AdminDecision {
|
package/src/crud.ts
CHANGED
|
@@ -59,10 +59,20 @@ export function decideOperation(
|
|
|
59
59
|
op: AdminOperation,
|
|
60
60
|
ctx: CrudCtx,
|
|
61
61
|
id?: string,
|
|
62
|
+
/**
|
|
63
|
+
* The row the surface ALREADY loaded, or `null` for "looked and found none". `undefined` means
|
|
64
|
+
* not loaded at all — a list page, a create form, a nav button — and is left off the subject
|
|
65
|
+
* entirely, because "there is no row here" and "there is a row and nobody loaded it" are
|
|
66
|
+
* different facts and only the second is a bug. Every admin decision used to be evaluated
|
|
67
|
+
* without one, so an ownership rule could not fire and the coarse `admin:read` + `<entity>:read`
|
|
68
|
+
* pair was the only gate on a single row.
|
|
69
|
+
*/
|
|
70
|
+
row?: AdminRow | null,
|
|
62
71
|
): AdminDecision {
|
|
63
72
|
return decideAll(ctx.authz, permissionsForOperation(resource.name, op), ctx.actor, {
|
|
64
73
|
entity: resource.name,
|
|
65
74
|
...(id === undefined ? {} : { id }),
|
|
75
|
+
...(row === undefined ? {} : { row }),
|
|
66
76
|
});
|
|
67
77
|
}
|
|
68
78
|
|
|
@@ -140,9 +150,12 @@ export async function adminDetail<Row extends AdminRow>(
|
|
|
140
150
|
ctx: CrudCtx,
|
|
141
151
|
id: string,
|
|
142
152
|
): Promise<CrudResult<Row>> {
|
|
143
|
-
|
|
144
|
-
|
|
153
|
+
// The row is loaded BEFORE the guard, the shape `packages/action/src/invoke.ts` uses for a
|
|
154
|
+
// row-level `policy`: a rule that decides about a row cannot decide without one, and the
|
|
155
|
+
// predicate has to stay synchronous. A denial still returns no row.
|
|
145
156
|
const row = await repoOf(resource).find(id);
|
|
157
|
+
const decision = decideOperation(resource, 'detail', ctx, id, row);
|
|
158
|
+
if (!decision.allowed) return refuse(resource, 'detail', ctx, decision, id);
|
|
146
159
|
return {
|
|
147
160
|
ok: true,
|
|
148
161
|
row,
|
|
@@ -196,11 +209,13 @@ export async function adminUpdate<Row extends AdminRow>(
|
|
|
196
209
|
id: string,
|
|
197
210
|
patch: Readonly<Record<string, unknown>>,
|
|
198
211
|
): Promise<CrudResult<Row>> {
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
212
|
+
// `before` was already loaded here, just after the guard rather than before it — so the rule
|
|
213
|
+
// that decides whether this actor may touch THIS row never saw the row.
|
|
202
214
|
const repo = repoOf(resource);
|
|
203
215
|
const before = await repo.find(id);
|
|
216
|
+
const decision = decideOperation(resource, 'update', ctx, id, before);
|
|
217
|
+
if (!decision.allowed) return refuse(resource, 'update', ctx, decision, id);
|
|
218
|
+
|
|
204
219
|
const parsed = await validateInput(resource.entity.$schema, { ...(before ?? {}), ...patch });
|
|
205
220
|
if (!parsed.ok) return invalid(resource, 'update', ctx, id, parsed.issues, decision);
|
|
206
221
|
|
|
@@ -208,9 +223,16 @@ export async function adminUpdate<Row extends AdminRow>(
|
|
|
208
223
|
// strip (undeclared, or normalized to a different value) must never reach the repo. Scoped
|
|
209
224
|
// to the keys actually submitted, so a partial update stays partial rather than rewriting
|
|
210
225
|
// every field of `before` too.
|
|
226
|
+
// `Object.hasOwn`, not `key in`: `in` walks the prototype chain, so a patch naming `toString`,
|
|
227
|
+
// `constructor` or `__proto__` put an inherited member into the object handed to `repo.update`
|
|
228
|
+
// — the exact thing the paragraph above says cannot happen. Over MCP the transport refuses
|
|
229
|
+
// those keys (`additionalProperties: false`), but `callAdminTool` and `adminUpdate` are both
|
|
230
|
+
// public API and `mcp.ts` keeps its own gate for a direct call and a future transport.
|
|
211
231
|
const submittedKeys = Object.keys(patch);
|
|
212
232
|
const validatedPatch: Readonly<Record<string, unknown>> = Object.fromEntries(
|
|
213
|
-
submittedKeys
|
|
233
|
+
submittedKeys
|
|
234
|
+
.filter((key) => Object.hasOwn(parsed.value, key))
|
|
235
|
+
.map((key) => [key, parsed.value[key]]),
|
|
214
236
|
);
|
|
215
237
|
const after = await repo.update(id, validatedPatch);
|
|
216
238
|
return {
|
|
@@ -238,7 +260,9 @@ export async function adminDestroy<Row extends AdminRow>(
|
|
|
238
260
|
id: string,
|
|
239
261
|
confirmation: string | undefined,
|
|
240
262
|
): Promise<CrudResult<Row>> {
|
|
241
|
-
const
|
|
263
|
+
const repo = repoOf(resource);
|
|
264
|
+
const before = await repo.find(id);
|
|
265
|
+
const decision = decideOperation(resource, 'delete', ctx, id, before);
|
|
242
266
|
if (!decision.allowed) return refuse(resource, 'delete', ctx, decision, id);
|
|
243
267
|
|
|
244
268
|
const expected = confirmationToken(resource.name, id);
|
|
@@ -258,8 +282,6 @@ export async function adminDestroy<Row extends AdminRow>(
|
|
|
258
282
|
);
|
|
259
283
|
}
|
|
260
284
|
|
|
261
|
-
const repo = repoOf(resource);
|
|
262
|
-
const before = await repo.find(id);
|
|
263
285
|
await repo.destroy(id);
|
|
264
286
|
return {
|
|
265
287
|
ok: true,
|
package/src/detail.tsx
CHANGED
|
@@ -33,6 +33,18 @@ export interface AdminDetailProps<Row extends AdminRow> {
|
|
|
33
33
|
readonly onCancel?: () => void;
|
|
34
34
|
}
|
|
35
35
|
|
|
36
|
+
/**
|
|
37
|
+
* The audit row's verb. `operation` holds a CRUD verb for `kind: 'operation'` and the ACTION NAME
|
|
38
|
+
* for `kind: 'action'`, and the catalog declares only `admin.operation.{list,detail,search,create,
|
|
39
|
+
* update,delete,page}` — so an entry for `post.publish` rendered the literal key
|
|
40
|
+
* `admin.operation.post.publish` into the page. `admin.action.<name>` is the same key
|
|
41
|
+
* `action-gate.ts` gives the button that ran it, so the two read identically.
|
|
42
|
+
*/
|
|
43
|
+
export const operationLabel = (entry: AuditEntry): string =>
|
|
44
|
+
entry.kind === 'action'
|
|
45
|
+
? t(`admin.action.${entry.operation}`)
|
|
46
|
+
: t(`admin.operation.${entry.operation}`);
|
|
47
|
+
|
|
36
48
|
export function AdminDetail<Row extends AdminRow>(props: AdminDetailProps<Row>): JSX.Element {
|
|
37
49
|
if (props.error !== null) {
|
|
38
50
|
return <ErrorState error={adminErrorFrom(props.error)} />;
|
|
@@ -102,7 +114,7 @@ export function AdminDetail<Row extends AdminRow>(props: AdminDetailProps<Row>):
|
|
|
102
114
|
{props.audit.map((entry) => (
|
|
103
115
|
<li>
|
|
104
116
|
<code>{entry.at}</code> <span>{entry.actor.id}</span>{' '}
|
|
105
|
-
<span>{
|
|
117
|
+
<span>{operationLabel(entry)}</span>{' '}
|
|
106
118
|
<span data-outcome={entry.outcome}>
|
|
107
119
|
{t(`admin.audit.outcome.${entry.outcome}`)}
|
|
108
120
|
</span>
|
package/src/dev/data.ts
CHANGED
|
@@ -63,17 +63,6 @@ export function staticDevSources(facts: Partial<DevSources> = {}): DevSources {
|
|
|
63
63
|
};
|
|
64
64
|
}
|
|
65
65
|
|
|
66
|
-
type Bag = Readonly<Record<string, unknown>>;
|
|
67
|
-
|
|
68
|
-
const bagOf = (value: unknown): Bag =>
|
|
69
|
-
typeof value === 'object' && value !== null ? (value as Bag) : {};
|
|
70
|
-
const str = (value: unknown, fallback = ''): string =>
|
|
71
|
-
typeof value === 'string' ? value : fallback;
|
|
72
|
-
const numOf = (value: unknown, fallback = 0): number =>
|
|
73
|
-
typeof value === 'number' ? value : fallback;
|
|
74
|
-
const listOf = (value: unknown): readonly unknown[] => (Array.isArray(value) ? value : []);
|
|
75
|
-
const strings = (value: unknown): readonly string[] => listOf(value).map((item) => String(item));
|
|
76
|
-
|
|
77
66
|
export interface DevSourceOptions {
|
|
78
67
|
/** The app's authz. The policy matrix is computed through it, never re-derived. */
|
|
79
68
|
readonly authz?: AdminAuthz;
|
|
@@ -136,9 +125,19 @@ const STEP_STATUS: Readonly<Record<StepStatus, JobStepFact['status']>> = {
|
|
|
136
125
|
};
|
|
137
126
|
|
|
138
127
|
/**
|
|
139
|
-
*
|
|
140
|
-
*
|
|
141
|
-
*
|
|
128
|
+
* Every registry is read through its OWN descriptor type, not as an untyped bag. The bag was
|
|
129
|
+
* defended here as tolerance — "a renamed field should show a blank cell rather than crash the
|
|
130
|
+
* process an engineer is debugging with" — and what it actually bought was three panels that
|
|
131
|
+
* were wrong for every row and could not go red: `route['render']`, `route['budget']`,
|
|
132
|
+
* `route['revalidate']` and `job['idempotencyKey']` are names no descriptor has ever published,
|
|
133
|
+
* so /_x reported every route as `stream` with no budget and every job as non-idempotent.
|
|
134
|
+
*
|
|
135
|
+
* The packages ship in lockstep at one version, so a renamed descriptor field is a rename this
|
|
136
|
+
* file can be edited with — as a TYPECHECK failure naming the field, which is the tolerance
|
|
137
|
+
* worth having. It costs the production graph nothing: `await import('@ultimat3/render')` is
|
|
138
|
+
* typed by the module it resolves to, so /_x is still reached only through the dynamic import
|
|
139
|
+
* and no descriptor type has to be named here at all. `published-keys.test.ts` is the other
|
|
140
|
+
* half — it walks what the real registries EMIT, which a stale `dist/*.d.ts` would hide.
|
|
142
141
|
*/
|
|
143
142
|
export function defaultDevSources(opts: DevSourceOptions = {}): DevSources {
|
|
144
143
|
const hooks = opts.hooks ?? {};
|
|
@@ -146,23 +145,26 @@ export function defaultDevSources(opts: DevSourceOptions = {}): DevSources {
|
|
|
146
145
|
const sources: DevSources = {
|
|
147
146
|
async routes(): Promise<readonly RouteFact[]> {
|
|
148
147
|
const { describeRoutes } = await import('@ultimat3/render');
|
|
149
|
-
return
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
}
|
|
165
|
-
|
|
148
|
+
return describeRoutes().map((route) => ({
|
|
149
|
+
path: route.path,
|
|
150
|
+
// `RouteDescriptor` calls the render mode `mode`; the panel's own word is `render`, and
|
|
151
|
+
// the two are bridged here, once. Reading `route['render']` answered `undefined` for
|
|
152
|
+
// every route, so the fallback shipped — `stream` for the whole table, forever.
|
|
153
|
+
render: route.mode,
|
|
154
|
+
offline: route.offline,
|
|
155
|
+
hydrate: route.hydrate,
|
|
156
|
+
// A descriptor has no `handler`: the FILE is what names the row.
|
|
157
|
+
handler: route.file,
|
|
158
|
+
// Two flat fields on the descriptor, one nested bag on the fact — the panel's budget
|
|
159
|
+
// check reads `budget.js`. Spread, never `js: undefined`: `exactOptionalPropertyTypes`.
|
|
160
|
+
budget: {
|
|
161
|
+
...(route.budgetJs === null ? {} : { js: route.budgetJs }),
|
|
162
|
+
...(route.budgetLcp === null ? {} : { lcp: route.budgetLcp }),
|
|
163
|
+
},
|
|
164
|
+
// `revalidateTags`, already flattened to keys — never `revalidate.tags`, a shape the
|
|
165
|
+
// descriptor does not have and which answered `[]` for every ISR route in the app.
|
|
166
|
+
revalidateTags: route.revalidateTags,
|
|
167
|
+
}));
|
|
166
168
|
},
|
|
167
169
|
|
|
168
170
|
traces: unwired<readonly RequestTrace[]>('traces', 'timeline'),
|
|
@@ -186,38 +188,30 @@ export function defaultDevSources(opts: DevSourceOptions = {}): DevSources {
|
|
|
186
188
|
entry.sql,
|
|
187
189
|
]),
|
|
188
190
|
);
|
|
189
|
-
return
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
policy: str(query['capability']),
|
|
198
|
-
sql: sqlByName.get(name) ?? null,
|
|
199
|
-
};
|
|
200
|
-
});
|
|
191
|
+
return queries.map((query) => ({
|
|
192
|
+
name: query.name,
|
|
193
|
+
live: query.live,
|
|
194
|
+
// `QueryDescriptor`'s permission field is named `capability`, not `policy` — this
|
|
195
|
+
// fact keeps its own field named `policy` (that is the /_x rendering, not the registry).
|
|
196
|
+
policy: query.capability,
|
|
197
|
+
sql: sqlByName.get(query.name) ?? null,
|
|
198
|
+
}));
|
|
201
199
|
},
|
|
202
200
|
|
|
203
201
|
subscribers: unwired<readonly LiveSubscriberFact[]>('subscribers', 'live'),
|
|
204
202
|
|
|
205
203
|
async jobDefs(): Promise<readonly JobDefFact[]> {
|
|
206
204
|
const { describeJobs } = await import('@ultimat3/jobs');
|
|
207
|
-
return
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
},
|
|
218
|
-
idempotent: job['idempotencyKey'] !== undefined,
|
|
219
|
-
};
|
|
220
|
-
});
|
|
205
|
+
return describeJobs().map((job) => ({
|
|
206
|
+
name: job.name,
|
|
207
|
+
queue: job.queue,
|
|
208
|
+
steps: job.steps,
|
|
209
|
+
retry: { attempts: job.retry.attempts, backoff: job.retry.backoff },
|
|
210
|
+
// The descriptor's own boolean. `job['idempotencyKey']` was a read of the DEFINITION's
|
|
211
|
+
// field on a descriptor that never carried it, so every job on the panel reported
|
|
212
|
+
// non-idempotent — the opposite of what `job()` refuses to register without.
|
|
213
|
+
idempotent: job.idempotent,
|
|
214
|
+
}));
|
|
221
215
|
},
|
|
222
216
|
|
|
223
217
|
async queues(): Promise<readonly QueueFact[]> {
|
|
@@ -301,38 +295,28 @@ export function defaultDevSources(opts: DevSourceOptions = {}): DevSources {
|
|
|
301
295
|
|
|
302
296
|
async tables(): Promise<readonly TableFact[]> {
|
|
303
297
|
const { describeEntities } = await import('@ultimat3/entity');
|
|
304
|
-
return
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
//
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
nullable: column['notNull'] !== true,
|
|
316
|
-
};
|
|
317
|
-
}),
|
|
318
|
-
};
|
|
319
|
-
});
|
|
298
|
+
return describeEntities().map((entity) => ({
|
|
299
|
+
name: entity.table,
|
|
300
|
+
// `EntityDescription.columns` is a LIST of physical columns — money is already two
|
|
301
|
+
// of them here. Reading it as a record produced a table whose columns were "0", "1".
|
|
302
|
+
columns: entity.columns.map((column) => ({
|
|
303
|
+
// The PHYSICAL name, which is the vocabulary a psql tab speaks.
|
|
304
|
+
name: column.column,
|
|
305
|
+
type: column.kind,
|
|
306
|
+
nullable: !column.notNull,
|
|
307
|
+
})),
|
|
308
|
+
}));
|
|
320
309
|
},
|
|
321
310
|
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
issue: str(issue['issue'], 'unknown'),
|
|
332
|
-
};
|
|
333
|
-
});
|
|
334
|
-
});
|
|
335
|
-
},
|
|
311
|
+
/**
|
|
312
|
+
* Unwired, and it has to be: drift is a COMPARISON — the entities this process declares
|
|
313
|
+
* against the columns a database actually has — and `describeEntities()` is only one half of
|
|
314
|
+
* it. This source used to read a `drift` key off `EntityDescription`, which has never had one,
|
|
315
|
+
* so the panel answered `[]` for every app and every schema: "no drift" printed over a
|
|
316
|
+
* database nobody looked at. Only a host holding the connection can answer (`x db migrate`'s
|
|
317
|
+
* `checkDrift`), so it wires the hook or the panel says the check did not run.
|
|
318
|
+
*/
|
|
319
|
+
drift: unwired<readonly DriftFact[]>('drift', 'db'),
|
|
336
320
|
|
|
337
321
|
runSql: unwired<SqlResult>('runSql', 'db'),
|
|
338
322
|
mail: unwired<readonly MailFact[]>('mail', 'mail'),
|
|
@@ -347,16 +331,13 @@ export function defaultDevSources(opts: DevSourceOptions = {}): DevSources {
|
|
|
347
331
|
import('@ultimat3/cache'),
|
|
348
332
|
import('@ultimat3/entity'),
|
|
349
333
|
]);
|
|
350
|
-
return
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
}),
|
|
358
|
-
};
|
|
359
|
-
});
|
|
334
|
+
return describeEntities().map((entity) => ({
|
|
335
|
+
tag: entity.name,
|
|
336
|
+
dependents: dependentsOf([{ entity: entity.name }]).map((dependent) => ({
|
|
337
|
+
kind: dependent.kind,
|
|
338
|
+
id: dependent.id,
|
|
339
|
+
})),
|
|
340
|
+
}));
|
|
360
341
|
},
|
|
361
342
|
|
|
362
343
|
invalidations: unwired<readonly InvalidationFact[]>('invalidations', 'cache'),
|
|
@@ -379,11 +360,15 @@ export function defaultDevSources(opts: DevSourceOptions = {}): DevSources {
|
|
|
379
360
|
});
|
|
380
361
|
}
|
|
381
362
|
const { describeActions } = await import('@ultimat3/action');
|
|
382
|
-
// `
|
|
383
|
-
//
|
|
384
|
-
//
|
|
363
|
+
// `permissions`, NOT `capability`. `capability` is the policy's DISPLAY label and
|
|
364
|
+
// `action.ts` says so: a composite renders as `and(post:read, org:member)`, which is not a
|
|
365
|
+
// permission and can never be granted — so every composite-guarded action was a
|
|
366
|
+
// permanently-denied row, and an operator read a real grant as missing. `permissions` is
|
|
367
|
+
// the flattened list a grant is actually matched against. This is the SECOND time the two
|
|
368
|
+
// were confused: `x policy list` reported the same actions as unenforced for the same
|
|
369
|
+
// reason, and `ActionDescriptor.permissions` carries that history in its own doc comment.
|
|
385
370
|
const permissions = [
|
|
386
|
-
...new Set(
|
|
371
|
+
...new Set(describeActions().flatMap((action) => action.permissions)),
|
|
387
372
|
].filter((permission) => permission !== '');
|
|
388
373
|
|
|
389
374
|
return actors.flatMap((actor) =>
|
package/src/dev/facts.ts
CHANGED
|
@@ -1,15 +1,27 @@
|
|
|
1
1
|
// The facts a /_x panel may read: one shape per introspection call. Kept apart from the
|
|
2
2
|
// sources that produce them so a panel imports the shape it renders and nothing else.
|
|
3
3
|
|
|
4
|
+
/**
|
|
5
|
+
* One row of the route table, in the panel's own words: `render` is the descriptor's `mode` and
|
|
6
|
+
* `handler` is its `file`. Both renames are made in `data.ts`, once.
|
|
7
|
+
*
|
|
8
|
+
* There is no `hasMeta`, deliberately. `defineRoute()` REFUSES a route with no `meta` function
|
|
9
|
+
* (`packages/render/src/route.ts`), so every registered route has one and the field could only
|
|
10
|
+
* ever be `true` — it was published as `false` for every route instead, off a `meta` key no
|
|
11
|
+
* descriptor carries, and fed a `missingMeta` list that can never have a member. Whether the meta
|
|
12
|
+
* a route returns is any GOOD is a render-time question: `meta` takes the loaded data, so nothing
|
|
13
|
+
* static can answer it, and a list that always reads "0 routes missing meta" is worse than absent.
|
|
14
|
+
*/
|
|
4
15
|
export interface RouteFact {
|
|
5
16
|
readonly path: string;
|
|
17
|
+
/** `RouteDescriptor.mode` — `ssr`, `isr`, `static`, `stream`, `spa`. */
|
|
6
18
|
readonly render: string;
|
|
7
19
|
readonly offline: string;
|
|
8
20
|
readonly hydrate: string;
|
|
21
|
+
/** The route's source file: what names the row. A descriptor publishes no handler. */
|
|
9
22
|
readonly handler: string;
|
|
10
23
|
readonly budget: { readonly js?: string; readonly lcp?: number };
|
|
11
24
|
readonly revalidateTags: readonly string[];
|
|
12
|
-
readonly hasMeta: boolean;
|
|
13
25
|
}
|
|
14
26
|
|
|
15
27
|
/**
|
|
@@ -85,6 +97,12 @@ export interface JobDefFact {
|
|
|
85
97
|
readonly queue: string;
|
|
86
98
|
readonly steps: readonly string[];
|
|
87
99
|
readonly retry: { readonly attempts: number; readonly backoff: string };
|
|
100
|
+
/**
|
|
101
|
+
* `JobDescriptor.idempotent` — `true` for every registered job, because `job()` refuses a
|
|
102
|
+
* definition with no `idempotencyKey`. Published rather than assumed: it read the DEFINITION's
|
|
103
|
+
* `idempotencyKey` off a descriptor that never carried it, so the panel called every job in
|
|
104
|
+
* the app unsafe to retry.
|
|
105
|
+
*/
|
|
88
106
|
readonly idempotent: boolean;
|
|
89
107
|
}
|
|
90
108
|
|
|
@@ -153,6 +171,11 @@ export interface TableFact {
|
|
|
153
171
|
readonly columns: readonly ColumnFact[];
|
|
154
172
|
}
|
|
155
173
|
|
|
174
|
+
/**
|
|
175
|
+
* One column the database and the entities disagree about. A COMPARISON, so no registry can
|
|
176
|
+
* produce it alone — `DevSources.drift` is a hook a host holding the connection wires, and the
|
|
177
|
+
* db panel reports `drift: null` ("nobody checked") rather than `[]` ("nothing wrong") without it.
|
|
178
|
+
*/
|
|
156
179
|
export interface DriftFact {
|
|
157
180
|
readonly table: string;
|
|
158
181
|
readonly column: string | null;
|
package/src/dev/panel-db.ts
CHANGED
|
@@ -4,12 +4,19 @@
|
|
|
4
4
|
|
|
5
5
|
import { isUltimateError } from '@ultimat3/core';
|
|
6
6
|
import { assertReadOnlyQuery } from '@ultimat3/mcp';
|
|
7
|
+
import { DevSourceUnavailableError } from '../errors';
|
|
7
8
|
import type { DriftFact, SqlResult, TableFact } from './facts';
|
|
8
9
|
import type { DevPanel } from './panel';
|
|
9
10
|
|
|
10
11
|
export interface DbPanelData {
|
|
11
12
|
readonly tables: readonly TableFact[];
|
|
12
|
-
|
|
13
|
+
/**
|
|
14
|
+
* `null` when no host wired the check — NOT `[]`. Drift is the entities against the database,
|
|
15
|
+
* so it needs the connection this process may not have, and an empty list reads as "the schema
|
|
16
|
+
* matches", which is the one answer a panel that never looked has not earned. The same
|
|
17
|
+
* distinction `panel-timeline.ts` draws for `nPlusOne` and `panel-live.ts` for its subscribers.
|
|
18
|
+
*/
|
|
19
|
+
readonly drift: readonly DriftFact[] | null;
|
|
13
20
|
readonly sql: string | null;
|
|
14
21
|
readonly result: SqlResult | null;
|
|
15
22
|
/** Set when a statement was refused; the panel shows it instead of a result grid. */
|
|
@@ -95,7 +102,16 @@ export const dbPanel: DevPanel<DbPanelData> = {
|
|
|
95
102
|
titleKey: 'dev.panel.db.title',
|
|
96
103
|
questionKey: 'dev.panel.db.question',
|
|
97
104
|
async data(sources, params): Promise<DbPanelData> {
|
|
98
|
-
|
|
105
|
+
// The drift half degrades on its own: a host with no drift check must still get the table
|
|
106
|
+
// list and the SQL box. Only `DevSourceUnavailableError` is caught — a checker that ran and
|
|
107
|
+
// FAILED is a diagnostic, and reaches `panelPayload` with its code and its fix.
|
|
108
|
+
const [tables, drift] = await Promise.all([
|
|
109
|
+
sources.tables(),
|
|
110
|
+
sources.drift().catch((error: unknown) => {
|
|
111
|
+
if (!(error instanceof DevSourceUnavailableError)) throw error;
|
|
112
|
+
return null;
|
|
113
|
+
}),
|
|
114
|
+
]);
|
|
99
115
|
const sql = params.get('sql');
|
|
100
116
|
if (sql === null || sql.trim() === '') {
|
|
101
117
|
return { tables, drift, sql: null, result: null, refused: null, readOnly: true };
|
package/src/dev/panel-routes.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// Panel: Routes.
|
|
2
|
-
// Kills: "which handler serves this?" — the route table with render mode, offline strategy
|
|
3
|
-
//
|
|
2
|
+
// Kills: "which handler serves this?" — the route table with render mode, offline strategy
|
|
3
|
+
// and budget.
|
|
4
4
|
|
|
5
5
|
import type { RouteFact } from './facts';
|
|
6
6
|
import type { DevPanel } from './panel';
|
|
@@ -9,7 +9,9 @@ export interface RoutesPanelData {
|
|
|
9
9
|
readonly routes: readonly RouteFact[];
|
|
10
10
|
/** Counts per render mode: an app that is all `ssr` has a caching problem to find. */
|
|
11
11
|
readonly byRenderMode: Readonly<Record<string, number>>;
|
|
12
|
-
|
|
12
|
+
// No `missingMeta`. `defineRoute()` refuses a route without a `meta` function, so the list had
|
|
13
|
+
// no member it could ever hold — and it was published from a `RouteFact.hasMeta` that read a
|
|
14
|
+
// key no descriptor has, which made it name EVERY route instead. See `RouteFact` in `facts.ts`.
|
|
13
15
|
readonly overBudget: readonly string[];
|
|
14
16
|
}
|
|
15
17
|
|
|
@@ -34,7 +36,6 @@ export const routesPanel: DevPanel<RoutesPanelData> = {
|
|
|
34
36
|
return {
|
|
35
37
|
routes: [...routes].sort((a, b) => a.path.localeCompare(b.path)),
|
|
36
38
|
byRenderMode,
|
|
37
|
-
missingMeta: routes.filter((route) => !route.hasMeta).map((route) => route.path),
|
|
38
39
|
overBudget: routes
|
|
39
40
|
.filter((route) => kb(route.budget.js) > BUDGET_LIMIT_KB)
|
|
40
41
|
.map((route) => route.path),
|
package/src/dev/panel.ts
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
// draws — and because `data()` returns plain JSON, `--json` and the rendered tab are the
|
|
3
3
|
// same facts by construction.
|
|
4
4
|
|
|
5
|
+
import { renderThrowable, stringField } from '@ultimat3/core';
|
|
5
6
|
import type { DevSources } from './facts';
|
|
6
7
|
|
|
7
8
|
export interface DevPanel<Data = unknown> {
|
|
@@ -25,12 +26,6 @@ export type PanelPayload =
|
|
|
25
26
|
readonly error: { readonly code: string; readonly cause: string; readonly fix: string };
|
|
26
27
|
};
|
|
27
28
|
|
|
28
|
-
interface ErrorFields {
|
|
29
|
-
readonly code?: unknown;
|
|
30
|
-
readonly cause?: unknown;
|
|
31
|
-
readonly fix?: unknown;
|
|
32
|
-
}
|
|
33
|
-
|
|
34
29
|
/**
|
|
35
30
|
* A panel whose source is not wired must say so in the panel, with the fix line — the same
|
|
36
31
|
* payload the CLI prints. A blank tab would read as "nothing is happening".
|
|
@@ -43,19 +38,18 @@ export async function panelPayload(
|
|
|
43
38
|
try {
|
|
44
39
|
return { panel: panel.key, ok: true, data: await panel.data(sources, params) };
|
|
45
40
|
} catch (error) {
|
|
46
|
-
|
|
41
|
+
// Core's total readers, not `String(error)` and a raw property read. This catch owes its
|
|
42
|
+
// caller a `/_x` RESPONSE: `String(Object.create(null))` throws, and so does a getter or a
|
|
43
|
+
// Proxy trap on `error.code` — either turns a rendered failure panel into an unhandled
|
|
44
|
+
// rejection on the request. `stringField` answers `undefined` for absent, wrong type and
|
|
45
|
+
// threw alike, which is what every branch below already meant.
|
|
47
46
|
return {
|
|
48
47
|
panel: panel.key,
|
|
49
48
|
ok: false,
|
|
50
49
|
error: {
|
|
51
|
-
code:
|
|
52
|
-
cause:
|
|
53
|
-
|
|
54
|
-
? fields.cause
|
|
55
|
-
: error instanceof Error
|
|
56
|
-
? error.message
|
|
57
|
-
: String(error),
|
|
58
|
-
fix: typeof fields.fix === 'string' ? fields.fix : 'x dev --help',
|
|
50
|
+
code: stringField(error, 'code') ?? 'X_NOT_IMPLEMENTED',
|
|
51
|
+
cause: stringField(error, 'cause') ?? renderThrowable(error),
|
|
52
|
+
fix: stringField(error, 'fix') ?? 'x dev --help',
|
|
59
53
|
},
|
|
60
54
|
};
|
|
61
55
|
}
|
package/src/errors.ts
CHANGED
|
@@ -21,6 +21,10 @@ export const ADMIN_OWNED_ERROR_CODES = [
|
|
|
21
21
|
// screen, is a defect the author must never be able to deploy.
|
|
22
22
|
'X_ADMIN_PAGE_UNGUARDED',
|
|
23
23
|
'X_ADMIN_PAGE_PATH_INVALID',
|
|
24
|
+
// `AdminAction.name` addresses one handler in three places at once — the MCP tool name, the
|
|
25
|
+
// default label key, and `callAdminTool`'s lookup. Two actions sharing it is refused where the
|
|
26
|
+
// admin is DECLARED, so an app that never wires MCP still cannot ship the ambiguity.
|
|
27
|
+
'X_ADMIN_ACTION_DUPLICATE',
|
|
24
28
|
] as const;
|
|
25
29
|
|
|
26
30
|
/**
|
|
@@ -48,6 +52,7 @@ export const ADMIN_ERROR_TITLES: Readonly<Record<AdminOwnedErrorCode, string>> =
|
|
|
48
52
|
X_ADMIN_INVALID: "an admin tool's arguments failed the resource schema",
|
|
49
53
|
X_ADMIN_PAGE_UNGUARDED: 'a custom admin page declared no permissions',
|
|
50
54
|
X_ADMIN_PAGE_PATH_INVALID: 'a custom admin page has an unusable or already-taken path',
|
|
55
|
+
X_ADMIN_ACTION_DUPLICATE: 'two admin actions share one name',
|
|
51
56
|
};
|
|
52
57
|
|
|
53
58
|
// One unconditional call, so a second package claiming one of admin's codes throws
|
|
@@ -90,6 +95,25 @@ export class AdminFieldUnsupportedError extends UltimateError {
|
|
|
90
95
|
}
|
|
91
96
|
}
|
|
92
97
|
|
|
98
|
+
/**
|
|
99
|
+
* Two registered actions carry one `name`. Refused at `defineAdmin`, not at the first call: the
|
|
100
|
+
* name is the MCP tool name, the default label key AND the key `callAdminTool` resolves a handler
|
|
101
|
+
* by, so a collision is a call that succeeds against the wrong action and reports nothing.
|
|
102
|
+
*/
|
|
103
|
+
export class AdminActionDuplicateError extends UltimateError {
|
|
104
|
+
constructor(input: { name: string; entities: readonly string[] }) {
|
|
105
|
+
const where = input.entities.length > 0 ? input.entities.join(' and ') : 'the global toolbar';
|
|
106
|
+
super({
|
|
107
|
+
code: 'X_ADMIN_ACTION_DUPLICATE',
|
|
108
|
+
cause: `two admin actions are named "${input.name}" (on ${where}); an action name addresses one handler`,
|
|
109
|
+
// The convention the framework's own examples already follow, made into the instruction:
|
|
110
|
+
// an entity-qualified name is unique by construction.
|
|
111
|
+
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
|
+
});
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
93
117
|
/**
|
|
94
118
|
* An action reached the admin without a policy — the button would be an open door.
|
|
95
119
|
*
|
package/src/index.ts
CHANGED
package/src/inert-jsx.ts
ADDED
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
// TEST-ONLY. Calls an admin `.tsx` view as the plain function it is and walks what came back, so
|
|
2
|
+
// a test can assert on the tree a screen produced rather than on a value it also constructed.
|
|
3
|
+
//
|
|
4
|
+
// Which JSX factory a `.tsx` in this package compiled to is NOT ours to choose: `@ultimat3/render`
|
|
5
|
+
// installs a process-global `Bun.plugin` `onLoad` for `/\.tsx$/` at import (`routes.ts` imports it,
|
|
6
|
+
// so any sibling suite can win the race) and `bun test` is one process. Both factories build the
|
|
7
|
+
// same shape — a `type`, a `props`, children under `props.children` — so this walker recognises
|
|
8
|
+
// both and the run's file order stops deciding. Recognising neither is what falls through to
|
|
9
|
+
// `String(value)` and asserts against `"[object Object]"`.
|
|
10
|
+
|
|
11
|
+
/** `@ultimat3/render`'s node brand, read off the GLOBAL symbol registry rather than imported. */
|
|
12
|
+
const RENDER_NODE: symbol = Symbol.for('ultimate.render.jsx');
|
|
13
|
+
|
|
14
|
+
export type InertComponent = (props: Record<string, unknown>) => unknown;
|
|
15
|
+
|
|
16
|
+
export interface InertNode {
|
|
17
|
+
readonly type: string | InertComponent;
|
|
18
|
+
readonly props: Record<string, unknown>;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function isInertNode(value: unknown): value is InertNode {
|
|
22
|
+
if (typeof value !== 'object' || value === null) return false;
|
|
23
|
+
return 'inert' in value || RENDER_NODE in value;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** The classic factory these `.tsx` files fall back to under `jsx: 'preserve'`. */
|
|
27
|
+
export function h(
|
|
28
|
+
type: string | InertComponent,
|
|
29
|
+
props: Record<string, unknown> | null,
|
|
30
|
+
...children: readonly unknown[]
|
|
31
|
+
): InertNode {
|
|
32
|
+
const base = { ...(props ?? {}) };
|
|
33
|
+
if (children.length > 0) base['children'] = children.length === 1 ? children[0] : children;
|
|
34
|
+
return { inert: true, type, props: base } as unknown as InertNode;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* How many installs are live, and what `globalThis.React` was before the first one. A harness that
|
|
39
|
+
* DELETED the property on the way out destroys a binding it did not create, and a nested install
|
|
40
|
+
* would tear the factory out from under the suite still using it — the counter is what makes the
|
|
41
|
+
* last restore the only one that acts.
|
|
42
|
+
*/
|
|
43
|
+
let depth = 0;
|
|
44
|
+
let saved: PropertyDescriptor | undefined;
|
|
45
|
+
|
|
46
|
+
export function installFactory(): void {
|
|
47
|
+
if (depth === 0) {
|
|
48
|
+
// The descriptor, not the value: the property may be a getter or non-writable, and `assign`
|
|
49
|
+
// onto either throws or silently loses.
|
|
50
|
+
saved = Object.getOwnPropertyDescriptor(globalThis, 'React');
|
|
51
|
+
Object.defineProperty(globalThis, 'React', {
|
|
52
|
+
value: { createElement: h },
|
|
53
|
+
configurable: true,
|
|
54
|
+
writable: true,
|
|
55
|
+
enumerable: true,
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
depth += 1;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Undo the matching `installFactory()`. Unbalanced calls are inert. */
|
|
62
|
+
export function restoreFactory(): void {
|
|
63
|
+
if (depth === 0) return;
|
|
64
|
+
depth -= 1;
|
|
65
|
+
if (depth > 0) return;
|
|
66
|
+
if (saved === undefined) Reflect.deleteProperty(globalThis, 'React');
|
|
67
|
+
else Object.defineProperty(globalThis, 'React', saved);
|
|
68
|
+
saved = undefined;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Every node in the tree, depth first, with nested components CALLED and thunks invoked — a
|
|
73
|
+
* component that reads a prop inside one renders nothing until something asks.
|
|
74
|
+
*
|
|
75
|
+
* Component nodes are KEPT as well as expanded, so a test can assert on the props a view handed
|
|
76
|
+
* `<Money>` — the admin's contract with the design system — and not only on the markup ui chose
|
|
77
|
+
* to emit for them, which is ui's business and changes without this package changing.
|
|
78
|
+
*/
|
|
79
|
+
export function nodesOf(value: unknown): InertNode[] {
|
|
80
|
+
if (value === null || value === undefined || typeof value === 'boolean') return [];
|
|
81
|
+
if (typeof value === 'string' || typeof value === 'number') return [];
|
|
82
|
+
if (Array.isArray(value)) return value.flatMap(nodesOf);
|
|
83
|
+
if (isInertNode(value)) {
|
|
84
|
+
if (typeof value.type === 'function') return [value, ...nodesOf(value.type(value.props))];
|
|
85
|
+
return [value, ...nodesOf(value.props['children'])];
|
|
86
|
+
}
|
|
87
|
+
if (typeof value === 'function') return nodesOf((value as () => unknown)());
|
|
88
|
+
return [];
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* The tree this package itself built: component nodes are kept but NOT called, so the walk stops
|
|
93
|
+
* at the design-system boundary. Use this when a design-system component's own internals would
|
|
94
|
+
* otherwise answer a query about the admin's markup — `<Dialog>` renders a close `<button>`, and a
|
|
95
|
+
* test asking "which buttons did the action bar render" must not be handed ui's.
|
|
96
|
+
*/
|
|
97
|
+
export function shallowNodesOf(value: unknown): InertNode[] {
|
|
98
|
+
if (value === null || value === undefined || typeof value === 'boolean') return [];
|
|
99
|
+
if (typeof value === 'string' || typeof value === 'number') return [];
|
|
100
|
+
if (Array.isArray(value)) return value.flatMap(shallowNodesOf);
|
|
101
|
+
if (isInertNode(value)) return [value, ...shallowNodesOf(value.props['children'])];
|
|
102
|
+
if (typeof value === 'function') return shallowNodesOf((value as () => unknown)());
|
|
103
|
+
return [];
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** Nodes rendered by the named component — `byComponent(nodes, 'Money')`. */
|
|
107
|
+
export function byComponent(nodes: readonly InertNode[], name: string): InertNode[] {
|
|
108
|
+
return nodes.filter((node) => typeof node.type === 'function' && node.type.name === name);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const SKIPPED_PROPS = new Set(['children', 'ref', 'innerHTML']);
|
|
112
|
+
|
|
113
|
+
function attributes(props: Record<string, unknown>): string {
|
|
114
|
+
return Object.entries(props)
|
|
115
|
+
.filter(([name, value]) => {
|
|
116
|
+
if (SKIPPED_PROPS.has(name) || name.startsWith('on')) return false;
|
|
117
|
+
return value !== undefined && value !== null && value !== false;
|
|
118
|
+
})
|
|
119
|
+
.map(([name, value]) => (value === true ? ` ${name}` : ` ${name}="${String(value)}"`))
|
|
120
|
+
.join('');
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** The tree as markup. Unknown values are `String()`d, which the premise test refuses to allow. */
|
|
124
|
+
export function renderHtml(value: unknown): string {
|
|
125
|
+
if (value === null || value === undefined || typeof value === 'boolean') return '';
|
|
126
|
+
if (typeof value === 'string' || typeof value === 'number') return String(value);
|
|
127
|
+
if (Array.isArray(value)) return value.map(renderHtml).join('');
|
|
128
|
+
if (isInertNode(value)) {
|
|
129
|
+
if (typeof value.type === 'function') return renderHtml(value.type(value.props));
|
|
130
|
+
const inner = renderHtml(value.props['children']);
|
|
131
|
+
return `<${value.type}${attributes(value.props)}>${inner}</${value.type}>`;
|
|
132
|
+
}
|
|
133
|
+
if (typeof value === 'function') return renderHtml((value as () => unknown)());
|
|
134
|
+
return String(value);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** Render a component to its host nodes. `props` is the component's own, untyped on purpose. */
|
|
138
|
+
export function renderNodes(component: unknown, props: Record<string, unknown> = {}): InertNode[] {
|
|
139
|
+
return nodesOf((component as InertComponent)(props));
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* `renderNodes`, stopping at the design-system boundary — the `shallowNodesOf` sibling. The
|
|
144
|
+
* `unknown` parameter is what makes it usable: a screen's props are typed and generic, so a test
|
|
145
|
+
* calling one through `as (props: Record<string, unknown>) => unknown` is a conversion TypeScript
|
|
146
|
+
* refuses outright. One widening here beats one per call site.
|
|
147
|
+
*/
|
|
148
|
+
export function renderShallowNodes(
|
|
149
|
+
component: unknown,
|
|
150
|
+
props: Record<string, unknown> = {},
|
|
151
|
+
): InertNode[] {
|
|
152
|
+
return shallowNodesOf((component as InertComponent)(props));
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** Render a component to markup. */
|
|
156
|
+
export function renderComponent(component: unknown, props: Record<string, unknown> = {}): string {
|
|
157
|
+
return renderHtml((component as InertComponent)(props));
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
export function byTag(nodes: readonly InertNode[], tag: string): InertNode[] {
|
|
161
|
+
return nodes.filter((node) => node.type === tag);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** Nodes whose attribute equals `value`; `undefined` matches "carries the attribute at all". */
|
|
165
|
+
export function withAttr(nodes: readonly InertNode[], name: string, value?: unknown): InertNode[] {
|
|
166
|
+
return nodes.filter((node) =>
|
|
167
|
+
value === undefined ? node.props[name] !== undefined : node.props[name] === value,
|
|
168
|
+
);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** The one node a test means, or a throw naming what it looked for — never a silent `undefined`. */
|
|
172
|
+
export function one(nodes: readonly InertNode[], what: string): InertNode {
|
|
173
|
+
const node = nodes[0];
|
|
174
|
+
if (node === undefined || nodes.length !== 1) {
|
|
175
|
+
throw new Error(`expected exactly one ${what}, found ${nodes.length}`);
|
|
176
|
+
}
|
|
177
|
+
return node;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/** Call an element's event handler prop, e.g. `fire(button, 'onClick', {})`. */
|
|
181
|
+
export function fire(node: InertNode, handler: string, event: unknown): void {
|
|
182
|
+
const fn = node.props[handler];
|
|
183
|
+
if (typeof fn !== 'function') {
|
|
184
|
+
throw new Error(`node <${String(node.type)}> carries no ${handler}`);
|
|
185
|
+
}
|
|
186
|
+
(fn as (e: unknown) => void)(event);
|
|
187
|
+
}
|
package/src/mcp.ts
CHANGED
|
@@ -2,7 +2,14 @@
|
|
|
2
2
|
// `defineAppMcp` so the user's agents drive the user's app. Same authz, same audit, same
|
|
3
3
|
// confirmation rules as the buttons — this file adds a transport, not a second back door.
|
|
4
4
|
|
|
5
|
-
import {
|
|
5
|
+
import type { Actor } from '@ultimat3/core';
|
|
6
|
+
import {
|
|
7
|
+
agentActor,
|
|
8
|
+
createContext,
|
|
9
|
+
hasContext,
|
|
10
|
+
runWithContext,
|
|
11
|
+
withChildContext,
|
|
12
|
+
} from '@ultimat3/core';
|
|
6
13
|
import {
|
|
7
14
|
type AnyMcpTool,
|
|
8
15
|
type AppMcp,
|
|
@@ -257,6 +264,28 @@ function allowedToolNames(
|
|
|
257
264
|
return names;
|
|
258
265
|
}
|
|
259
266
|
|
|
267
|
+
/**
|
|
268
|
+
* Run the call AS the MCP caller — the ambient actor, not only the `CrudCtx` one.
|
|
269
|
+
*
|
|
270
|
+
* `AdminApp.ctx()` builds a plain `CrudCtx` and never touches core's async context, so everything
|
|
271
|
+
* deriving from `tryUseContext()` — entity's tenant guard, the query cache authority, the
|
|
272
|
+
* jit-preload store — saw whatever actor the TRANSPORT's surrounding request installed rather than
|
|
273
|
+
* the token's. Over `mcpHttpRoute` mounted in a pipeline that resolves a session cookie, an agent
|
|
274
|
+
* token authorized as agent X while the repo reads ran as cookie-user Y's tenant; over stdio there
|
|
275
|
+
* was no context at all, so `actorTenant` was `undefined` and `assertRowTenant` became a no-op and
|
|
276
|
+
* an admin `create` could name any `orgId`. Same rule as `@ultimat3/mcp`'s `app-tool.ts`: the
|
|
277
|
+
* caller is the actor for the WHOLE call, by construction rather than by two call sites agreeing.
|
|
278
|
+
*
|
|
279
|
+
* Two spellings, one rule, because there are two transports: a child context when a request is
|
|
280
|
+
* already in flight (it keeps the parent's `requestId` and rebuilds the managed services against
|
|
281
|
+
* the child's actor), a fresh ROOT context over stdio — where `withChildContext` would throw
|
|
282
|
+
* `X_NO_CONTEXT` from its own `useContext()`.
|
|
283
|
+
*/
|
|
284
|
+
const asCaller = <T>(actor: Actor, requestId: string, run: () => Promise<T>): Promise<T> =>
|
|
285
|
+
hasContext()
|
|
286
|
+
? withChildContext({ actor }, run)
|
|
287
|
+
: runWithContext(createContext({ actor, requestId }), run);
|
|
288
|
+
|
|
260
289
|
function toMcpTool(opts: AdminMcpOptions, requestId: () => string, tool: AdminMcpTool): AnyMcpTool {
|
|
261
290
|
return {
|
|
262
291
|
name: tool.name,
|
|
@@ -270,8 +299,11 @@ function toMcpTool(opts: AdminMcpOptions, requestId: () => string, tool: AdminMc
|
|
|
270
299
|
visibleTo: (caller: McpCaller): boolean =>
|
|
271
300
|
allowedToolNames(opts, requestId, caller).has(tool.name),
|
|
272
301
|
async handle(args: ToolArgs, caller: McpCaller): Promise<McpToolResult> {
|
|
273
|
-
const
|
|
274
|
-
const
|
|
302
|
+
const id = requestId();
|
|
303
|
+
const ctx = opts.app.ctx({ actor: adminActorOf(caller), requestId: id });
|
|
304
|
+
const result = await asCaller(caller.actor, id, () =>
|
|
305
|
+
callAdminTool(opts.app, ctx, tool.name, args),
|
|
306
|
+
);
|
|
275
307
|
if (result.ok) return jsonResult(result.data);
|
|
276
308
|
// An expected outcome the model should reason about (a policy said no), not a
|
|
277
309
|
// protocol error: the transport still answers 200 with the denial in the body.
|
package/src/policy-bridge.ts
CHANGED
|
@@ -15,7 +15,10 @@ export const adminPermissions = definePermissions(ADMIN_PERMISSIONS);
|
|
|
15
15
|
* mapping lives here because this file is the only one allowed to speak to the policy layer.
|
|
16
16
|
*/
|
|
17
17
|
const policyActor = (actor: AdminActor): Actor =>
|
|
18
|
-
|
|
18
|
+
// `orgId` rides along, and its absence used to be silent: every admin decision was evaluated
|
|
19
|
+
// with `actor.orgId === undefined`, so an org-scoped rule could not fire and a role-only rule
|
|
20
|
+
// allowed a row from another tenant.
|
|
21
|
+
userActor({ id: actor.id, roles: actor.roles ?? [], orgId: actor.orgId });
|
|
19
22
|
|
|
20
23
|
/**
|
|
21
24
|
* `evaluate()`'s result is read structurally: the policy layer owns its own decision type,
|
|
@@ -55,9 +58,13 @@ export function policyAuthz(input: PolicyAuthzInput): AdminAuthz {
|
|
|
55
58
|
// `EvaluateArgs` carries exactly one payload. An admin subject with no action input IS
|
|
56
59
|
// that payload: a row-level rule has nothing but the entity and id to decide on.
|
|
57
60
|
const payload = subject?.input ?? subject;
|
|
61
|
+
// `row` is passed through only when the surface LOADED one. Omitting it and passing
|
|
62
|
+
// `row: undefined` are different facts to `evaluate`, and a rule that reads `row` must see
|
|
63
|
+
// `null` — "no row was loaded" — rather than a value the admin invented from `input`.
|
|
64
|
+
const row = subject === undefined || !('row' in subject) ? undefined : { row: subject.row };
|
|
58
65
|
return readDecision(
|
|
59
66
|
permission,
|
|
60
|
-
evaluate(policy, { actor: policyActor(actor), input: payload }),
|
|
67
|
+
evaluate(policy, { actor: policyActor(actor), input: payload, ...row }),
|
|
61
68
|
);
|
|
62
69
|
},
|
|
63
70
|
};
|
package/src/resource.ts
CHANGED
|
@@ -69,7 +69,16 @@ export interface AdminResourceOptions<Row extends AdminRow = AdminRow> {
|
|
|
69
69
|
|
|
70
70
|
export interface AdminResource<Row extends AdminRow = AdminRow> {
|
|
71
71
|
readonly name: string;
|
|
72
|
-
/**
|
|
72
|
+
/**
|
|
73
|
+
* Mount-relative, e.g. `/posts` — the layout composes it with `AdminApp.basePath`, so this is
|
|
74
|
+
* never the URL on its own.
|
|
75
|
+
*
|
|
76
|
+
* Defaults to the entity's `$name` VERBATIM. It used to be `pluralize($name)`, which is why the
|
|
77
|
+
* reference app's dashboard served `/orgses`, `/postses` and `/commentses`: every entity in both
|
|
78
|
+
* tracked apps is already named plural (19 of 19), so the heuristic ran a second time on a word
|
|
79
|
+
* that had already had it. Which plural a name takes is an app's convention and not a mechanism
|
|
80
|
+
* the framework can own (axiom 8) — `path:` is how an app spells its own.
|
|
81
|
+
*/
|
|
73
82
|
readonly path: string;
|
|
74
83
|
readonly titleKey: string;
|
|
75
84
|
readonly group: string;
|
|
@@ -90,12 +99,6 @@ export interface AdminResource<Row extends AdminRow = AdminRow> {
|
|
|
90
99
|
field(name: string): AdminField;
|
|
91
100
|
}
|
|
92
101
|
|
|
93
|
-
const pluralize = (name: string): string => {
|
|
94
|
-
if (/[sxz]$/.test(name) || /(ch|sh)$/.test(name)) return `${name}es`;
|
|
95
|
-
if (/[^aeiou]y$/.test(name)) return `${name.slice(0, -1)}ies`;
|
|
96
|
-
return `${name}s`;
|
|
97
|
-
};
|
|
98
|
-
|
|
99
102
|
function deriveField(
|
|
100
103
|
entity: AdminEntity,
|
|
101
104
|
column: AdminColumnFacts,
|
|
@@ -213,7 +216,9 @@ function pickListFields(
|
|
|
213
216
|
entity: entityName,
|
|
214
217
|
field: name,
|
|
215
218
|
cause: 'listed in listFields but not a column of the entity',
|
|
216
|
-
|
|
219
|
+
// `x db gen` and NOT `x g migration`: there is no `migration` generator, and the fix
|
|
220
|
+
// line an operator pastes has to be a command the CLI actually dispatches.
|
|
221
|
+
fix: `remove "${name}" from listFields, or add the column with x db gen "add ${name} to ${entityName}"`,
|
|
217
222
|
});
|
|
218
223
|
}
|
|
219
224
|
return field;
|
|
@@ -261,7 +266,7 @@ export function adminResource<Row extends AdminRow = AdminRow>(
|
|
|
261
266
|
|
|
262
267
|
const resource: AdminResource<Row> = {
|
|
263
268
|
name: entity.$name,
|
|
264
|
-
path: opts.path ?? `/${
|
|
269
|
+
path: opts.path ?? `/${entity.$name}`,
|
|
265
270
|
titleKey: opts.titleKey ?? `admin.${entity.$name}.title`,
|
|
266
271
|
group: opts.group ?? 'admin.group.data',
|
|
267
272
|
idField,
|
package/src/search.ts
CHANGED
|
@@ -3,8 +3,10 @@
|
|
|
3
3
|
// the same `admin:read` + `<entity>:read` pair gates the hit and the detail page it links to.
|
|
4
4
|
|
|
5
5
|
import { expectedQueryLoop } from '@ultimat3/db';
|
|
6
|
+
import { type AuditEntry, deniedDraft } from './audit';
|
|
7
|
+
import { denied } from './authz';
|
|
6
8
|
import type { CrudCtx } from './crud';
|
|
7
|
-
import {
|
|
9
|
+
import { decideOperation } from './crud';
|
|
8
10
|
import type { AdminFilter, AdminRow } from './registry';
|
|
9
11
|
import { rowId } from './registry';
|
|
10
12
|
import { type AdminResource, repoOf } from './resource';
|
|
@@ -29,6 +31,14 @@ export interface AdminSearchResult {
|
|
|
29
31
|
readonly searched: readonly string[];
|
|
30
32
|
/** Resources left out, and why — so an operator is never silently shown a subset. */
|
|
31
33
|
readonly skipped: readonly { readonly entity: string; readonly reason: string }[];
|
|
34
|
+
/**
|
|
35
|
+
* One entry per resource this call decided about: allowed, or refused. Search read rows out of
|
|
36
|
+
* every readable entity and wrote NOTHING, on either path — while `permissions.ts` carries
|
|
37
|
+
* `audited: true` for it, `audit.ts` says denied attempts are logged too, and `CLAUDE.md` says
|
|
38
|
+
* "every admin operation is audited, reads included". Shaped like `adminList`'s: the subject is
|
|
39
|
+
* the table, so there is no `entityId`.
|
|
40
|
+
*/
|
|
41
|
+
readonly audit: readonly AuditEntry[];
|
|
32
42
|
}
|
|
33
43
|
|
|
34
44
|
export interface AdminSearchInput {
|
|
@@ -89,20 +99,45 @@ function labelOf(row: AdminRow, resource: AdminResource): string {
|
|
|
89
99
|
return rowId(row, resource.idField);
|
|
90
100
|
}
|
|
91
101
|
|
|
102
|
+
const SKIPPED_FORBIDDEN = 'admin.search.skipped.forbidden';
|
|
103
|
+
|
|
92
104
|
export async function adminSearch(input: AdminSearchInput): Promise<AdminSearchResult> {
|
|
105
|
+
const { ctx } = input;
|
|
93
106
|
const term = input.term.trim();
|
|
94
107
|
const limit = input.limitPerResource ?? DEFAULT_LIMIT_PER_RESOURCE;
|
|
95
108
|
const searched: string[] = [];
|
|
96
109
|
const skipped: { entity: string; reason: string }[] = [];
|
|
97
110
|
const hits: AdminSearchHit[] = [];
|
|
111
|
+
const audit: AuditEntry[] = [];
|
|
98
112
|
|
|
99
|
-
|
|
113
|
+
// Nothing was decided and nothing was read, so there is nothing to log: an empty term never
|
|
114
|
+
// reaches a repo.
|
|
115
|
+
if (term === '') return { term, hits: [], searched: [], skipped: [], audit: [] };
|
|
100
116
|
|
|
101
117
|
for (const resource of input.resources) {
|
|
102
|
-
|
|
103
|
-
|
|
118
|
+
const offered = resource.operations.includes('search');
|
|
119
|
+
const decision = decideOperation(resource, 'search', ctx);
|
|
120
|
+
if (!offered || !decision.allowed) {
|
|
121
|
+
skipped.push({ entity: resource.name, reason: SKIPPED_FORBIDDEN });
|
|
122
|
+
audit.push(
|
|
123
|
+
await ctx.audit.append(
|
|
124
|
+
deniedDraft({
|
|
125
|
+
requestId: ctx.requestId,
|
|
126
|
+
actor: ctx.actor,
|
|
127
|
+
operation: 'search',
|
|
128
|
+
kind: 'operation',
|
|
129
|
+
entity: resource.name,
|
|
130
|
+
entityId: null,
|
|
131
|
+
// A resource that does not OFFER search was refused by the registry, not by a policy,
|
|
132
|
+
// and the decision object would otherwise read `allow` on a denied entry.
|
|
133
|
+
decision: offered ? decision : denied(decision.permission, SKIPPED_FORBIDDEN),
|
|
134
|
+
}),
|
|
135
|
+
),
|
|
136
|
+
);
|
|
104
137
|
continue;
|
|
105
138
|
}
|
|
139
|
+
// Neither of these is an authorization event: the resource is searchable in principle and has
|
|
140
|
+
// no text index or no repo to ask. They stay in `skipped` and out of the log.
|
|
106
141
|
if (resource.searchFields.length === 0) {
|
|
107
142
|
skipped.push({ entity: resource.name, reason: 'admin.search.skipped.no-text-fields' });
|
|
108
143
|
continue;
|
|
@@ -112,8 +147,23 @@ export async function adminSearch(input: AdminSearchInput): Promise<AdminSearchR
|
|
|
112
147
|
continue;
|
|
113
148
|
}
|
|
114
149
|
searched.push(resource.name);
|
|
150
|
+
// Appended BEFORE the read, so a repo that throws still leaves the record that the rows were
|
|
151
|
+
// asked for — `audit.ts`: if it isn't logged, it didn't happen.
|
|
152
|
+
audit.push(
|
|
153
|
+
await ctx.audit.append({
|
|
154
|
+
requestId: ctx.requestId,
|
|
155
|
+
actor: ctx.actor,
|
|
156
|
+
operation: 'search',
|
|
157
|
+
kind: 'operation',
|
|
158
|
+
entity: resource.name,
|
|
159
|
+
entityId: null,
|
|
160
|
+
permission: decision.permission,
|
|
161
|
+
outcome: 'allowed',
|
|
162
|
+
reason: decision.reason,
|
|
163
|
+
}),
|
|
164
|
+
);
|
|
115
165
|
hits.push(...(await searchResource(resource, term, limit)));
|
|
116
166
|
}
|
|
117
167
|
|
|
118
|
-
return { term, hits, searched, skipped };
|
|
168
|
+
return { term, hits, searched, skipped, audit };
|
|
119
169
|
}
|
package/src/widget-value.ts
CHANGED
|
@@ -92,7 +92,8 @@ export function assertMoney(field: AdminField, value: unknown): Money | null {
|
|
|
92
92
|
return fail(
|
|
93
93
|
field,
|
|
94
94
|
`money value arrived as the number ${value}; money is integer minor units + an ISO currency`,
|
|
95
|
-
`
|
|
95
|
+
// `x db gen` writes migrations; `x g migration` is not a generator and never was.
|
|
96
|
+
`store ${field.name} as { minor, currency } — x db gen "change ${field.entity}.${field.name} to money"`,
|
|
96
97
|
);
|
|
97
98
|
}
|
|
98
99
|
if (typeof value !== 'object') {
|