@ultimat3/admin 22.14.0 → 23.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.
Files changed (89) hide show
  1. package/CLAUDE.md +73 -80
  2. package/README.md +276 -87
  3. package/package.json +19 -17
  4. package/src/action-form.tsx +139 -0
  5. package/src/action-gate.ts +127 -28
  6. package/src/action-input.ts +119 -0
  7. package/src/action-label.ts +9 -0
  8. package/src/action-row.ts +37 -0
  9. package/src/actions.tsx +89 -66
  10. package/src/actor.ts +53 -0
  11. package/src/admin.module.scss +298 -0
  12. package/src/admin.ts +187 -23
  13. package/src/audit-pg.ts +207 -0
  14. package/src/audit-schema.ts +47 -0
  15. package/src/audit.ts +109 -22
  16. package/src/authz.ts +11 -0
  17. package/src/batch-bar.tsx +66 -0
  18. package/src/batch-job.ts +152 -0
  19. package/src/batch-matching.ts +92 -0
  20. package/src/batch.ts +431 -0
  21. package/src/catalog-keys.ts +55 -0
  22. package/src/crud-input.ts +104 -0
  23. package/src/crud-outcome.ts +166 -0
  24. package/src/crud.ts +129 -177
  25. package/src/describe.ts +131 -0
  26. package/src/detail.tsx +115 -43
  27. package/src/dev/data.ts +24 -6
  28. package/src/dev/facts.ts +27 -0
  29. package/src/dev/panel-db.ts +37 -5
  30. package/src/dev/panel-jobs.ts +8 -0
  31. package/src/dev/panel-routes.ts +10 -0
  32. package/src/dev/panel.ts +6 -0
  33. package/src/dev/server.ts +15 -7
  34. package/src/entity-columns.ts +24 -5
  35. package/src/errors.ts +83 -0
  36. package/src/fields.ts +63 -4
  37. package/src/form-decode.ts +91 -0
  38. package/src/form.tsx +78 -45
  39. package/src/index.ts +198 -9
  40. package/src/jobs/fleet-repo.ts +143 -0
  41. package/src/jobs/job-actions.ts +172 -0
  42. package/src/jobs/job-entities.ts +118 -0
  43. package/src/jobs/job-labels.ts +108 -0
  44. package/src/jobs/job-repo.ts +103 -0
  45. package/src/jobs/job-resources.ts +211 -0
  46. package/src/jobs/job-where.ts +170 -0
  47. package/src/jobs/operator.ts +34 -0
  48. package/src/jobs/overview-data.ts +129 -0
  49. package/src/jobs/overview-tab.tsx +61 -0
  50. package/src/jobs/overview.tsx +189 -0
  51. package/src/json-text.ts +52 -0
  52. package/src/layout.tsx +55 -43
  53. package/src/list-columns.tsx +53 -0
  54. package/src/list-filter-bar.tsx +207 -0
  55. package/src/list-filters.ts +217 -0
  56. package/src/list-request.ts +127 -0
  57. package/src/list-scope-tabs.tsx +60 -0
  58. package/src/list-scope.ts +113 -0
  59. package/src/list.tsx +111 -72
  60. package/src/mcp-tools.ts +57 -13
  61. package/src/mcp.ts +108 -38
  62. package/src/mounts.ts +44 -0
  63. package/src/page-guard.tsx +26 -29
  64. package/src/policy-bridge.ts +110 -15
  65. package/src/registry.ts +138 -8
  66. package/src/related.ts +105 -0
  67. package/src/relations.ts +224 -0
  68. package/src/renderers.tsx +89 -0
  69. package/src/repo-entity.ts +204 -0
  70. package/src/resource-fields.ts +116 -0
  71. package/src/resource-layout.ts +89 -0
  72. package/src/resource-list.ts +185 -0
  73. package/src/resource.ts +129 -79
  74. package/src/route-config.ts +71 -0
  75. package/src/routes.ts +55 -54
  76. package/src/row-scope-write.ts +130 -0
  77. package/src/screen-action.tsx +333 -0
  78. package/src/screen-frame.tsx +167 -0
  79. package/src/screen-home.tsx +201 -0
  80. package/src/screen-lookup.tsx +157 -0
  81. package/src/screen-resource.tsx +393 -0
  82. package/src/screen-system.tsx +69 -0
  83. package/src/screens.ts +56 -0
  84. package/src/search.ts +13 -9
  85. package/src/stylesheet-scope.ts +8 -0
  86. package/src/test-ctx.ts +31 -0
  87. package/src/validate.ts +3 -0
  88. package/src/widget-value.ts +36 -8
  89. package/src/widgets.tsx +84 -12
