@ultimat3/admin 22.15.0 → 24.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/CLAUDE.md +83 -91
- package/README.md +282 -89
- package/package.json +19 -17
- package/src/action-form.tsx +139 -0
- package/src/action-gate.ts +141 -68
- package/src/action-input.ts +119 -0
- package/src/action-label.ts +9 -0
- package/src/action-row.ts +37 -0
- package/src/actions.tsx +89 -66
- package/src/actor.ts +53 -0
- package/src/admin.module.scss +298 -0
- package/src/admin.ts +187 -23
- package/src/audit-pg.ts +207 -0
- package/src/audit-schema.ts +47 -0
- package/src/audit.ts +109 -22
- package/src/authz.ts +11 -0
- package/src/batch-bar.tsx +66 -0
- package/src/batch-job.ts +152 -0
- package/src/batch-matching.ts +87 -0
- package/src/batch-queue.ts +90 -0
- package/src/batch.ts +396 -0
- package/src/catalog-keys.ts +55 -0
- package/src/crud-input.ts +104 -0
- package/src/crud-outcome.ts +224 -0
- package/src/crud.ts +172 -176
- package/src/describe.ts +131 -0
- package/src/detail.tsx +115 -43
- package/src/dev/data.ts +27 -12
- package/src/dev/facts.ts +28 -1
- package/src/dev/index.ts +1 -0
- package/src/dev/panel-cache.ts +8 -3
- package/src/dev/panel-db.ts +37 -5
- package/src/dev/panel-jobs.ts +8 -0
- package/src/dev/panel-routes.ts +10 -0
- package/src/dev/panel-timeline.ts +7 -2
- package/src/dev/panel.ts +10 -2
- package/src/dev/server.ts +16 -11
- package/src/dev/sql-runner.ts +31 -0
- package/src/entity-columns.ts +24 -5
- package/src/errors.ts +92 -5
- package/src/fields.ts +63 -4
- package/src/form-decode.ts +132 -0
- package/src/form.tsx +96 -49
- package/src/index.ts +199 -9
- package/src/jobs/fleet-repo.ts +155 -0
- package/src/jobs/job-actions.ts +172 -0
- package/src/jobs/job-entities.ts +118 -0
- package/src/jobs/job-labels.ts +108 -0
- package/src/jobs/job-repo.ts +137 -0
- package/src/jobs/job-resources.ts +211 -0
- package/src/jobs/job-where.ts +170 -0
- package/src/jobs/operator.ts +34 -0
- package/src/jobs/overview-data.ts +129 -0
- package/src/jobs/overview-tab.tsx +61 -0
- package/src/jobs/overview.tsx +189 -0
- package/src/json-text.ts +52 -0
- package/src/keyset-seek.ts +189 -0
- package/src/layout.tsx +55 -43
- package/src/list-columns.tsx +53 -0
- package/src/list-filter-bar.tsx +207 -0
- package/src/list-filters.ts +242 -0
- package/src/list-request.ts +129 -0
- package/src/list-scope-tabs.tsx +60 -0
- package/src/list-scope.ts +113 -0
- package/src/list.tsx +111 -72
- package/src/mcp-tools.ts +57 -13
- package/src/mcp.ts +112 -38
- package/src/mounts.ts +44 -0
- package/src/page-guard.tsx +26 -29
- package/src/pagination.ts +10 -4
- package/src/policy-bridge.ts +115 -18
- package/src/registry.ts +140 -9
- package/src/related.ts +105 -0
- package/src/relations.ts +240 -0
- package/src/renderers.tsx +89 -0
- package/src/repo-entity.ts +195 -0
- package/src/resource-fields.ts +116 -0
- package/src/resource-layout.ts +89 -0
- package/src/resource-list.ts +194 -0
- package/src/resource.ts +129 -79
- package/src/route-config.ts +71 -0
- package/src/routes.ts +55 -54
- package/src/row-scope-write.ts +130 -0
- package/src/row-version.ts +20 -0
- package/src/screen-action.tsx +333 -0
- package/src/screen-frame.tsx +167 -0
- package/src/screen-home.tsx +201 -0
- package/src/screen-lookup.tsx +157 -0
- package/src/screen-resource.tsx +441 -0
- package/src/screen-system.tsx +75 -0
- package/src/screens.ts +56 -0
- package/src/search.ts +22 -10
- package/src/stylesheet-scope.ts +8 -0
- package/src/test-ctx.ts +31 -0
- package/src/validate.ts +3 -0
- package/src/widget-value.ts +36 -8
- package/src/widgets.tsx +84 -16
package/src/crud.ts
CHANGED
|
@@ -2,9 +2,12 @@
|
|
|
2
2
|
// Views and MCP tools both call these, so there is one ordering of those five steps in the
|
|
3
3
|
// admin rather than one per surface.
|
|
4
4
|
|
|
5
|
-
import {
|
|
5
|
+
import type { AuditEntry, AuditLog } from './audit';
|
|
6
6
|
import { type AdminActor, type AdminAuthz, type AdminDecision, decideAll } from './authz';
|
|
7
|
-
import {
|
|
7
|
+
import { onlyFor, rowDiff, splitSecrets, withoutTenant, withTenant } from './crud-input';
|
|
8
|
+
import { auditedWrite, deniedEntry, invalid, missingRow, refuse, staleRow } from './crud-outcome';
|
|
9
|
+
import { type AdminListRequest, findRow, listWhere } from './list-scope';
|
|
10
|
+
import { type AdminPage, fetchPage } from './pagination';
|
|
8
11
|
import {
|
|
9
12
|
type AdminOperation,
|
|
10
13
|
adminPermissionFor,
|
|
@@ -15,6 +18,9 @@ import {
|
|
|
15
18
|
} from './permissions';
|
|
16
19
|
import type { AdminRow } from './registry';
|
|
17
20
|
import { type AdminResource, repoOf } from './resource';
|
|
21
|
+
import type { AdminScope } from './resource-list';
|
|
22
|
+
import { outOfScopeDecision, writeOutsideScope } from './row-scope-write';
|
|
23
|
+
import { rowVersion } from './row-version';
|
|
18
24
|
import { type ValidationIssue, validateInput } from './validate';
|
|
19
25
|
|
|
20
26
|
export interface CrudCtx {
|
|
@@ -38,10 +44,43 @@ export type CrudResult<Row extends AdminRow> =
|
|
|
38
44
|
readonly kind: 'invalid';
|
|
39
45
|
readonly issues: readonly ValidationIssue[];
|
|
40
46
|
readonly audit: AuditEntry;
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* An update or a delete of a row that is not there — gone, never existed, or outside this
|
|
50
|
+
* actor's row scope, which are one answer on purpose. Nothing was written; the attempt is on
|
|
51
|
+
* the log as `failed`, like every other write that did not happen.
|
|
52
|
+
*/
|
|
53
|
+
| { readonly ok: false; readonly kind: 'missing'; readonly audit: AuditEntry }
|
|
54
|
+
/**
|
|
55
|
+
* An update against a `version` the row no longer has: somebody wrote it after the form was
|
|
56
|
+
* rendered. Nothing was written; the attempt is on the log as `failed`
|
|
57
|
+
* (`admin.error.row-changed`), and `row` is the row as it is NOW — what a re-render shows.
|
|
58
|
+
*/
|
|
59
|
+
| {
|
|
60
|
+
readonly ok: false;
|
|
61
|
+
readonly kind: 'stale';
|
|
62
|
+
readonly row: Row;
|
|
63
|
+
readonly version: string;
|
|
64
|
+
readonly audit: AuditEntry;
|
|
41
65
|
};
|
|
42
66
|
|
|
67
|
+
export interface AdminUpdateOptions {
|
|
68
|
+
/**
|
|
69
|
+
* `rowVersion()` of the row the caller was shown — the edit form posts it as `_version`. Given,
|
|
70
|
+
* a row that has changed since is refused (`kind: 'stale'`) instead of overwritten with the
|
|
71
|
+
* stale copy. Omitted (an MCP call, a script), the write is against the row as it is now.
|
|
72
|
+
*/
|
|
73
|
+
readonly version?: string;
|
|
74
|
+
}
|
|
75
|
+
|
|
43
76
|
export type ListResult<Row extends AdminRow> =
|
|
44
|
-
| {
|
|
77
|
+
| {
|
|
78
|
+
readonly ok: true;
|
|
79
|
+
readonly page: AdminPage<Row>;
|
|
80
|
+
/** The scope the page was read under — the request's, or the resource's default. */
|
|
81
|
+
readonly scope: AdminScope | null;
|
|
82
|
+
readonly audit: AuditEntry;
|
|
83
|
+
}
|
|
45
84
|
| {
|
|
46
85
|
readonly ok: false;
|
|
47
86
|
readonly kind: 'denied';
|
|
@@ -51,7 +90,11 @@ export type ListResult<Row extends AdminRow> =
|
|
|
51
90
|
|
|
52
91
|
/** Both gates, always in this order: the admin-level one, then the entity-level one. */
|
|
53
92
|
export function permissionsForOperation(entity: string, op: AdminOperation): readonly string[] {
|
|
54
|
-
|
|
93
|
+
const gate = adminPermissionFor(op);
|
|
94
|
+
const own = entityPermissionFor(entity, op);
|
|
95
|
+
// The dashboard and the search are gated on the admin itself, where the two halves are one
|
|
96
|
+
// permission: `admin:read + admin:read` was what `x routes` printed for both.
|
|
97
|
+
return gate === own ? [gate] : [gate, own];
|
|
55
98
|
}
|
|
56
99
|
|
|
57
100
|
export function decideOperation(
|
|
@@ -68,6 +111,12 @@ export function decideOperation(
|
|
|
68
111
|
* pair was the only gate on a single row.
|
|
69
112
|
*/
|
|
70
113
|
row?: AdminRow | null,
|
|
114
|
+
/**
|
|
115
|
+
* The validated values a create or an update WRITES — the row as the write leaves it. A rule
|
|
116
|
+
* over what is written ("nobody files an invoice as paid") reads it as `input`; absent on every
|
|
117
|
+
* surface that writes nothing.
|
|
118
|
+
*/
|
|
119
|
+
input?: Readonly<Record<string, unknown>>,
|
|
71
120
|
): AdminDecision {
|
|
72
121
|
// First, before any grant is consulted: an operation the resource does not OFFER is refused for
|
|
73
122
|
// everyone. `canOperate` checked this for the nav, the buttons and the MCP tools, and the three
|
|
@@ -81,10 +130,11 @@ export function decideOperation(
|
|
|
81
130
|
trace: [`operations: ${resource.name} does not offer ${op}`],
|
|
82
131
|
};
|
|
83
132
|
}
|
|
84
|
-
return decideAll(ctx.authz, permissionsForOperation(resource.
|
|
133
|
+
return decideAll(ctx.authz, permissionsForOperation(resource.permission, op), ctx.actor, {
|
|
85
134
|
entity: resource.name,
|
|
86
135
|
...(id === undefined ? {} : { id }),
|
|
87
136
|
...(row === undefined ? {} : { row }),
|
|
137
|
+
...(input === undefined ? {} : { input }),
|
|
88
138
|
});
|
|
89
139
|
}
|
|
90
140
|
|
|
@@ -100,35 +150,8 @@ export function canOperate(resource: AdminResource, op: AdminOperation, ctx: Cru
|
|
|
100
150
|
return decideOperation(resource, op, ctx).allowed;
|
|
101
151
|
}
|
|
102
152
|
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
async function refuse<Row extends AdminRow>(
|
|
107
|
-
resource: AdminResource<Row>,
|
|
108
|
-
op: AdminOperation,
|
|
109
|
-
ctx: CrudCtx,
|
|
110
|
-
decision: AdminDecision,
|
|
111
|
-
id: string | null,
|
|
112
|
-
confirmationRequired = false,
|
|
113
|
-
): Promise<CrudResult<Row>> {
|
|
114
|
-
return {
|
|
115
|
-
ok: false,
|
|
116
|
-
kind: 'denied',
|
|
117
|
-
decision,
|
|
118
|
-
confirmationRequired,
|
|
119
|
-
audit: await ctx.audit.append(
|
|
120
|
-
deniedDraft({
|
|
121
|
-
requestId: ctx.requestId,
|
|
122
|
-
actor: ctx.actor,
|
|
123
|
-
operation: op,
|
|
124
|
-
kind: 'operation',
|
|
125
|
-
entity: resource.name,
|
|
126
|
-
entityId: id,
|
|
127
|
-
decision,
|
|
128
|
-
}),
|
|
129
|
-
),
|
|
130
|
-
};
|
|
131
|
-
}
|
|
153
|
+
/** The key a required sealed column is refused with when a create names no value for it. */
|
|
154
|
+
const SECRET_REQUIRED_REASON = 'admin.error.secret-required';
|
|
132
155
|
|
|
133
156
|
/**
|
|
134
157
|
* The one read that logged nothing, in either direction: `audit.ts` says denied and failed attempts
|
|
@@ -139,17 +162,30 @@ async function refuse<Row extends AdminRow>(
|
|
|
139
162
|
export async function adminList<Row extends AdminRow>(
|
|
140
163
|
resource: AdminResource<Row>,
|
|
141
164
|
ctx: CrudCtx,
|
|
142
|
-
req:
|
|
165
|
+
req: AdminListRequest = {},
|
|
143
166
|
): Promise<ListResult<Row>> {
|
|
144
167
|
const decision = decideOperation(resource, 'list', ctx);
|
|
145
168
|
if (!decision.allowed) {
|
|
146
|
-
|
|
147
|
-
|
|
169
|
+
return {
|
|
170
|
+
ok: false,
|
|
171
|
+
kind: 'denied',
|
|
172
|
+
decision,
|
|
173
|
+
audit: await ctx.audit.append(deniedEntry(resource, 'list', ctx, decision, null)),
|
|
174
|
+
};
|
|
148
175
|
}
|
|
149
|
-
|
|
176
|
+
// Composed AFTER the decision: an actor who may not list the table learns nothing about which
|
|
177
|
+
// scopes it declares from the refusal they get.
|
|
178
|
+
const { scope, where } = listWhere(resource, ctx.actor, req);
|
|
179
|
+
const page = await fetchPage(resource, {
|
|
180
|
+
...(req.cursor === undefined ? {} : { cursor: req.cursor }),
|
|
181
|
+
...(req.limit === undefined ? {} : { limit: req.limit }),
|
|
182
|
+
...(req.sort === undefined ? {} : { sort: req.sort }),
|
|
183
|
+
...(where.length === 0 ? {} : { where }),
|
|
184
|
+
});
|
|
150
185
|
return {
|
|
151
186
|
ok: true,
|
|
152
187
|
page,
|
|
188
|
+
scope: scope ?? null,
|
|
153
189
|
audit: await ctx.audit.append({
|
|
154
190
|
requestId: ctx.requestId,
|
|
155
191
|
actor: ctx.actor,
|
|
@@ -171,8 +207,9 @@ export async function adminDetail<Row extends AdminRow>(
|
|
|
171
207
|
): Promise<CrudResult<Row>> {
|
|
172
208
|
// The row is loaded BEFORE the guard, the shape `packages/action/src/invoke.ts` uses for a
|
|
173
209
|
// row-level `policy`: a rule that decides about a row cannot decide without one, and the
|
|
174
|
-
// predicate has to stay synchronous. A denial still returns no row.
|
|
175
|
-
|
|
210
|
+
// predicate has to stay synchronous. A denial still returns no row. Through the row scope: a
|
|
211
|
+
// row this actor's `rows` leaves out is not found, exactly as a row that does not exist.
|
|
212
|
+
const row = await findRow(resource, ctx.actor, id);
|
|
176
213
|
const decision = decideOperation(resource, 'detail', ctx, id, row);
|
|
177
214
|
if (!decision.allowed) return refuse(resource, 'detail', ctx, decision, id);
|
|
178
215
|
return {
|
|
@@ -192,51 +229,6 @@ export async function adminDetail<Row extends AdminRow>(
|
|
|
192
229
|
};
|
|
193
230
|
}
|
|
194
231
|
|
|
195
|
-
/** The reason on a `failed` entry. A key, never the database's message. */
|
|
196
|
-
const WRITE_FAILED_REASON = 'admin.audit.write-failed';
|
|
197
|
-
|
|
198
|
-
/**
|
|
199
|
-
* Run a repo WRITE, and leave a `failed` entry behind if it throws.
|
|
200
|
-
*
|
|
201
|
-
* `AuditOutcome` has declared a `failed` member all along and this file emitted it in exactly one
|
|
202
|
-
* place — `invalid()`, for a VALIDATION issue. A constraint violation, a statement that timed out
|
|
203
|
-
* after committing, a connection dropped mid-write: each left no entry at all, which is the case
|
|
204
|
-
* an auditor opens the log for. Both siblings already do this and each states the rule
|
|
205
|
-
* (`search.ts`, `action-gate.ts`).
|
|
206
|
-
*
|
|
207
|
-
* A mutation cannot append BEFORE the call the way a read does — that would record a write which
|
|
208
|
-
* may never have happened. So: try, record, re-throw UNCHANGED. Nothing about the thrown value is
|
|
209
|
-
* read or rendered; the caller owns it, and an audit reason is a key, not a database message.
|
|
210
|
-
*/
|
|
211
|
-
async function auditedWrite<T>(
|
|
212
|
-
// Only the NAME is read, so this stays invariance-free: `AdminResource<Row>` at four call
|
|
213
|
-
// sites would need the generic threaded through for nothing.
|
|
214
|
-
resource: { readonly name: string },
|
|
215
|
-
op: AdminOperation,
|
|
216
|
-
ctx: CrudCtx,
|
|
217
|
-
entityId: string | null,
|
|
218
|
-
decision: AdminDecision,
|
|
219
|
-
run: () => Promise<T>,
|
|
220
|
-
): Promise<T> {
|
|
221
|
-
try {
|
|
222
|
-
return await run();
|
|
223
|
-
} catch (error) {
|
|
224
|
-
await ctx.audit.append({
|
|
225
|
-
requestId: ctx.requestId,
|
|
226
|
-
actor: ctx.actor,
|
|
227
|
-
operation: op,
|
|
228
|
-
kind: 'operation',
|
|
229
|
-
entity: resource.name,
|
|
230
|
-
entityId,
|
|
231
|
-
permission: decision.permission,
|
|
232
|
-
outcome: 'failed',
|
|
233
|
-
reason: WRITE_FAILED_REASON,
|
|
234
|
-
diff: [],
|
|
235
|
-
});
|
|
236
|
-
throw error;
|
|
237
|
-
}
|
|
238
|
-
}
|
|
239
|
-
|
|
240
232
|
export async function adminCreate<Row extends AdminRow>(
|
|
241
233
|
resource: AdminResource<Row>,
|
|
242
234
|
ctx: CrudCtx,
|
|
@@ -245,28 +237,47 @@ export async function adminCreate<Row extends AdminRow>(
|
|
|
245
237
|
const decision = decideOperation(resource, 'create', ctx);
|
|
246
238
|
if (!decision.allowed) return refuse(resource, 'create', ctx, decision, null);
|
|
247
239
|
|
|
248
|
-
const
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
const row = await auditedWrite(resource, 'create', ctx, null, decision, () =>
|
|
252
|
-
repoOf(resource).create(parsed.value),
|
|
240
|
+
const { open, secrets } = splitSecrets(
|
|
241
|
+
resource,
|
|
242
|
+
withTenant(resource, ctx, onlyFor(resource, 'create', input)),
|
|
253
243
|
);
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
244
|
+
const parsed = await validateInput(resource.entity.$schema, open);
|
|
245
|
+
const unset: readonly ValidationIssue[] = resource.secretFields
|
|
246
|
+
.filter((field) => field.required && !Object.hasOwn(secrets, field.name))
|
|
247
|
+
.map((field) => ({
|
|
248
|
+
path: field.name,
|
|
249
|
+
message: 'a value is required: this column is sealed and has no default',
|
|
250
|
+
messageKey: SECRET_REQUIRED_REASON,
|
|
251
|
+
}));
|
|
252
|
+
if (!parsed.ok || unset.length > 0) {
|
|
253
|
+
const issues = [...(parsed.ok ? [] : parsed.issues), ...unset];
|
|
254
|
+
return invalid(resource, 'create', ctx, null, issues, decision);
|
|
255
|
+
}
|
|
256
|
+
// `rows` narrows a write's RESULT too: a row this actor could not then read is not theirs to
|
|
257
|
+
// create. Judged over what the schema validated, before the repo is asked for anything.
|
|
258
|
+
const outside = writeOutsideScope(resource, ctx.actor, parsed.value);
|
|
259
|
+
if (outside !== null) {
|
|
260
|
+
const permission = entityPermissionFor(resource.permission, 'create');
|
|
261
|
+
return refuse(resource, 'create', ctx, outOfScopeDecision(permission, outside), null);
|
|
262
|
+
}
|
|
263
|
+
// Decided again on what is WRITTEN: the first decision had no values to read, and a rule over
|
|
264
|
+
// them is the one an ownership or a state rule on a create is.
|
|
265
|
+
const written = decideOperation(resource, 'create', ctx, undefined, undefined, parsed.value);
|
|
266
|
+
if (!written.allowed) return refuse(resource, 'create', ctx, written, null);
|
|
267
|
+
|
|
268
|
+
const { value: row, audit } = await auditedWrite(
|
|
269
|
+
resource,
|
|
270
|
+
'create',
|
|
271
|
+
ctx,
|
|
272
|
+
null,
|
|
273
|
+
written,
|
|
274
|
+
() => repoOf(resource).create({ ...parsed.value, ...secrets }),
|
|
275
|
+
(made) => ({
|
|
276
|
+
entityId: String(made[resource.idField] ?? ''),
|
|
277
|
+
diff: rowDiff(resource, null, made, secrets),
|
|
268
278
|
}),
|
|
269
|
-
|
|
279
|
+
);
|
|
280
|
+
return { ok: true, row, audit };
|
|
270
281
|
}
|
|
271
282
|
|
|
272
283
|
export async function adminUpdate<Row extends AdminRow>(
|
|
@@ -274,15 +285,29 @@ export async function adminUpdate<Row extends AdminRow>(
|
|
|
274
285
|
ctx: CrudCtx,
|
|
275
286
|
id: string,
|
|
276
287
|
patch: Readonly<Record<string, unknown>>,
|
|
288
|
+
options: AdminUpdateOptions = {},
|
|
277
289
|
): Promise<CrudResult<Row>> {
|
|
278
290
|
// `before` was already loaded here, just after the guard rather than before it — so the rule
|
|
279
291
|
// that decides whether this actor may touch THIS row never saw the row.
|
|
280
292
|
const repo = repoOf(resource);
|
|
281
|
-
const before = await
|
|
293
|
+
const before = await findRow(resource, ctx.actor, id);
|
|
282
294
|
const decision = decideOperation(resource, 'update', ctx, id, before);
|
|
283
295
|
if (!decision.allowed) return refuse(resource, 'update', ctx, decision, id);
|
|
296
|
+
if (before === null) return missingRow(resource, 'update', ctx, id, decision);
|
|
297
|
+
// Optimistic concurrency: the patch was composed against what the caller was SHOWN, and a merge
|
|
298
|
+
// onto a row that has moved on writes their stale copy of every field they posted.
|
|
299
|
+
const now = rowVersion(before);
|
|
300
|
+
if (options.version !== undefined && options.version !== now) {
|
|
301
|
+
return staleRow(resource, ctx, id, decision, before, now);
|
|
302
|
+
}
|
|
284
303
|
|
|
285
|
-
|
|
304
|
+
// `{ ...before }` carries no sealed value — a row's sealed properties are non-enumerable — and
|
|
305
|
+
// `$schema` has no member for one, so the merged object is exactly what the schema describes.
|
|
306
|
+
const { open, secrets } = splitSecrets(
|
|
307
|
+
resource,
|
|
308
|
+
withoutTenant(resource, onlyFor(resource, 'update', patch)),
|
|
309
|
+
);
|
|
310
|
+
const parsed = await validateInput(resource.entity.$schema, { ...before, ...open });
|
|
286
311
|
if (!parsed.ok) return invalid(resource, 'update', ctx, id, parsed.issues, decision);
|
|
287
312
|
|
|
288
313
|
// Write what the schema validated, not the caller's raw patch — a field the schema would
|
|
@@ -294,31 +319,35 @@ export async function adminUpdate<Row extends AdminRow>(
|
|
|
294
319
|
// — the exact thing the paragraph above says cannot happen. Over MCP the transport refuses
|
|
295
320
|
// those keys (`additionalProperties: false`), but `callAdminTool` and `adminUpdate` are both
|
|
296
321
|
// public API and `mcp.ts` keeps its own gate for a direct call and a future transport.
|
|
297
|
-
const submittedKeys = Object.keys(
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
)
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
entityId: id,
|
|
316
|
-
permission: decision.permission,
|
|
317
|
-
outcome: 'allowed',
|
|
318
|
-
reason: decision.reason,
|
|
319
|
-
diff: diffRows(before, after, { redact: redactedFields(resource) }),
|
|
320
|
-
}),
|
|
322
|
+
const submittedKeys = Object.keys(open);
|
|
323
|
+
// The same rule as a create: an update may not MOVE a row out of the actor's own scope.
|
|
324
|
+
const outside = writeOutsideScope(resource, ctx.actor, parsed.value, submittedKeys);
|
|
325
|
+
if (outside !== null) {
|
|
326
|
+
const permission = entityPermissionFor(resource.permission, 'update');
|
|
327
|
+
return refuse(resource, 'update', ctx, outOfScopeDecision(permission, outside), id);
|
|
328
|
+
}
|
|
329
|
+
// Decided again with the row before AND the row the write leaves — a rule over the values an
|
|
330
|
+
// operator is about to write ("not to paid", "not to another owner") reads `input`.
|
|
331
|
+
const written = decideOperation(resource, 'update', ctx, id, before, parsed.value);
|
|
332
|
+
if (!written.allowed) return refuse(resource, 'update', ctx, written, id);
|
|
333
|
+
const validatedPatch: Readonly<Record<string, unknown>> = {
|
|
334
|
+
...Object.fromEntries(
|
|
335
|
+
submittedKeys
|
|
336
|
+
.filter((key) => Object.hasOwn(parsed.value, key))
|
|
337
|
+
.map((key) => [key, parsed.value[key]]),
|
|
338
|
+
),
|
|
339
|
+
...secrets,
|
|
321
340
|
};
|
|
341
|
+
const { value: after, audit } = await auditedWrite(
|
|
342
|
+
resource,
|
|
343
|
+
'update',
|
|
344
|
+
ctx,
|
|
345
|
+
id,
|
|
346
|
+
written,
|
|
347
|
+
() => repo.update(id, validatedPatch),
|
|
348
|
+
(changed) => ({ entityId: id, diff: rowDiff(resource, before, changed, secrets) }),
|
|
349
|
+
);
|
|
350
|
+
return { ok: true, row: after, audit };
|
|
322
351
|
}
|
|
323
352
|
|
|
324
353
|
/** Destructive: the caller must echo `confirmationToken(entity, id)` or nothing happens. */
|
|
@@ -329,9 +358,10 @@ export async function adminDestroy<Row extends AdminRow>(
|
|
|
329
358
|
confirmation: string | undefined,
|
|
330
359
|
): Promise<CrudResult<Row>> {
|
|
331
360
|
const repo = repoOf(resource);
|
|
332
|
-
const before = await
|
|
361
|
+
const before = await findRow(resource, ctx.actor, id);
|
|
333
362
|
const decision = decideOperation(resource, 'delete', ctx, id, before);
|
|
334
363
|
if (!decision.allowed) return refuse(resource, 'delete', ctx, decision, id);
|
|
364
|
+
if (before === null) return missingRow(resource, 'delete', ctx, id, decision);
|
|
335
365
|
|
|
336
366
|
const expected = confirmationToken(resource.name, id);
|
|
337
367
|
if (isDestructive('delete') && confirmation !== expected) {
|
|
@@ -350,48 +380,14 @@ export async function adminDestroy<Row extends AdminRow>(
|
|
|
350
380
|
);
|
|
351
381
|
}
|
|
352
382
|
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
entityId: id,
|
|
364
|
-
permission: decision.permission,
|
|
365
|
-
outcome: 'allowed',
|
|
366
|
-
reason: decision.reason,
|
|
367
|
-
diff: diffRows(before, null, { redact: redactedFields(resource) }),
|
|
368
|
-
}),
|
|
369
|
-
};
|
|
370
|
-
}
|
|
371
|
-
|
|
372
|
-
async function invalid<Row extends AdminRow>(
|
|
373
|
-
resource: AdminResource<Row>,
|
|
374
|
-
op: AdminOperation,
|
|
375
|
-
ctx: CrudCtx,
|
|
376
|
-
id: string | null,
|
|
377
|
-
issues: readonly ValidationIssue[],
|
|
378
|
-
decision: AdminDecision,
|
|
379
|
-
): Promise<CrudResult<Row>> {
|
|
380
|
-
return {
|
|
381
|
-
ok: false,
|
|
382
|
-
kind: 'invalid',
|
|
383
|
-
issues,
|
|
384
|
-
audit: await ctx.audit.append({
|
|
385
|
-
requestId: ctx.requestId,
|
|
386
|
-
actor: ctx.actor,
|
|
387
|
-
operation: op,
|
|
388
|
-
kind: 'operation',
|
|
389
|
-
entity: resource.name,
|
|
390
|
-
entityId: id,
|
|
391
|
-
permission: decision.permission,
|
|
392
|
-
outcome: 'failed',
|
|
393
|
-
reason: 'admin.error.invalid-input',
|
|
394
|
-
diff: [],
|
|
395
|
-
}),
|
|
396
|
-
};
|
|
383
|
+
const { audit } = await auditedWrite(
|
|
384
|
+
resource,
|
|
385
|
+
'delete',
|
|
386
|
+
ctx,
|
|
387
|
+
id,
|
|
388
|
+
decision,
|
|
389
|
+
() => repo.destroy(id),
|
|
390
|
+
() => ({ entityId: id, diff: rowDiff(resource, before, null) }),
|
|
391
|
+
);
|
|
392
|
+
return { ok: true, row: null, audit };
|
|
397
393
|
}
|
package/src/describe.ts
ADDED
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
// One admin, as plain data: its resources with the filters, sorts and scopes each list answers,
|
|
2
|
+
// whether a row scope narrows it, how its detail and form are arranged, the related lists and the
|
|
3
|
+
// actions it offers — and its routes with the permissions that gate them.
|
|
4
|
+
// JSON-safe and deterministic — what `x manifest` records, read off `AdminApp.describe()` by a
|
|
5
|
+
// tier that may not import this package.
|
|
6
|
+
|
|
7
|
+
import type { AdminRoute } from './admin';
|
|
8
|
+
import { batchPlan } from './batch';
|
|
9
|
+
import type { AdminAction } from './registry';
|
|
10
|
+
import type { AdminResource } from './resource';
|
|
11
|
+
import type { AdminSection } from './resource-layout';
|
|
12
|
+
|
|
13
|
+
export interface AdminScopeDescription {
|
|
14
|
+
readonly name: string;
|
|
15
|
+
readonly default: boolean;
|
|
16
|
+
/** The tab reads a count — one extra query per list page. */
|
|
17
|
+
readonly count: boolean;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** One titled group of a detail page or a form. `null`: the untitled default group. */
|
|
21
|
+
export interface AdminSectionDescription {
|
|
22
|
+
readonly title: string | null;
|
|
23
|
+
readonly fields: readonly string[];
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** One action as an agent, the batch bar and a reviewer need to know it. */
|
|
27
|
+
export interface AdminActionDescription {
|
|
28
|
+
readonly name: string;
|
|
29
|
+
readonly permission: string;
|
|
30
|
+
readonly destructive: boolean;
|
|
31
|
+
/** It declares an input schema: its button is a form. */
|
|
32
|
+
readonly input: boolean;
|
|
33
|
+
/** It declares `when`: it applies to some rows only. */
|
|
34
|
+
readonly when: boolean;
|
|
35
|
+
/** It is in the batch bar and its MCP tool takes `ids`. */
|
|
36
|
+
readonly batch: boolean;
|
|
37
|
+
/** Rows a batch runs inline before it is queued as `admin.batch` jobs. `null`: always inline. */
|
|
38
|
+
readonly threshold: number | null;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface AdminResourceDescription {
|
|
42
|
+
readonly entity: string;
|
|
43
|
+
/** Mount-relative: `/posts`. */
|
|
44
|
+
readonly path: string;
|
|
45
|
+
/** The fields a list URL's `f.<field>` and the MCP list tool's `where` may name. */
|
|
46
|
+
readonly filters: readonly string[];
|
|
47
|
+
/** The fields `?sort=` may name. */
|
|
48
|
+
readonly sorts: readonly string[];
|
|
49
|
+
readonly scopes: readonly AdminScopeDescription[];
|
|
50
|
+
/** `rows` is declared: every read of the resource is narrowed per actor. */
|
|
51
|
+
readonly rowScoped: boolean;
|
|
52
|
+
readonly sections: readonly AdminSectionDescription[];
|
|
53
|
+
readonly formGroups: readonly AdminSectionDescription[];
|
|
54
|
+
/** The `hasMany` relations drawn on the detail page, by name. */
|
|
55
|
+
readonly related: readonly string[];
|
|
56
|
+
/** Declaration order — the order the buttons are drawn in. */
|
|
57
|
+
readonly actions: readonly AdminActionDescription[];
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export interface AdminRouteDescription {
|
|
61
|
+
readonly url: string;
|
|
62
|
+
readonly view: string;
|
|
63
|
+
/** The resource a generated screen belongs to; `null` for the dashboard, a page, a system screen. */
|
|
64
|
+
readonly entity: string | null;
|
|
65
|
+
readonly permissions: readonly string[];
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export interface AdminDescription {
|
|
69
|
+
readonly basePath: string;
|
|
70
|
+
/** Which audit log the admin writes: `memory` forgets at a restart, `postgres` is the record. */
|
|
71
|
+
readonly audit: string;
|
|
72
|
+
readonly resources: readonly AdminResourceDescription[];
|
|
73
|
+
readonly routes: readonly AdminRouteDescription[];
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const byCodeUnit = (a: string, b: string): number => (a < b ? -1 : a > b ? 1 : 0);
|
|
77
|
+
|
|
78
|
+
const sectionOf = (section: AdminSection): AdminSectionDescription => ({
|
|
79
|
+
title: section.titleKey,
|
|
80
|
+
fields: section.fields.map((field) => field.name),
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
const actionOf = (action: AdminAction): AdminActionDescription => ({
|
|
84
|
+
name: action.name,
|
|
85
|
+
permission: action.permission,
|
|
86
|
+
destructive: action.destructive === true,
|
|
87
|
+
input: action.input !== undefined,
|
|
88
|
+
when: action.when !== undefined,
|
|
89
|
+
batch: action.batch !== undefined,
|
|
90
|
+
threshold: batchPlan(action)?.threshold ?? null,
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
/** Sorted by name and by URL, so two boots of one app describe it in the same bytes. */
|
|
94
|
+
export function describeAdmin(
|
|
95
|
+
basePath: string,
|
|
96
|
+
resources: readonly AdminResource[],
|
|
97
|
+
routes: readonly AdminRoute[],
|
|
98
|
+
audit: string,
|
|
99
|
+
): AdminDescription {
|
|
100
|
+
return {
|
|
101
|
+
basePath,
|
|
102
|
+
audit,
|
|
103
|
+
resources: resources
|
|
104
|
+
.map((resource) => ({
|
|
105
|
+
entity: resource.name,
|
|
106
|
+
path: resource.path,
|
|
107
|
+
// Declaration order for both: it is the order the filter bar and the tabs are drawn in.
|
|
108
|
+
filters: resource.filters.map((field) => field.name),
|
|
109
|
+
sorts: resource.fields.filter((field) => field.sortable).map((field) => field.name),
|
|
110
|
+
scopes: resource.scopes.map((scope) => ({
|
|
111
|
+
name: scope.name,
|
|
112
|
+
default: scope.default,
|
|
113
|
+
count: scope.count,
|
|
114
|
+
})),
|
|
115
|
+
rowScoped: resource.rowScope !== undefined,
|
|
116
|
+
sections: resource.sections.map(sectionOf),
|
|
117
|
+
formGroups: resource.formGroups.map(sectionOf),
|
|
118
|
+
related: [...resource.related],
|
|
119
|
+
actions: resource.actions.map(actionOf),
|
|
120
|
+
}))
|
|
121
|
+
.sort((a, b) => byCodeUnit(a.entity, b.entity)),
|
|
122
|
+
routes: routes
|
|
123
|
+
.map((route) => ({
|
|
124
|
+
url: route.path,
|
|
125
|
+
view: route.view,
|
|
126
|
+
entity: route.entity,
|
|
127
|
+
permissions: [...route.permissions],
|
|
128
|
+
}))
|
|
129
|
+
.sort((a, b) => byCodeUnit(a.url, b.url)),
|
|
130
|
+
};
|
|
131
|
+
}
|