@ultimat3/admin 10.0.0 → 11.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 +2 -2
- package/package.json +16 -16
- package/src/dev/server.ts +6 -0
- package/src/mcp.ts +18 -2
package/CLAUDE.md
CHANGED
|
@@ -7,8 +7,8 @@ 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-
|
|
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
|
+
- **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-bridge.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`. **And the caller's TENANT rides both hops** — `resolveToken` mints the `Actor`, `adminActorOf` rebuilds the `AdminActor` from it, and each dropped `orgId`, so every `AdminAuthz` decision over `POST /mcp` was made with `actor.orgId === undefined` while the same app's UI path saw the real org. Pinned in `mcp-tenant.test.ts`, which asserts the org an authz decision RECEIVES, over `route.handle` — the catalog filter and the call both.
|
|
12
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
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.
|
|
14
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.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ultimat3/admin",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "11.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": "11.1.0",
|
|
36
|
+
"@ultimat3/ai": "11.1.0",
|
|
37
|
+
"@ultimat3/cache": "11.1.0",
|
|
38
|
+
"@ultimat3/core": "11.1.0",
|
|
39
|
+
"@ultimat3/db": "11.1.0",
|
|
40
|
+
"@ultimat3/entity": "11.1.0",
|
|
41
|
+
"@ultimat3/i18n": "11.1.0",
|
|
42
|
+
"@ultimat3/jobs": "11.1.0",
|
|
43
|
+
"@ultimat3/mcp": "11.1.0",
|
|
44
|
+
"@ultimat3/money": "11.1.0",
|
|
45
|
+
"@ultimat3/policy": "11.1.0",
|
|
46
|
+
"@ultimat3/query": "11.1.0",
|
|
47
|
+
"@ultimat3/render": "11.1.0",
|
|
48
|
+
"@ultimat3/schema": "11.1.0",
|
|
49
|
+
"@ultimat3/ui": "11.1.0"
|
|
50
50
|
}
|
|
51
51
|
}
|
package/src/dev/server.ts
CHANGED
|
@@ -136,6 +136,12 @@ html[data-theme="light"] { ${block('light')} }
|
|
|
136
136
|
html[data-theme="dark"] { ${block('dark')} }
|
|
137
137
|
${SHELL_LAYOUT}`;
|
|
138
138
|
});
|
|
139
|
+
// A rejected import (a transient resolution failure) must not be memoised, or `/_x` is
|
|
140
|
+
// unstyled for the life of the process — the `jwks.ts` inflight pattern.
|
|
141
|
+
stylePromise = stylePromise.catch((error: unknown) => {
|
|
142
|
+
stylePromise = undefined;
|
|
143
|
+
throw error;
|
|
144
|
+
});
|
|
139
145
|
return stylePromise;
|
|
140
146
|
}
|
|
141
147
|
|
package/src/mcp.ts
CHANGED
|
@@ -210,11 +210,19 @@ const inputSchema = (tool: AdminMcpTool): JsonSchema => ({
|
|
|
210
210
|
|
|
211
211
|
/**
|
|
212
212
|
* `caller.actor` is whatever `resolveToken` returned, so the identity the tool runs as is the
|
|
213
|
-
* one the session authenticated as — id and
|
|
213
|
+
* one the session authenticated as — id, roles and the TENANT are the authz reads.
|
|
214
|
+
*
|
|
215
|
+
* `orgId` rides the whole way or an org-scoped rule cannot fire: this hop rebuilt an `AdminActor`
|
|
216
|
+
* from id and roles alone, so every `AdminAuthz` decision over `POST /mcp` was evaluated with
|
|
217
|
+
* `actor.orgId === undefined` while the same app's UI path saw the real org — and
|
|
218
|
+
* `adminList`/`adminSearch` add no tenant predicate of their own. Absent stays ABSENT rather than
|
|
219
|
+
* becoming an explicit `undefined` key: `policy-bridge.ts` hands it to `userActor`, where
|
|
220
|
+
* "single-tenant app" and "we dropped it" must not be the same value by accident.
|
|
214
221
|
*/
|
|
215
222
|
const adminActorOf = (caller: McpCaller): AdminActor => ({
|
|
216
223
|
id: caller.actor.id,
|
|
217
224
|
roles: caller.actor.roles,
|
|
225
|
+
...(caller.actor.orgId === undefined ? {} : { orgId: caller.actor.orgId }),
|
|
218
226
|
});
|
|
219
227
|
|
|
220
228
|
/**
|
|
@@ -343,9 +351,17 @@ export function adminMcp(opts: AdminMcpOptions): AppMcp {
|
|
|
343
351
|
const actor = await opts.actor({ token });
|
|
344
352
|
// `kind: 'agent'` — the same actor shape an agent gets everywhere else, so a policy
|
|
345
353
|
// that distinguishes agents from people keeps working on this surface.
|
|
354
|
+
//
|
|
355
|
+
// `orgId` is carried because this is the FIRST of the two hops between the app's resolver
|
|
356
|
+
// and an authz decision (`adminActorOf` is the second): dropped here, the tenant is gone
|
|
357
|
+
// before any tool, catalog filter or child context can see it, and nothing downstream can
|
|
358
|
+
// put it back.
|
|
346
359
|
return actor === null
|
|
347
360
|
? null
|
|
348
|
-
: {
|
|
361
|
+
: {
|
|
362
|
+
actor: agentActor({ id: actor.id, roles: actor.roles ?? [], orgId: actor.orgId }),
|
|
363
|
+
scopes: new Set(),
|
|
364
|
+
};
|
|
349
365
|
},
|
|
350
366
|
});
|
|
351
367
|
}
|