package/src/mcp-tools.ts CHANGED
@@ -11,6 +11,7 @@ import { type AdminDecision, decideAll } from './authz';
11
11
  import { type CrudCtx, decideOperation, permissionsForOperation } from './crud';
12
12
  import type { AdminFieldType } from './fields';
13
13
  import type { AdminOperation } from './permissions';
14
+ import type { AdminAction } from './registry';
14
15
  import type { AdminResource } from './resource';
15
16
 
16
17
  export type AdminToolKind = 'list' | 'read' | 'search' | 'create' | 'update' | 'delete' | 'action';
@@ -19,6 +20,8 @@ export interface AdminToolField {
19
20
  readonly name: string;
20
21
  readonly type: AdminFieldType;
21
22
  readonly required: boolean;
23
+ /** The argument is a LIST of `type`: the list tool's `where` is a list of predicates. */
24
+ readonly list?: true;
22
25
  }
23
26
 
24
27
  export interface AdminMcpTool {
@@ -49,11 +52,30 @@ const ID_FIELD: AdminToolField = { name: 'id', type: 'text', required: true };
49
52
  * description of the same contract. `mcp.ts` leaves an action tool's schema OPEN instead, so the
50
53
  * action's own validation is the one that decides.
51
54
  */
52
- const actionEnvelope = (destructive: boolean): readonly AdminToolField[] => [
55
+ const actionEnvelope = (action: AdminAction): readonly AdminToolField[] => [
53
56
  { name: 'id', type: 'text', required: false },
54
- ...(destructive ? [{ name: 'confirmation', type: 'text', required: true } as const] : []),
57
+ // A batch action is the SAME tool, given a list of rows instead of one: run once per row through
58
+ // the button's gate, answered with the counts the batch bar shows.
59
+ ...(action.batch === undefined
60
+ ? []
61
+ : [{ name: 'ids', type: 'text', required: false, list: true } as const]),
62
+ ...(action.destructive === true
63
+ ? [{ name: 'confirmation', type: 'text', required: true } as const]
64
+ : []),
55
65
  ];
56
66
 
67
+ /** What an agent reads about when and over what an action runs — the schema cannot say it. */
68
+ const actionDescription = (action: AdminAction): string =>
69
+ [
70
+ action.mcp?.description ?? `Run the ${action.name} action.`,
71
+ action.when === undefined
72
+ ? ''
73
+ : ' Applies only to some rows: a row it does not apply to is refused with X_ADMIN_ACTION_NOT_APPLICABLE.',
74
+ action.batch === undefined
75
+ ? ''
76
+ : ` Pass ids: [...] to run it once per row; the answer counts done, refused and failed${action.destructive === true ? ', and confirmation is "<entity>:<n> rows"' : ''}.`,
77
+ ].join('');
78
+
57
79
  const formFields = (resource: AdminResource): readonly AdminToolField[] =>
58
80
  resource.formFields.map((field) => ({
59
81
  name: field.name,
@@ -61,18 +83,40 @@ const formFields = (resource: AdminResource): readonly AdminToolField[] =>
61
83
  required: field.required,
62
84
  }));
63
85
 
86
+ /**
87
+ * The list tool says what it filters by, because a schema cannot: `where` is a list of
88
+ * `{ field, op?, value }` and the fields and scopes are this resource's own. The same names the
89
+ * list screen's URL takes — one grammar, two transports.
90
+ */
91
+ const listDescription = (resource: AdminResource): string => {
92
+ const filters = resource.filters.map((field) => field.name);
93
+ const scopes = resource.scopes.map((scope) => scope.name);
94
+ return [
95
+ `List ${resource.name} rows, cursor-paginated.`,
96
+ filters.length === 0
97
+ ? ''
98
+ : ` where: [{ field, op?, value }] over ${filters.join(', ')}; op defaults per field.`,
99
+ scopes.length === 0 ? '' : ` scope: one of ${scopes.join(', ')}.`,
100
+ ].join('');
101
+ };
102
+
64
103
  function toolFor(resource: AdminResource, op: AdminOperation): AdminMcpTool | null {
65
104
  switch (op) {
66
105
  case 'list':
67
106
  return {
68
107
  name: `admin.${resource.name}.list`,
69
108
  kind: 'list',
70
- description: `List ${resource.name} rows, cursor-paginated, newest first.`,
109
+ description: listDescription(resource),
71
110
  entity: resource.name,
72
111
  action: null,
73
- permissions: permissionsForOperation(resource.name, 'list'),
112
+ permissions: permissionsForOperation(resource.permission, 'list'),
74
113
  destructive: false,
75
- input: [CURSOR_FIELD, { name: 'limit', type: 'number', required: false }],
114
+ input: [
115
+ CURSOR_FIELD,
116
+ { name: 'limit', type: 'number', required: false },
117
+ { name: 'scope', type: 'text', required: false },
118
+ { name: 'where', type: 'json', required: false, list: true },
119
+ ],
76
120
  };
77
121
  case 'detail':
78
122
  return {
@@ -81,7 +125,7 @@ function toolFor(resource: AdminResource, op: AdminOperation): AdminMcpTool | nu
81
125
  description: `Read one ${resource.name} row by id.`,
82
126
  entity: resource.name,
83
127
  action: null,
84
- permissions: permissionsForOperation(resource.name, 'detail'),
128
+ permissions: permissionsForOperation(resource.permission, 'detail'),
85
129
  destructive: false,
86
130
  input: [ID_FIELD],
87
131
  };
@@ -92,7 +136,7 @@ function toolFor(resource: AdminResource, op: AdminOperation): AdminMcpTool | nu
92
136
  description: `Create a ${resource.name} row. Validated by the entity's schema.`,
93
137
  entity: resource.name,
94
138
  action: null,
95
- permissions: permissionsForOperation(resource.name, 'create'),
139
+ permissions: permissionsForOperation(resource.permission, 'create'),
96
140
  destructive: false,
97
141
  input: formFields(resource),
98
142
  };
@@ -103,7 +147,7 @@ function toolFor(resource: AdminResource, op: AdminOperation): AdminMcpTool | nu
103
147
  description: `Update fields of one ${resource.name} row.`,
104
148
  entity: resource.name,
105
149
  action: null,
106
- permissions: permissionsForOperation(resource.name, 'update'),
150
+ permissions: permissionsForOperation(resource.permission, 'update'),
107
151
  destructive: false,
108
152
  input: [ID_FIELD, ...formFields(resource)],
109
153
  };
@@ -114,7 +158,7 @@ function toolFor(resource: AdminResource, op: AdminOperation): AdminMcpTool | nu
114
158
  description: `Delete one ${resource.name} row. Requires confirmation "<entity>:<id>".`,
115
159
  entity: resource.name,
116
160
  action: null,
117
- permissions: permissionsForOperation(resource.name, 'delete'),
161
+ permissions: permissionsForOperation(resource.permission, 'delete'),
118
162
  destructive: true,
119
163
  input: [ID_FIELD, { name: 'confirmation', type: 'text', required: true }],
120
164
  };
@@ -154,12 +198,12 @@ function toolGates(app: AdminApp): readonly ToolGate[] {
154
198
  tool: {
155
199
  name: `admin.action.${action.name}`,
156
200
  kind: 'action',
157
- description: action.mcp?.description ?? `Run the ${action.name} action.`,
201
+ description: actionDescription(action),
158
202
  entity: action.entity ?? null,
159
203
  action: action.name,
160
204
  permissions: permissionsForAction(action),
161
205
  destructive: action.destructive === true,
162
- input: actionEnvelope(action.destructive === true),
206
+ input: actionEnvelope(action),
163
207
  },
164
208
  gate: (ctx) => decideAction(action, ctx.actor, ctx.authz),
165
209
  });
