@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 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": "21.0.0",
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": "21.0.0",
36
- "@ultimat3/ai": "21.0.0",
37
- "@ultimat3/cache": "21.0.0",
38
- "@ultimat3/core": "21.0.0",
39
- "@ultimat3/db": "21.0.0",
40
- "@ultimat3/entity": "21.0.0",
41
- "@ultimat3/i18n": "21.0.0",
42
- "@ultimat3/jobs": "21.0.0",
43
- "@ultimat3/mcp": "21.0.0",
44
- "@ultimat3/money": "21.0.0",
45
- "@ultimat3/policy": "21.0.0",
46
- "@ultimat3/query": "21.0.0",
47
- "@ultimat3/render": "21.0.0",
48
- "@ultimat3/schema": "21.0.0",
49
- "@ultimat3/ui": "21.0.0"
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
  }
@@ -11,7 +11,12 @@ import {
11
11
  type AdminSubject,
12
12
  decideAll,
13
13
  } from './authz';
14
- import { ADMIN_DESTROY, ADMIN_WRITE, CONFIRMATION_REQUIRED_REASON } from './permissions';
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
- /** Echo of `confirmationToken(entity, id)`. Required for a destructive action. */
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
- if (action.destructive === true && args.confirmation !== args.expectedConfirmation) {
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 "${args.expectedConfirmation ?? ''}"`],
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 resource.operations.includes(op) && decideOperation(resource, op, ctx).allowed;
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 }