@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
@@ -0,0 +1,166 @@
1
+ // The audit half of a CRUD call that did NOT write: a refusal, an input the schema rejected, a
2
+ // row that was not there, a repo that threw. Each leaves its entry and answers the result the
3
+ // caller renders — so `crud.ts` reads as the five steps of each operation and nothing else.
4
+
5
+ import { type AuditEntry, type AuditFieldDiff, deniedDraft } from './audit';
6
+ import type { AdminDecision } from './authz';
7
+ import type { CrudCtx, CrudResult } from './crud';
8
+ import type { AdminOperation } from './permissions';
9
+ import type { AdminRow } from './registry';
10
+ import type { AdminResource } from './resource';
11
+ import type { ValidationIssue } from './validate';
12
+
13
+ export const deniedEntry = (
14
+ resource: { readonly name: string },
15
+ op: AdminOperation,
16
+ ctx: CrudCtx,
17
+ decision: AdminDecision,
18
+ id: string | null,
19
+ ) =>
20
+ deniedDraft({
21
+ requestId: ctx.requestId,
22
+ actor: ctx.actor,
23
+ operation: op,
24
+ kind: 'operation',
25
+ entity: resource.name,
26
+ entityId: id,
27
+ decision,
28
+ });
29
+
30
+ export async function refuse<Row extends AdminRow>(
31
+ resource: AdminResource<Row>,
32
+ op: AdminOperation,
33
+ ctx: CrudCtx,
34
+ decision: AdminDecision,
35
+ id: string | null,
36
+ confirmationRequired = false,
37
+ ): Promise<CrudResult<Row>> {
38
+ return {
39
+ ok: false,
40
+ kind: 'denied',
41
+ decision,
42
+ confirmationRequired,
43
+ audit: await ctx.audit.append(deniedEntry(resource, op, ctx, decision, id)),
44
+ };
45
+ }
46
+
47
+ /** The reason on a `failed` entry. A key, never the database's message. */
48
+ const WRITE_FAILED_REASON = 'admin.audit.write-failed';
49
+
50
+ /**
51
+ * Run a repo WRITE and append its entry — as one unit where the log can make it one — and leave a
52
+ * `failed` entry behind if either throws.
53
+ *
54
+ * `AuditOutcome` has declared a `failed` member all along and this file emitted it in exactly one
55
+ * place — `invalid()`, for a VALIDATION issue. A constraint violation, a statement that timed out
56
+ * after committing, a connection dropped mid-write: each left no entry at all, which is the case
57
+ * an auditor opens the log for. Both siblings already do this and each states the rule
58
+ * (`search.ts`, `action-gate.ts`).
59
+ *
60
+ * A mutation cannot append BEFORE the call the way a read does — that would record a write which
61
+ * may never have happened. So the write and its entry go inside `audit.atomic`: a durable log
62
+ * opens a transaction, so a row never changes without the entry naming who changed it, and an
63
+ * entry never describes a write that rolled back. The `failed` entry is written OUTSIDE it, after
64
+ * the rollback — inside, it would be rolled back with the write it reports. Nothing about the
65
+ * thrown value is read or rendered; the caller owns it, and an audit reason is a key.
66
+ */
67
+ export async function auditedWrite<T>(
68
+ // Only the NAME is read, so this stays invariance-free: `AdminResource<Row>` at four call
69
+ // sites would need the generic threaded through for nothing.
70
+ resource: { readonly name: string },
71
+ op: AdminOperation,
72
+ ctx: CrudCtx,
73
+ entityId: string | null,
74
+ decision: AdminDecision,
75
+ write: () => Promise<T>,
76
+ /** The diff of what the write did, and the id of the row it left — known only afterwards. */
77
+ written: (value: T) => {
78
+ readonly entityId: string | null;
79
+ readonly diff: readonly AuditFieldDiff[];
80
+ },
81
+ ): Promise<{ readonly value: T; readonly audit: AuditEntry }> {
82
+ const entry = {
83
+ requestId: ctx.requestId,
84
+ actor: ctx.actor,
85
+ operation: op,
86
+ kind: 'operation',
87
+ entity: resource.name,
88
+ permission: decision.permission,
89
+ } as const;
90
+ try {
91
+ return await ctx.audit.atomic(async () => {
92
+ const value = await write();
93
+ const audit = await ctx.audit.append({
94
+ ...entry,
95
+ ...written(value),
96
+ outcome: 'allowed',
97
+ reason: decision.reason,
98
+ });
99
+ return { value, audit };
100
+ });
101
+ } catch (error) {
102
+ await ctx.audit.append({
103
+ ...entry,
104
+ entityId,
105
+ outcome: 'failed',
106
+ reason: WRITE_FAILED_REASON,
107
+ diff: [],
108
+ });
109
+ throw error;
110
+ }
111
+ }
112
+
113
+ /** The key a write to a row that is not there is logged with. */
114
+ const ROW_MISSING_REASON = 'admin.error.row-missing';
115
+
116
+ export async function missingRow<Row extends AdminRow>(
117
+ resource: AdminResource<Row>,
118
+ op: AdminOperation,
119
+ ctx: CrudCtx,
120
+ id: string,
121
+ decision: AdminDecision,
122
+ ): Promise<CrudResult<Row>> {
123
+ return {
124
+ ok: false,
125
+ kind: 'missing',
126
+ audit: await ctx.audit.append({
127
+ requestId: ctx.requestId,
128
+ actor: ctx.actor,
129
+ operation: op,
130
+ kind: 'operation',
131
+ entity: resource.name,
132
+ entityId: id,
133
+ permission: decision.permission,
134
+ outcome: 'failed',
135
+ reason: ROW_MISSING_REASON,
136
+ diff: [],
137
+ }),
138
+ };
139
+ }
140
+
141
+ export async function invalid<Row extends AdminRow>(
142
+ resource: AdminResource<Row>,
143
+ op: AdminOperation,
144
+ ctx: CrudCtx,
145
+ id: string | null,
146
+ issues: readonly ValidationIssue[],
147
+ decision: AdminDecision,
148
+ ): Promise<CrudResult<Row>> {
149
+ return {
150
+ ok: false,
151
+ kind: 'invalid',
152
+ issues,
153
+ audit: await ctx.audit.append({
154
+ requestId: ctx.requestId,
155
+ actor: ctx.actor,
156
+ operation: op,
157
+ kind: 'operation',
158
+ entity: resource.name,
159
+ entityId: id,
160
+ permission: decision.permission,
161
+ outcome: 'failed',
162
+ reason: 'admin.error.invalid-input',
163
+ diff: [],
164
+ }),
165
+ };
166
+ }
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 } 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,8 @@ 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';
18
23
  import { type ValidationIssue, validateInput } from './validate';