@@ -189,12 +233,12 @@ function toolGates(app: AdminApp): readonly ToolGate[] {
189
233
  tool: {
190
234
  name: `admin.action.${action.name}`,
191
235
  kind: 'action',
192
- description: action.mcp?.description ?? `Run the ${action.name} action.`,
236
+ description: actionDescription(action),
193
237
  entity: null,
194
238
  action: action.name,
195
239
  permissions: permissionsForAction(action),
196
240
  destructive: action.destructive === true,
197
- input: actionEnvelope(action.destructive === true),
241
+ input: actionEnvelope(action),
198
242
  },
199
243
  gate: (ctx) => decideAction(action, ctx.actor, ctx.authz),
200
244
  });
package/src/mcp.ts CHANGED
@@ -22,8 +22,11 @@ import {
22
22
  type ToolArgs,
23
23
  } from '@ultimat3/mcp';
24
24
  import { invokeAdminAction } from './action-gate';
25
+ import { invokeRowAction } from './action-row';
25
26
  import type { AdminApp } from './admin';
26
27
  import type { AdminActor } from './authz';
28
+ import { batchPlan, runAdminBatch } from './batch';
29
+ import { batchEnqueue } from './batch-job';
27
30
  import {
28
31
  adminCreate,
29
32
  adminDestroy,
@@ -33,11 +36,13 @@ import {
33
36
  type CrudCtx,
34
37
  type CrudResult,
35
38
  } from './crud';
39
+ import { AdminFilterInvalidError } from './errors';
36
40
  import type { AdminFieldType } from './fields';
41
+ import { type AskedFilter, checkedFilter } from './list-filters';
37
42
  import { type AdminMcpTool, adminMcpTools, adminToolCatalog } from './mcp-tools';
38
43
  import type { AdminAction, AdminRow } from './registry';
39
- import { repoOf } from './resource';
40
44
  import { adminSearch } from './search';
45
+ import type { ValidationIssue } from './validate';
41
46
 
42
47
  export type McpInput = Readonly<Record<string, unknown>>;
43
48
 
@@ -63,6 +68,22 @@ const withoutKeys = (input: McpInput, keys: readonly string[]): Record<string, u
63
68
  return out;
64
69
  };
