@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.
Files changed (97) hide show
  1. package/CLAUDE.md +83 -91
  2. package/README.md +282 -89
  3. package/package.json +19 -17
  4. package/src/action-form.tsx +139 -0
  5. package/src/action-gate.ts +141 -68
  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 +87 -0
  20. package/src/batch-queue.ts +90 -0
  21. package/src/batch.ts +396 -0
  22. package/src/catalog-keys.ts +55 -0
  23. package/src/crud-input.ts +104 -0
  24. package/src/crud-outcome.ts +224 -0
  25. package/src/crud.ts +172 -176
  26. package/src/describe.ts +131 -0
  27. package/src/detail.tsx +115 -43
  28. package/src/dev/data.ts +27 -12
  29. package/src/dev/facts.ts +28 -1
  30. package/src/dev/index.ts +1 -0
  31. package/src/dev/panel-cache.ts +8 -3
  32. package/src/dev/panel-db.ts +37 -5
  33. package/src/dev/panel-jobs.ts +8 -0
  34. package/src/dev/panel-routes.ts +10 -0
  35. package/src/dev/panel-timeline.ts +7 -2
  36. package/src/dev/panel.ts +10 -2
  37. package/src/dev/server.ts +16 -11
  38. package/src/dev/sql-runner.ts +31 -0
  39. package/src/entity-columns.ts +24 -5
  40. package/src/errors.ts +92 -5
  41. package/src/fields.ts +63 -4
  42. package/src/form-decode.ts +132 -0
  43. package/src/form.tsx +96 -49
  44. package/src/index.ts +199 -9
  45. package/src/jobs/fleet-repo.ts +155 -0
  46. package/src/jobs/job-actions.ts +172 -0
  47. package/src/jobs/job-entities.ts +118 -0
  48. package/src/jobs/job-labels.ts +108 -0
  49. package/src/jobs/job-repo.ts +137 -0
  50. package/src/jobs/job-resources.ts +211 -0
  51. package/src/jobs/job-where.ts +170 -0
  52. package/src/jobs/operator.ts +34 -0
  53. package/src/jobs/overview-data.ts +129 -0
  54. package/src/jobs/overview-tab.tsx +61 -0
  55. package/src/jobs/overview.tsx +189 -0
  56. package/src/json-text.ts +52 -0
  57. package/src/keyset-seek.ts +189 -0
  58. package/src/layout.tsx +55 -43
  59. package/src/list-columns.tsx +53 -0
  60. package/src/list-filter-bar.tsx +207 -0
  61. package/src/list-filters.ts +242 -0
  62. package/src/list-request.ts +129 -0
  63. package/src/list-scope-tabs.tsx +60 -0
  64. package/src/list-scope.ts +113 -0
  65. package/src/list.tsx +111 -72
  66. package/src/mcp-tools.ts +57 -13
  67. package/src/mcp.ts +112 -38
  68. package/src/mounts.ts +44 -0
  69. package/src/page-guard.tsx +26 -29
  70. package/src/pagination.ts +10 -4
  71. package/src/policy-bridge.ts +115 -18
  72. package/src/registry.ts +140 -9
  73. package/src/related.ts +105 -0
  74. package/src/relations.ts +240 -0
  75. package/src/renderers.tsx +89 -0
  76. package/src/repo-entity.ts +195 -0
  77. package/src/resource-fields.ts +116 -0
  78. package/src/resource-layout.ts +89 -0
  79. package/src/resource-list.ts +194 -0
  80. package/src/resource.ts +129 -79
  81. package/src/route-config.ts +71 -0
  82. package/src/routes.ts +55 -54
  83. package/src/row-scope-write.ts +130 -0
  84. package/src/row-version.ts +20 -0
  85. package/src/screen-action.tsx +333 -0
  86. package/src/screen-frame.tsx +167 -0
  87. package/src/screen-home.tsx +201 -0
  88. package/src/screen-lookup.tsx +157 -0
  89. package/src/screen-resource.tsx +441 -0
  90. package/src/screen-system.tsx +75 -0
  91. package/src/screens.ts +56 -0
  92. package/src/search.ts +22 -10
  93. package/src/stylesheet-scope.ts +8 -0
  94. package/src/test-ctx.ts +31 -0
  95. package/src/validate.ts +3 -0
  96. package/src/widget-value.ts +36 -8
  97. 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 { type AuditEntry, type AuditLog, deniedDraft, diffRows } from './audit';
5
+ import type { AuditEntry, AuditLog } from './audit';
6
6
  import { type AdminActor, type AdminAuthz, type AdminDecision, decideAll } from './authz';
7
- import { type AdminPage, fetchPage, type PageRequest } from './pagination';
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
- | { readonly ok: true; readonly page: AdminPage<Row>; readonly audit: AuditEntry }
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
- return [adminPermissionFor(op), entityPermissionFor(entity, op)];
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.name, op), ctx.actor, {
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
- const redactedFields = (resource: AdminResource): readonly string[] =>
104
- resource.fields.filter((field) => field.sensitive).map((field) => field.name);
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: PageRequest = {},
165
+ req: AdminListRequest = {},
143
166
  ): Promise<ListResult<Row>> {
144
167
  const decision = decideOperation(resource, 'list', ctx);
145
168
  if (!decision.allowed) {
146
- const refused = await refuse(resource, 'list', ctx, decision, null);
147
- return { ok: false, kind: 'denied', decision, audit: refused.audit };
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
- const page = await fetchPage(resource, req);
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
- const row = await repoOf(resource).find(id);
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 parsed = await validateInput(resource.entity.$schema, input);
249
- if (!parsed.ok) return invalid(resource, 'create', ctx, null, parsed.issues, decision);
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
- return {
255
- ok: true,
256
- row,
257
- audit: await ctx.audit.append({
258
- requestId: ctx.requestId,
259
- actor: ctx.actor,
260
- operation: 'create',
261
- kind: 'operation',
262
- entity: resource.name,
263
- entityId: String(row[resource.idField] ?? ''),
264
- permission: decision.permission,
265
- outcome: 'allowed',
266
- reason: decision.reason,
267
- diff: diffRows(null, row, { redact: redactedFields(resource) }),
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 repo.find(id);
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
- const parsed = await validateInput(resource.entity.$schema, { ...(before ?? {}), ...patch });
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(patch);
298
- const validatedPatch: Readonly<Record<string, unknown>> = Object.fromEntries(
299
- submittedKeys
300
- .filter((key) => Object.hasOwn(parsed.value, key))
301
- .map((key) => [key, parsed.value[key]]),
302
- );
303
- const after = await auditedWrite(resource, 'update', ctx, id, decision, () =>
304
- repo.update(id, validatedPatch),
305
- );
306
- return {
307
- ok: true,
308
- row: after,
309
- audit: await ctx.audit.append({
310
- requestId: ctx.requestId,
311
- actor: ctx.actor,
312
- operation: 'update',
313
- kind: 'operation',
314
- entity: resource.name,
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 repo.find(id);
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
- await auditedWrite(resource, 'delete', ctx, id, decision, () => repo.destroy(id));
354
- return {
355
- ok: true,
356
- row: null,
357
- audit: await ctx.audit.append({
358
- requestId: ctx.requestId,
359
- actor: ctx.actor,
360
- operation: 'delete',
361
- kind: 'operation',
362
- entity: resource.name,
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
  }
@@ -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
+ }