@ultimat3/admin 21.0.0 → 22.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/README.md +16 -0
- package/package.json +16 -16
- package/src/action-gate.ts +18 -5
- package/src/crud.ts +20 -1
- package/src/mcp.ts +11 -3
package/README.md
CHANGED
|
@@ -230,3 +230,19 @@ Panes are off until enabled, and `runAiPane` refuses (never no-ops) without a ru
|
|
|
230
230
|
## Errors
|
|
231
231
|
|
|
232
232
|
`X_ADMIN_ENTITY_UNKNOWN` · `X_ADMIN_FIELD_UNSUPPORTED` · `X_ADMIN_POLICY_MISSING` · `X_ADMIN_PAGE_UNGUARDED` · `X_ADMIN_PAGE_PATH_INVALID` · `X_ADMIN_DENIED` · `X_ADMIN_TOOL_FORBIDDEN` · `X_ADMIN_INVALID` · `X_DEV_DASHBOARD_IN_PROD` · `X_NOT_IMPLEMENTED` (an unwired `/_x` source, carrying the wiring line).
|
|
233
|
+
|
|
234
|
+
### Error classes
|
|
235
|
+
|
|
236
|
+
Every error class `src/index.ts` exports, for `instanceof` inside one process. Across a wire or
|
|
237
|
+
a job boundary the class is gone and the `code` is what survives — match on that.
|
|
238
|
+
|
|
239
|
+
| Class | Code | Declared in |
|
|
240
|
+
|---|---|---|
|
|
241
|
+
| `AdminActionDuplicateError` | `X_ADMIN_ACTION_DUPLICATE` | `src/errors.ts` |
|
|
242
|
+
| `AdminEntityUnknownError` | `X_ADMIN_ENTITY_UNKNOWN` | `src/errors.ts` |
|
|
243
|
+
| `AdminFieldUnsupportedError` | `X_ADMIN_FIELD_UNSUPPORTED` | `src/errors.ts` |
|
|
244
|
+
| `AdminPagePathInvalidError` | `X_ADMIN_PAGE_PATH_INVALID` | `src/errors.ts` |
|
|
245
|
+
| `AdminPageUnguardedError` | `X_ADMIN_PAGE_UNGUARDED` | `src/errors.ts` |
|
|
246
|
+
| `AdminPolicyMissingError` | `X_ADMIN_POLICY_MISSING` | `src/errors.ts` |
|
|
247
|
+
| `DevDashboardInProdError` | `X_DEV_DASHBOARD_IN_PROD` | `src/errors.ts` |
|
|
248
|
+
| `DevSourceUnavailableError` | `X_NOT_IMPLEMENTED` | `src/errors.ts` |
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ultimat3/admin",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "22.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": "22.0.0",
|
|
36
|
+
"@ultimat3/ai": "22.0.0",
|
|
37
|
+
"@ultimat3/cache": "22.0.0",
|
|
38
|
+
"@ultimat3/core": "22.0.0",
|
|
39
|
+
"@ultimat3/db": "22.0.0",
|
|
40
|
+
"@ultimat3/entity": "22.0.0",
|
|
41
|
+
"@ultimat3/i18n": "22.0.0",
|
|
42
|
+
"@ultimat3/jobs": "22.0.0",
|
|
43
|
+
"@ultimat3/mcp": "22.0.0",
|
|
44
|
+
"@ultimat3/money": "22.0.0",
|
|
45
|
+
"@ultimat3/policy": "22.0.0",
|
|
46
|
+
"@ultimat3/query": "22.0.0",
|
|
47
|
+
"@ultimat3/render": "22.0.0",
|
|
48
|
+
"@ultimat3/schema": "22.0.0",
|
|
49
|
+
"@ultimat3/ui": "22.0.0"
|
|
50
50
|
}
|
|
51
51
|
}
|
package/src/action-gate.ts
CHANGED
|
@@ -11,7 +11,12 @@ import {
|
|
|
11
11
|
type AdminSubject,
|
|
12
12
|
decideAll,
|
|
13
13
|
} from './authz';
|
|
14
|
-
import {
|
|
14
|
+
import {
|
|
15
|
+
ADMIN_DESTROY,
|
|
16
|
+
ADMIN_WRITE,
|
|
17
|
+
CONFIRMATION_REQUIRED_REASON,
|
|
18
|
+
confirmationToken,
|
|
19
|
+
} from './permissions';
|
|
15
20
|
import type { AdminAction, AdminActionCtx } from './registry';
|
|
16
21
|
|
|
17
22
|
export interface AdminActionButton {
|
|
@@ -94,9 +99,16 @@ export interface InvokeInput<Input, Output> {
|
|
|
94
99
|
readonly audit: AuditLog;
|
|
95
100
|
readonly requestId: string;
|
|
96
101
|
readonly subject?: AdminSubject;
|
|
97
|
-
/**
|
|
102
|
+
/**
|
|
103
|
+
* Echo of `confirmationToken(entity, subject.id)`. Required for a destructive action, and the
|
|
104
|
+
* gate DERIVES the token it compares against — it took the caller's `expectedConfirmation`
|
|
105
|
+
* until 22.0.0, and with both omitted `undefined !== undefined` ran the action unconfirmed.
|
|
106
|
+
*
|
|
107
|
+
* A type-to-confirm guard against ACCIDENTS, the same design as `adminDestroy`: the token is not
|
|
108
|
+
* a secret, and a hostile caller can compute it. What stops that caller is the `admin:destroy`
|
|
109
|
+
* permission this gate checks first.
|
|
110
|
+
*/
|
|
98
111
|
readonly confirmation?: string;
|
|
99
|
-
readonly expectedConfirmation?: string;
|
|
100
112
|
readonly locale?: string;
|
|
101
113
|
readonly timeZone?: string;
|
|
102
114
|
/** Before/after of the affected row, when the caller knows it. Always logged. */
|
|
@@ -134,12 +146,13 @@ export async function invokeAdminAction<Input, Output>(
|
|
|
134
146
|
};
|
|
135
147
|
}
|
|
136
148
|
|
|
137
|
-
|
|
149
|
+
const expected = confirmationToken(entity, entityId ?? '');
|
|
150
|
+
if (action.destructive === true && args.confirmation !== expected) {
|
|
138
151
|
const refused: AdminDecision = {
|
|
139
152
|
allowed: false,
|
|
140
153
|
permission: ADMIN_DESTROY,
|
|
141
154
|
reason: CONFIRMATION_REQUIRED_REASON,
|
|
142
|
-
trace: [`confirmation: expected "${
|
|
155
|
+
trace: [`confirmation: expected "${expected}"`],
|
|
143
156
|
};
|
|
144
157
|
return {
|
|
145
158
|
ok: false,
|
package/src/crud.ts
CHANGED
|
@@ -69,6 +69,18 @@ export function decideOperation(
|
|
|
69
69
|
*/
|
|
70
70
|
row?: AdminRow | null,
|
|
71
71
|
): AdminDecision {
|
|
72
|
+
// First, before any grant is consulted: an operation the resource does not OFFER is refused for
|
|
73
|
+
// everyone. `canOperate` checked this for the nav, the buttons and the MCP tools, and the three
|
|
74
|
+
// direct write functions did not — a `['list', 'detail']` resource deleted a row through
|
|
75
|
+
// `adminDestroy`. Here, every caller of this function asks it.
|
|
76
|
+
if (!operationOffered(resource, op)) {
|
|
77
|
+
return {
|
|
78
|
+
allowed: false,
|
|
79
|
+
permission: adminPermissionFor(op),
|
|
80
|
+
reason: OPERATION_NOT_OFFERED_REASON,
|
|
81
|
+
trace: [`operations: ${resource.name} does not offer ${op}`],
|
|
82
|
+
};
|
|
83
|
+
}
|
|
72
84
|
return decideAll(ctx.authz, permissionsForOperation(resource.name, op), ctx.actor, {
|
|
73
85
|
entity: resource.name,
|
|
74
86
|
...(id === undefined ? {} : { id }),
|
|
@@ -76,9 +88,16 @@ export function decideOperation(
|
|
|
76
88
|
});
|
|
77
89
|
}
|
|
78
90
|
|
|
91
|
+
/** Whether the resource declares this operation at all — the one answer, `decideOperation`'s. */
|
|
92
|
+
export const operationOffered = (resource: AdminResource, op: AdminOperation): boolean =>
|
|
93
|
+
resource.operations.includes(op);
|
|
94
|
+
|
|
95
|
+
/** The key an operation the resource does not offer is refused with. */
|
|
96
|
+
export const OPERATION_NOT_OFFERED_REASON = 'admin.error.operation-not-offered';
|
|
97
|
+
|
|
79
98
|
/** `true` when the operation should be offered at all — nav, buttons, MCP tool list. */
|
|
80
99
|
export function canOperate(resource: AdminResource, op: AdminOperation, ctx: CrudCtx): boolean {
|
|
81
|
-
return
|
|
100
|
+
return decideOperation(resource, op, ctx).allowed;
|
|
82
101
|
}
|
|
83
102
|
|
|
84
103
|
const redactedFields = (resource: AdminResource): readonly string[] =>
|
package/src/mcp.ts
CHANGED
|
@@ -35,8 +35,8 @@ import {
|
|
|
35
35
|
} from './crud';
|
|
36
36
|
import type { AdminFieldType } from './fields';
|
|
37
37
|
import { type AdminMcpTool, adminMcpTools, adminToolCatalog } from './mcp-tools';
|
|
38
|
-
import { confirmationToken } from './permissions';
|
|
39
38
|
import type { AdminAction, AdminRow } from './registry';
|
|
39
|
+
import { repoOf } from './resource';
|
|
40
40
|
import { adminSearch } from './search';
|
|
41
41
|
|
|
42
42
|
export type McpInput = Readonly<Record<string, unknown>>;
|
|
@@ -111,6 +111,13 @@ async function dispatch(
|
|
|
111
111
|
return { ok: false, error: 'X_ADMIN_TOOL_FORBIDDEN', reason: 'action is not registered' };
|
|
112
112
|
}
|
|
113
113
|
const id = str(input, 'id');
|
|
114
|
+
// The row a row-level rule decides about, loaded through the resource that OWNS the action —
|
|
115
|
+
// what the UI's action button does. Without it the subject carried an id and no row, so an
|
|
116
|
+
// ownership rule could never allow. `null` for a row that does not exist: no evidence of
|
|
117
|
+
// permission, same contract as an action's `row:` loader. A global action names no resource.
|
|
118
|
+
const owner = app.resources.find((resource) => resource.actions.includes(action));
|
|
119
|
+
const row =
|
|
120
|
+
owner === undefined || id === '' ? undefined : ((await repoOf(owner).find(id)) ?? null);
|
|
114
121
|
const result = await invokeAdminAction({
|
|
115
122
|
action,
|
|
116
123
|
input: withoutKeys(input, ['confirmation']),
|
|
@@ -121,10 +128,11 @@ async function dispatch(
|
|
|
121
128
|
subject: {
|
|
122
129
|
...(action.entity === undefined ? {} : { entity: action.entity }),
|
|
123
130
|
...(id === '' ? {} : { id }),
|
|
131
|
+
...(row === undefined ? {} : { row }),
|
|
124
132
|
},
|
|
133
|
+
// The agent must echo the token, exactly as the UI makes an operator type it — and the gate
|
|
134
|
+
// derives the token it expects from the entity and `subject.id` above.
|
|
125
135
|
confirmation: str(input, 'confirmation'),
|
|
126
|
-
// The agent must echo the token, exactly as the UI makes an operator type it.
|
|
127
|
-
expectedConfirmation: confirmationToken(action.entity ?? 'admin', id),
|
|
128
136
|
});
|
|
129
137
|
return result.ok
|
|
130
138
|
? { ok: true, data: result.value }
|