19
24
 
20
25
  export interface CrudCtx {
@@ -38,10 +43,22 @@ export type CrudResult<Row extends AdminRow> =
38
43
  readonly kind: 'invalid';
39
44
  readonly issues: readonly ValidationIssue[];
40
45
  readonly audit: AuditEntry;
41
- };
46
+ }
47
+ /**
48
+ * An update or a delete of a row that is not there — gone, never existed, or outside this
49
+ * actor's row scope, which are one answer on purpose. Nothing was written; the attempt is on
50
+ * the log as `failed`, like every other write that did not happen.
51
+ */
52
+ | { readonly ok: false; readonly kind: 'missing'; readonly audit: AuditEntry };
42
53
 
43
54
  export type ListResult<Row extends AdminRow> =
44
- | { readonly ok: true; readonly page: AdminPage<Row>; readonly audit: AuditEntry }
55
+ | {
56
+ readonly ok: true;
57
+ readonly page: AdminPage<Row>;
58
+ /** The scope the page was read under — the request's, or the resource's default. */
59
+ readonly scope: AdminScope | null;
60
+ readonly audit: AuditEntry;
61
+ }
45
62
  | {
46
63
  readonly ok: false;
47
64
  readonly kind: 'denied';
@@ -51,7 +68,11 @@ export type ListResult<Row extends AdminRow> =
51
68
 
52
69
  /** Both gates, always in this order: the admin-level one, then the entity-level one. */
53
70
  export function permissionsForOperation(entity: string, op: AdminOperation): readonly string[] {
54
- return [adminPermissionFor(op), entityPermissionFor(entity, op)];
71
+ const gate = adminPermissionFor(op);
72
+ const own = entityPermissionFor(entity, op);
73
+ // The dashboard and the search are gated on the admin itself, where the two halves are one
74
+ // permission: `admin:read + admin:read` was what `x routes` printed for both.
75
+ return gate === own ? [gate] : [gate, own];
55
76
  }
56
77
 
57
78
  export function decideOperation(
@@ -81,7 +102,7 @@ export function decideOperation(
81
102
  trace: [`operations: ${resource.name} does not offer ${op}`],
82
103
  };
83
104
  }
84
- return decideAll(ctx.authz, permissionsForOperation(resource.name, op), ctx.actor, {
105
+ return decideAll(ctx.authz, permissionsForOperation(resource.permission, op), ctx.actor, {
85
106
  entity: resource.name,
86
107
  ...(id === undefined ? {} : { id }),
87
108
  ...(row === undefined ? {} : { row }),
@@ -100,35 +121,8 @@ export function canOperate(resource: AdminResource, op: AdminOperation, ctx: Cru
100
121
  return decideOperation(resource, op, ctx).allowed;
101
122
  }
102
123
 
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
- }
124
+ /** The key a required sealed column is refused with when a create names no value for it. */
125
+ const SECRET_REQUIRED_REASON = 'admin.error.secret-required';
132
126
 
133
127
  /**
134
128
  * The one read that logged nothing, in either direction: `audit.ts` says denied and failed attempts
@@ -139,17 +133,30 @@ async function refuse<Row extends AdminRow>(
139
133
  export async function adminList<Row extends AdminRow>(
140
134
  resource: AdminResource<Row>,
141
135
  ctx: CrudCtx,
142
- req: PageRequest = {},
136
+ req: AdminListRequest = {},
143
137
  ): Promise<ListResult<Row>> {
144
138
  const decision = decideOperation(resource, 'list', ctx);
145
139
  if (!decision.allowed) {
146
- const refused = await refuse(resource, 'list', ctx, decision, null);
147
- return { ok: false, kind: 'denied', decision, audit: refused.audit };
140
+ return {
141
+ ok: false,
142
+ kind: 'denied',
143
+ decision,
144
+ audit: await ctx.audit.append(deniedEntry(resource, 'list', ctx, decision, null)),
145
+ };
148
146
  }
149
- const page = await fetchPage(resource, req);
147
+ // Composed AFTER the decision: an actor who may not list the table learns nothing about which
148
+ // scopes it declares from the refusal they get.
149
+ const { scope, where } = listWhere(resource, ctx.actor, req);
150
+ const page = await fetchPage(resource, {
151
+ ...(req.cursor === undefined ? {} : { cursor: req.cursor }),
152
+ ...(req.limit === undefined ? {} : { limit: req.limit }),
153
+ ...(req.sort === undefined ? {} : { sort: req.sort }),
154
+ ...(where.length === 0 ? {} : { where }),
155
+ });
150
156
  return {
151
157
  ok: true,
152
158
  page,
159
+ scope: scope ?? null,
153
160
  audit: await ctx.audit.append({
154
161
  requestId: ctx.requestId,
155
162
  actor: ctx.actor,
@@ -171,8 +178,9 @@ export async function adminDetail<Row extends AdminRow>(
171
178
  ): Promise<CrudResult<Row>> {
172
179
  // The row is loaded BEFORE the guard, the shape `packages/action/src/invoke.ts` uses for a
173
180
  // 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);
181
+ // predicate has to stay synchronous. A denial still returns no row. Through the row scope: a
182
+ // row this actor's `rows` leaves out is not found, exactly as a row that does not exist.
183
+ const row = await findRow(resource, ctx.actor, id);
176
184
  const decision = decideOperation(resource, 'detail', ctx, id, row);
177
185
  if (!decision.allowed) return refuse(resource, 'detail', ctx, decision, id);
178
186
  return {
@@ -192,51 +200,6 @@ export async function adminDetail<Row extends AdminRow>(
192
200
  };
193
201
  }
194
202
 
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
203
  export async function adminCreate<Row extends AdminRow>(
241
204
  resource: AdminResource<Row>,
242
205
  ctx: CrudCtx,
@@ -245,28 +208,43 @@ export async function adminCreate<Row extends AdminRow>(
245
208
  const decision = decideOperation(resource, 'create', ctx);
246
209
  if (!decision.allowed) return refuse(resource, 'create', ctx, decision, null);
247
210
 
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),
211
+ const { open, secrets } = splitSecrets(
212
+ resource,
213
+ withTenant(resource, ctx, onlyFor(resource, 'create', input)),
253
214
  );
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) }),
215
+ const parsed = await validateInput(resource.entity.$schema, open);
216
+ const unset: readonly ValidationIssue[] = resource.secretFields
217
+ .filter((field) => field.required && !Object.hasOwn(secrets, field.name))
218
+ .map((field) => ({
219
+ path: field.name,
220
+ message: 'a value is required: this column is sealed and has no default',
221
+ messageKey: SECRET_REQUIRED_REASON,
222
+ }));
223
+ if (!parsed.ok || unset.length > 0) {
224
+ const issues = [...(parsed.ok ? [] : parsed.issues), ...unset];
225
+ return invalid(resource, 'create', ctx, null, issues, decision);
226
+ }
227
+ // `rows` narrows a write's RESULT too: a row this actor could not then read is not theirs to
228
+ // create. Judged over what the schema validated, before the repo is asked for anything.
229
+ const outside = writeOutsideScope(resource, ctx.actor, parsed.value);
230
+ if (outside !== null) {
231
+ const permission = entityPermissionFor(resource.permission, 'create');
232
+ return refuse(resource, 'create', ctx, outOfScopeDecision(permission, outside), null);
233
+ }
234
+
235
+ const { value: row, audit } = await auditedWrite(
236
+ resource,
237
+ 'create',
238
+ ctx,
239
+ null,
240
+ decision,
241
+ () => repoOf(resource).create({ ...parsed.value, ...secrets }),
242
+ (made) => ({
243
+ entityId: String(made[resource.idField] ?? ''),
244
+ diff: rowDiff(resource, null, made, secrets),
268
245
  }),
269
- };
246
+ );
247
+ return { ok: true, row, audit };
270
248
  }
271
249
 
272
250
  export async function adminUpdate<Row extends AdminRow>(
@@ -278,11 +256,18 @@ export async function adminUpdate<Row extends AdminRow>(
278
256
  // `before` was already loaded here, just after the guard rather than before it — so the rule
279
257
  // that decides whether this actor may touch THIS row never saw the row.
280
258
  const repo = repoOf(resource);
281
- const before = await repo.find(id);
259
+ const before = await findRow(resource, ctx.actor, id);
282
260
  const decision = decideOperation(resource, 'update', ctx, id, before);
283
261
  if (!decision.allowed) return refuse(resource, 'update', ctx, decision, id);
262
+ if (before === null) return missingRow(resource, 'update', ctx, id, decision);
284
263
 
285
- const parsed = await validateInput(resource.entity.$schema, { ...(before ?? {}), ...patch });
264
+ // `{ ...before }` carries no sealed value — a row's sealed properties are non-enumerable — and
265
+ // `$schema` has no member for one, so the merged object is exactly what the schema describes.
266
+ const { open, secrets } = splitSecrets(
267
+ resource,
268
+ withoutTenant(resource, onlyFor(resource, 'update', patch)),
269
+ );
270
+ const parsed = await validateInput(resource.entity.$schema, { ...before, ...open });
286
271
  if (!parsed.ok) return invalid(resource, 'update', ctx, id, parsed.issues, decision);
287
272
 
288
273
  // Write what the schema validated, not the caller's raw patch — a field the schema would
@@ -294,31 +279,31 @@ export async function adminUpdate<Row extends AdminRow>(
294
279
  // — the exact thing the paragraph above says cannot happen. Over MCP the transport refuses
295
280
  // those keys (`additionalProperties: false`), but `callAdminTool` and `adminUpdate` are both
296
281
  // 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
- }),
282
+ const submittedKeys = Object.keys(open);
283
+ // The same rule as a create: an update may not MOVE a row out of the actor's own scope.
284
+ const outside = writeOutsideScope(resource, ctx.actor, parsed.value, submittedKeys);
285
+ if (outside !== null) {
286
+ const permission = entityPermissionFor(resource.permission, 'update');
287
+ return refuse(resource, 'update', ctx, outOfScopeDecision(permission, outside), id);
288
+ }
289
+ const validatedPatch: Readonly<Record<string, unknown>> = {
290
+ ...Object.fromEntries(
291
+ submittedKeys
292
+ .filter((key) => Object.hasOwn(parsed.value, key))
293
+ .map((key) => [key, parsed.value[key]]),
294
+ ),
295
+ ...secrets,
321
296
  };
297
+ const { value: after, audit } = await auditedWrite(
298
+ resource,
299
+ 'update',
300
+ ctx,
301
+ id,
302
+ decision,
303
+ () => repo.update(id, validatedPatch),
304
+ (changed) => ({ entityId: id, diff: rowDiff(resource, before, changed, secrets) }),
305
+ );
306
+ return { ok: true, row: after, audit };
322
307
  }
323
308
 
324
309
  /** Destructive: the caller must echo `confirmationToken(entity, id)` or nothing happens. */
@@ -329,9 +314,10 @@ export async function adminDestroy<Row extends AdminRow>(
329
314
  confirmation: string | undefined,
330
315
  ): Promise<CrudResult<Row>> {
331
316
  const repo = repoOf(resource);
332
- const before = await repo.find(id);
317
+ const before = await findRow(resource, ctx.actor, id);
333
318
  const decision = decideOperation(resource, 'delete', ctx, id, before);
334
319
  if (!decision.allowed) return refuse(resource, 'delete', ctx, decision, id);
320
+ if (before === null) return missingRow(resource, 'delete', ctx, id, decision);
335
321
 
336
322
  const expected = confirmationToken(resource.name, id);
337
323
  if (isDestructive('delete') && confirmation !== expected) {
@@ -350,48 +336,14 @@ export async function adminDestroy<Row extends AdminRow>(
350
336
  );
351
337
  }
352
338
 
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
- };
339
+ const { audit } = await auditedWrite(
340
+ resource,
341
+ 'delete',
342
+ ctx,
343
+ id,
344
+ decision,
345
+ () => repo.destroy(id),
346
+ () => ({ entityId: id, diff: rowDiff(resource, before, null) }),
347
+ );
348
+ return { ok: true, row: null, audit };
397
349
  }