65
70
 
71
+ /** The `where` argument as a list of asked filters; anything else in it is refused by name. */
72
+ const askedFilters = (where: unknown): readonly AskedFilter[] => {
73
+ if (where === undefined || where === null) return [];
74
+ const list: readonly unknown[] = Array.isArray(where) ? where : [where];
75
+ return list.map((one) => {
76
+ const bag = (typeof one === 'object' && one !== null ? one : {}) as Record<string, unknown>;
77
+ const op = bag['op'];
78
+ return {
79
+ // A predicate with no `field` names the empty one, which no resource derives.
80
+ field: typeof bag['field'] === 'string' ? bag['field'] : '',
81
+ ...(typeof op === 'string' ? { op } : {}),
82
+ value: bag['value'],
83
+ };
84
+ });
85
+ };
86
+
66
87
  const actionByName = (app: AdminApp, name: string): AdminAction | undefined =>
67
88
  [...app.resources.flatMap((resource) => resource.actions), ...app.globalActions].find(
68
89
  (action) => action.name === name,
@@ -110,46 +131,77 @@ async function dispatch(
110
131
  if (action === undefined) {
111
132
  return { ok: false, error: 'X_ADMIN_TOOL_FORBIDDEN', reason: 'action is not registered' };
112
133
  }
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
134
  const owner = app.resources.find((resource) => resource.actions.includes(action));
119
- const row =
120
- owner === undefined || id === '' ? undefined : ((await repoOf(owner).find(id)) ?? null);
121
- const result = await invokeAdminAction({
122
- action,
123
- input: withoutKeys(input, ['confirmation']),
124
- actor: ctx.actor,
125
- authz: ctx.authz,
126
- audit: ctx.audit,
127
- requestId: ctx.requestId,
128
- subject: {
129
- ...(action.entity === undefined ? {} : { entity: action.entity }),
130
- ...(id === '' ? {} : { id }),
131
- ...(row === undefined ? {} : { row }),
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.
135
- confirmation: str(input, 'confirmation'),
136
- });
137
- return result.ok
138
- ? { ok: true, data: result.value }
139
- : { ok: false, error: 'X_ADMIN_DENIED', reason: result.decision.reason };
135
+ const own = withoutKeys(input, ['id', 'ids', 'confirmation']);
136
+ const confirmation = str(input, 'confirmation');
137
+ // `ids`: the same tool as a batch — the bar's own path, row by row through the gate.
138
+ if (owner !== undefined && action.batch !== undefined && Array.isArray(input['ids'])) {
139
+ const ids = (input['ids'] as readonly unknown[]).filter(
140
+ (one): one is string => typeof one === 'string',
141
+ );
142
+ const result = await runAdminBatch({
143
+ resource: owner,
144
+ action,
145
+ ctx,
146
+ selection: { kind: 'ids', ids },
147
+ input: own,
148
+ confirmation,
149
+ ...(batchPlan(action) === null ? {} : { enqueue: batchEnqueue(app.basePath) }),
150
+ });
151
+ if (result.ok) return { ok: true, data: result };
152
+ if (result.kind === 'invalid') return invalidIssues(result.issues);
153
+ return { ok: false, error: 'X_ADMIN_DENIED', reason: result.decision.reason };
154
+ }
155
+ const id = str(input, 'id');
156
+ // The row a row-level rule — and the action's own `when` — decides about, loaded through the
157
+ // resource that OWNS the action: what the UI's button does. A global action names no row.
158
+ const result =
159
+ owner !== undefined && id !== ''
160
+ ? await invokeRowAction({ resource: owner, action, id, ctx, input: own, confirmation })
161
+ : await invokeAdminAction({
162
+ action,
163
+ input: { ...own, ...(id === '' ? {} : { id }) },
164
+ actor: ctx.actor,
165
+ authz: ctx.authz,
166
+ audit: ctx.audit,
167
+ requestId: ctx.requestId,
168
+ subject: {
169
+ ...(action.entity === undefined ? {} : { entity: action.entity }),
170
+ ...(id === '' ? {} : { id }),
171
+ },
172
+ // The agent must echo the token, exactly as the UI makes an operator type it.
173
+ confirmation,
174
+ });
175
+ if (result.ok) return { ok: true, data: result.value };
176
+ if (result.kind === 'not-applicable') {
177
+ return { ok: false, error: result.error.code, reason: result.error.cause };
178
+ }
179
+ if (result.kind === 'invalid') return invalidIssues(result.issues);
180
+ return { ok: false, error: 'X_ADMIN_DENIED', reason: result.decision.reason };
140
181
  }
141
182
 
142
183
  const resource = app.resource(tool.entity ?? '');
143
184
  switch (tool.kind) {
144
185
  case 'list': {
145
186
  const limit = num(input, 'limit');
146
- const result = await adminList(resource, ctx, {
147
- cursor: str(input, 'cursor'),
148
- ...(limit === undefined ? {} : { limit }),
149
- });
150
- return result.ok
151
- ? { ok: true, data: result.page }
152
- : { ok: false, error: 'X_ADMIN_DENIED', reason: result.decision.reason };
187
+ const scope = str(input, 'scope');
188
+ try {
189
+ // `where` through the same validator a URL's `f.<field>` passes, and the scope by name:
190
+ // an agent filters by exactly what an operator can, and the resource's row scope rides
191
+ // the read inside `adminList` whatever the agent sends.
192
+ const result = await adminList(resource, ctx, {
193
+ cursor: str(input, 'cursor'),
194
+ ...(limit === undefined ? {} : { limit }),
195
+ ...(scope === '' ? {} : { scope }),
196
+ filters: askedFilters(input['where']).map((asked) => checkedFilter(resource, asked)),
197
+ });
198
+ return result.ok
199
+ ? { ok: true, data: result.page }
200
+ : { ok: false, error: 'X_ADMIN_DENIED', reason: result.decision.reason };
201
+ } catch (error) {
202
+ if (!(error instanceof AdminFilterInvalidError)) throw error;
203
+ return { ok: false, error: error.code, reason: error.cause };
204
+ }
153
205
  }
154
206
  case 'read':
155
207
  return crudResult(await adminDetail(resource, ctx, str(input, 'id')));
@@ -166,11 +218,23 @@ async function dispatch(
166
218
  }
167
219
  }
168
220
 
221
+ /** The schema's issues, as the one string an agent's result carries. */
222
+ const invalidIssues = (issues: readonly ValidationIssue[]): AdminToolResult => ({
223
+ ok: false,
224
+ error: 'X_ADMIN_INVALID',
225
+ reason: JSON.stringify(issues),
226
+ });
227
+
169
228
  function crudResult(result: CrudResult<AdminRow>): AdminToolResult {
170
229
  if (result.ok) return { ok: true, data: result.row };
171
- return result.kind === 'denied'
172
- ? { ok: false, error: 'X_ADMIN_DENIED', reason: result.decision.reason }
173
- : { ok: false, error: 'X_ADMIN_INVALID', reason: JSON.stringify(result.issues) };
230
+ if (result.kind === 'denied') {
231
+ return { ok: false, error: 'X_ADMIN_DENIED', reason: result.decision.reason };
232
+ }
233
+ // No row by that id — or none this caller's row scope lets them see, which reads the same.
234
+ if (result.kind === 'missing') {
235
+ return { ok: false, error: 'X_ADMIN_INVALID', reason: 'no row has that id' };
236
+ }
237
+ return invalidIssues(result.issues);
174
238
  }
175
239
 
176
240
  export interface AdminMcpOptions {
@@ -195,6 +259,9 @@ const JSON_TYPE: Readonly<Record<AdminFieldType, NonNullable<JsonSchema['type']>
195
259
  json: 'object',
196
260
  relation: 'string',
197
261
  file: 'string',
262
+ // A sealed column travels as the text an agent sends. It is in no tool schema today — the
263
+ // CRUD tools are built from `formFields`, which never holds one.
264
+ secret: 'string',
198
265
  };
199
266
 
200
267
  /**
@@ -210,7 +277,10 @@ const JSON_TYPE: Readonly<Record<AdminFieldType, NonNullable<JsonSchema['type']>
210
277
  const inputSchema = (tool: AdminMcpTool): JsonSchema => ({
211
278
  type: 'object',
212
279
  properties: Object.fromEntries(
213
- tool.input.map((field) => [field.name, { type: JSON_TYPE[field.type] }]),
280
+ tool.input.map((field) => {
281
+ const one = { type: JSON_TYPE[field.type] };
282
+ return [field.name, field.list === true ? { type: 'array', items: one } : one];
283
+ }),
214
284
  ),
215
285
  required: tool.input.filter((field) => field.required).map((field) => field.name),
216
286
  additionalProperties: tool.kind === 'action',
package/src/mounts.ts ADDED
@@ -0,0 +1,44 @@
1
+ // Which admins this process declared, by base path — what a host reads to MOUNT them. Kept on the
2
+ // global symbol registry rather than in a module variable so the host can ask "did this app
3
+ // declare an admin?" WITHOUT importing this package: importing it loads every screen, and an app
4
+ // with no admin must not pay for one (axiom 6).
5
+
6
+ import type { AdminApp } from './admin';
7
+
8
+ /** The global key. `@ultimat3/cli`'s `runtime-admin.ts` restates the string; a test holds them equal. */
9
+ export const ADMIN_MOUNTS: symbol = Symbol.for('ultimate.admin.mounts');
10
+
11
+ type Mounts = Map<string, AdminApp>;
12
+
13
+ const mounts = (): Mounts => {
14
+ const scope = globalThis as { [key: symbol]: unknown };
15
+ const held = scope[ADMIN_MOUNTS];
16
+ if (held instanceof Map) return held as Mounts;
17
+ const created: Mounts = new Map();
18
+ scope[ADMIN_MOUNTS] = created;
19
+ return created;
20
+ };
21
+
22
+ /**
23
+ * Called by `defineAdmin()`. Keyed by base path and last-wins: `x dev` re-evaluates the module
24
+ * that declares the admin when it changes, and the admin a request then meets has to be the one
25
+ * the author just saved.
26
+ */
27
+ export function registerAdminMount(app: AdminApp): void {
28
+ mounts().set(app.basePath, app);
29
+ }
30
+
31
+ /** Every declared admin, in declaration order. */
32
+ export function adminMounts(): readonly AdminApp[] {
33
+ return [...mounts().values()];
34
+ }
35
+
36
+ /** The admin declared at `basePath` NOW, or `undefined`. Read per request, never captured. */
37
+ export function adminMountAt(basePath: string): AdminApp | undefined {
38
+ return mounts().get(basePath);
39
+ }
40
+
41
+ /** Test seam; a running process never forgets an admin it declared. */
42
+ export function clearAdminMounts(): void {
43
+ mounts().clear();
44
+ }
@@ -1,13 +1,14 @@
1
- // The wrapper that makes a custom page's authz unskippable. `routes.ts` never hands the router
2
- // the author's component — it hands this one, which asks the SAME `decideAll` every CRUD call
3
- // and every nav item asks, audits the refusal, and only then calls the author's code.
1
+ // What a refused screen renders and the audit entry it leaves. The wrapper that DECIDES is
2
+ // `guardedScreen()` in `screen-frame.tsx`: `routes.ts` never hands a host the author's component
3
+ // — it hands that screen, which asks the SAME `decideAll` every CRUD call and every nav item
4
+ // asks, audits the refusal through this file, and only then calls the author's code.
4
5
 
5
6
  import { t } from '@ultimat3/i18n';
6
7
  import type { JSX } from 'solid-js';
7
- import type { AdminRoute } from './admin';
8
+ import styles from './admin.module.scss';
8
9
  import { deniedDraft } from './audit';
9
- import { type AdminDecision, decideAll } from './authz';
10
- import type { AdminPageComponent, AdminPageProps } from './pages';
10
+ import type { AdminDecision } from './authz';
11
+ import type { CrudCtx } from './crud';
11
12
 
12
13
  /**
13
14
  * The refusal, rendered as the page. Not a redirect and not a 404: an operator who is missing a
@@ -21,7 +22,7 @@ export function AdminPageDenied(props: {
21
22
  return (
22
23
  <section class="x-admin-denied" role="alert">
23
24
  <h1>{t(props.titleKey)}</h1>
24
- <p>
25
+ <p class={styles['refusal']}>
25
26
  {t('admin.denied.body', {
26
27
  permission: props.decision.permission,
27
28
  reason: props.decision.reason,
@@ -32,27 +33,23 @@ export function AdminPageDenied(props: {
32
33
  }
33
34
 
34
35
  /**
35
- * Wrap once, at route-table build time. The author's component is never reachable from
36
- * `adminRoutes()`, so "the page that forgot its policy line" has nowhere left to exist.
36
+ * The audit entry of a refused SCREEN. The page is the subject, so its path is what the row
37
+ * names — there is no entity and no row id to key a refused screen by. One writer, so a custom
38
+ * page and a generated form's GET leave the same entry.
37
39
  */
38
- export function guardedPage(route: AdminRoute, component: AdminPageComponent): AdminPageComponent {
39
- return async (props: AdminPageProps): Promise<JSX.Element> => {
40
- const decision = decideAll(props.ctx.authz, route.permissions, props.ctx.actor);
41
- if (!decision.allowed) {
42
- // The page IS the subject here, so its path is what the audit row names — there is no
43
- // entity and no row id to key a refused screen by.
44
- await props.ctx.audit.append(
45
- deniedDraft({
46
- requestId: props.ctx.requestId,
47
- actor: props.ctx.actor,
48
- operation: 'page',
49
- kind: 'operation',
50
- entity: route.path,
51
- decision,
52
- }),
53
- );
54
- return <AdminPageDenied titleKey={route.titleKey} decision={decision} />;
55
- }
56
- return component(props);
57
- };
40
+ export async function auditRefusal(
41
+ ctx: CrudCtx,
42
+ path: string,
43
+ decision: AdminDecision,
44
+ ): Promise<void> {
45
+ await ctx.audit.append(
46
+ deniedDraft({
47
+ requestId: ctx.requestId,
48
+ actor: ctx.actor,
49
+ operation: 'page',
50
+ kind: 'operation',
51
+ entity: path,
52
+ decision,
53
+ }),
54
+ );
58
55
  }
@@ -3,8 +3,24 @@
3
3
  // policies and the dashboard — and no second authz implementation can grow next to it.
4
4
 
5
5
  import { type Actor, userActor } from '@ultimat3/core';
6
- import { definePermissions, evaluate, type Policy } from '@ultimat3/policy';
7
- import { type AdminActor, type AdminAuthz, type AdminDecision, allowed, denied } from './authz';
6
+ import {
7
+ can,
8
+ definePermissions,
9
+ evaluate,
10
+ isKnownPermission,
11
+ type KnownPermission,
12
+ type Permission,
13
+ type Policy,
14
+ } from '@ultimat3/policy';
15
+ import {
16
+ type AdminActor,
17
+ type AdminAuthz,
18
+ type AdminDecision,
19
+ type AdminSubject,
20
+ allowed,
21
+ denied,
22
+ impliersOf,
23
+ } from './authz';
8
24
  import { ADMIN_PERMISSIONS } from './permissions';
9
25
 
10
26
  /** The admin's own permission set, registered with the policy layer at import time. */
@@ -27,15 +43,104 @@ const policyActor = (actor: AdminActor): Actor =>
27
43
  function readDecision(permission: string, result: unknown): AdminDecision {
28
44
  const bag = (typeof result === 'object' && result !== null ? result : {}) as {
29
45
  allowed?: unknown;
30
- reason?: unknown;
46
+ decision?: unknown;
31
47
  trace?: unknown;
32
48
  };
49
+ // The reason lives on the evaluation's `decision`, not beside `allowed`. It was read off the top
50
+ // level, where no evaluation has ever carried one, so every refusal an operator read said
51
+ // `admin.policy.evaluated` — the same string whichever rule refused them.
52
+ const inner = (typeof bag.decision === 'object' && bag.decision !== null ? bag.decision : {}) as {
53
+ reason?: unknown;
54
+ };
33
55
  const verdict = result === true || bag.allowed === true;
34
- const reason = typeof bag.reason === 'string' ? bag.reason : 'admin.policy.evaluated';
56
+ const reason = typeof inner.reason === 'string' ? inner.reason : 'admin.policy.evaluated';
35
57
  const trace = Array.isArray(bag.trace) ? bag.trace.map((line) => String(line)) : [];
36
58
  return verdict ? allowed(permission, reason, trace) : denied(permission, reason, trace);
37
59
  }
38
60
 
61
+ /** `resource:verb` — the only shape `can()` takes. Anything else is denied, never thrown. */
62
+ const isPermission = (value: string): value is Permission => /^[^:]+:[^:]+$/.test(value);
63
+
64
+ /**
65
+ * Declare the permissions an admin DERIVES — `<entity>:read|write|delete`, a page's, an action's —
66
+ * so `can()` knows them. Granting them stays the app's: a role map that names none of these
67
+ * refuses every screen. Idempotent, and this file's because it is the one that speaks to
68
+ * `@ultimat3/policy`.
69
+ */
70
+ export function declareAdminPermissions(permissions: readonly string[]): void {
71
+ definePermissions(permissions.filter(isPermission));
72
+ }
73
+
74
+ const evaluated = (
75
+ permission: string,
76
+ policy: Policy,
77
+ actor: AdminActor,
78
+ subject: AdminSubject | undefined,
79
+ ): AdminDecision => {
80
+ // `EvaluateArgs` carries exactly one payload. An admin subject with no action input IS
81
+ // that payload: a row-level rule has nothing but the entity and id to decide on.
82
+ const payload = subject?.input ?? subject;
83
+ // `row` is passed through only when the surface LOADED one. Omitting it and passing
84
+ // `row: undefined` are different facts to `evaluate`, and a rule that reads `row` must see
85
+ // `null` — "no row was loaded" — rather than a value the admin invented from `input`.
86
+ const row = subject === undefined || !('row' in subject) ? undefined : { row: subject.row };
87
+ return readDecision(
88
+ permission,
89
+ evaluate(policy, { actor: policyActor(actor), input: payload, ...row }),
90
+ );
91
+ };
92
+
93
+ /**
94
+ * The default authz: the app's ROLE MAP decides. Every permission the admin asks about is
95
+ * `can(permission)` — "does a role this actor holds grant it" — so an app with a `defineRoles()`
96
+ * call has a working, closed admin with no permission → policy table to keep in step. An app
97
+ * whose rules read the row or the tenant passes `policyAuthz({ policies })` instead.
98
+ */
99
+ export function roleAuthz(): AdminAuthz {
100
+ return {
101
+ decide({ permission, actor, subject }): AdminDecision {
102
+ // Asked BEFORE `can()`, which throws on a name the registry lacks: a decision is an answer,
103
+ // and an undeclared permission is a refusal with its fix, never a 500 out of a nav render.
104
+ if (!isPermission(permission) || !isKnownPermission(permission)) {
105
+ return denied(permission, 'admin.policy.missing', [
106
+ `"${permission}" is not a declared resource:verb permission`,
107
+ `fix: definePermissions(['${permission}']), then grant it to a role in defineRoles()`,
108
+ ]);
109
+ }
110
+ // `can()` asserts the name against the registry `declareAdminPermissions` filled. The cast
111
+ // is the same one a route guard's bare string takes: the registry, not the type, is the check.
112
+ const own = evaluated(permission, can(permission as KnownPermission), actor, subject);
113
+ if (own.allowed) return own;
114
+ // The admin's implications, as `staticAuthz` applies them: a role granting `admin:destroy`
115
+ // holds `admin:write` and `admin:read` too. Without this the same grants answered two ways.
116
+ for (const implier of impliersOf(permission)) {
117
+ const carried = evaluated(implier, can(implier as KnownPermission), actor, subject);
118
+ if (carried.allowed) {
119
+ return allowed(permission, carried.reason, [
120
+ ...carried.trace,
121
+ `${permission} is implied by ${implier}`,
122
+ ]);
123
+ }
124
+ }
125
+ return own;
126
+ },
127
+ };
128
+ }
129
+
130
+ /**
131
+ * ONE policy answers every gate the admin asks — "whoever may run the app may run its admin".
132
+ * The shape a small app starts in: a single grant its role map already has, with no table of
133
+ * per-entity permissions to keep in step as entities are added. The pair is still asked
134
+ * (`admin:write` then `posts:write`), each is still audited under its own name, and the day a
135
+ * view-only operator is needed this becomes `roleAuthz()` or `policyAuthz({ policies })`.
136
+ */
137
+ export function singlePolicyAuthz(policy: Policy): AdminAuthz {
138
+ return {
139
+ decide: ({ permission, actor, subject }): AdminDecision =>
140
+ evaluated(permission, policy, actor, subject),
141
+ };
142
+ }
143
+
39
144
  export interface PolicyAuthzInput {
40
145
  /** Permission name → the policy that decides it. `describeActions()` supplies these. */
41
146
  readonly policies: Readonly<Record<string, Policy>>;
@@ -55,17 +160,7 @@ export function policyAuthz(input: PolicyAuthzInput): AdminAuthz {
55
160
  `fix: definePermissions({ '${permission}': … }) or can('${permission}') on the action`,
56
161
  ]);
57
162
  }
58
- // `EvaluateArgs` carries exactly one payload. An admin subject with no action input IS
59
- // that payload: a row-level rule has nothing but the entity and id to decide on.
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 };
65
- return readDecision(
66
- permission,
67
- evaluate(policy, { actor: policyActor(actor), input: payload, ...row }),
68
- );
163
+ return evaluated(permission, policy, actor, subject);
69
164
  },
70
165
  };
71
166
  }