@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/batch.ts ADDED
@@ -0,0 +1,396 @@
1
+ // A batch: one action over many rows of one resource. Each row goes through the SAME gate as the
2
+ // button — policy, the action's `when`, its handler — and is audited on its own, and the answer
3
+ // counts what happened honestly: done, refused, failed, queued, and how many rows remain.
4
+
5
+ import { finiteCount } from '@ultimat3/core';
6
+ import { expectedQueryLoop } from '@ultimat3/db';
7
+ import { decideAction } from './action-gate';
8
+ import { invokeRowAction } from './action-row';
9
+ import type { AdminDecision } from './authz';
10
+ import { runMatching } from './batch-matching';
11
+ import { queueBatch } from './batch-queue';
12
+ import { type CrudCtx, decideOperation } from './crud';
13
+ import { type AdminListRequest, listWhere } from './list-scope';
14
+ import {
15
+ ADMIN_DESTROY,
16
+ ADMIN_WRITE,
17
+ CONFIRMATION_REQUIRED_REASON,
18
+ confirmationToken,
19
+ } from './permissions';
20
+ import type { AdminAction, AdminFilter } from './registry';
21
+ import { rowId } from './registry';
22
+ import { type AdminResource, repoOf } from './resource';
23
+ import { type ValidationIssue, validateInput } from './validate';
24
+
25
+ /**
26
+ * The most rows one request runs INLINE for an action that declares no threshold. The list's own
27
+ * page-size ceiling, so every checked row of one page always fits.
28
+ */
29
+ export const MAX_BATCH_ROWS = 200;
30
+
31
+ /**
32
+ * The most rows one request hands to the QUEUE, for an action that declares a threshold. The
33
+ * number `@ultimat3/jobs` bounds its own bulk operations by (`MAX_BULK_ROWS`), restated because
34
+ * that constant bounds a different statement.
35
+ */
36
+ export const MAX_BATCH_QUEUED_ROWS = 1_000;
37
+
38
+ /** The reason an action that is not a batch action is refused the batch bar with. */
39
+ export const BATCH_NOT_OFFERED_REASON = 'admin.error.batch-not-offered';
40
+
41
+ export type BatchSelection =
42
+ /** The rows an operator checked. */
43
+ | { readonly kind: 'ids'; readonly ids: readonly string[] }
44
+ /**
45
+ * Every row the list's own scope and filters match — "all matching", not "all on this page".
46
+ * `after` is where the previous request stopped: a batch whose rows stay in the filter must
47
+ * still make progress, so the position is a row id and never "the first N again".
48
+ */
49
+ | {
50
+ readonly kind: 'all';
51
+ readonly request: Pick<AdminListRequest, 'scope' | 'filters'>;
52
+ readonly after?: string;
53
+ };
54
+
55
+ export type BatchRowOutcome = 'done' | 'refused' | 'failed';
56
+
57
+ export interface BatchRow {
58
+ readonly id: string;
59
+ readonly outcome: BatchRowOutcome;
60
+ /** An i18n key: the gate's reason for a refusal, the failure's for a throw. */
61
+ readonly reason: string;
62
+ }
63
+
64
+ export interface AdminBatchResult {
65
+ readonly ok: true;
66
+ /** Rows whose handler ran to the end. */
67
+ readonly done: number;
68
+ /** Rows the gate refused: the policy said no, or the action's `when` excludes the row. */
69
+ readonly refused: number;
70
+ /** Rows whose handler threw. */
71
+ readonly failed: number;
72
+ /** Rows handed to the queue instead of run here. Each is counted and audited when its job runs. */
73
+ readonly queued: number;
74
+ /** The jobs those rows were queued as, one per chunk. Empty for an inline batch. */
75
+ readonly jobs: readonly string[];
76
+ /** Rows the selection matched that this request did not reach. Run again from `after`. */
77
+ readonly remaining: number;
78
+ /** The row to continue after, when `remaining` is not 0. */
79
+ readonly after: string | null;
80
+ /** One line per row that was NOT done — what an operator acts on. */
81
+ readonly rows: readonly BatchRow[];
82
+ }
83
+
84
+ export type AdminBatchAnswer =
85
+ | AdminBatchResult
86
+ | { readonly ok: false; readonly kind: 'denied'; readonly decision: AdminDecision }
87
+ /** A destructive batch not yet confirmed: the token to type, and how many rows it covers. */
88
+ | {
89
+ readonly ok: false;
90
+ readonly kind: 'confirm';
91
+ readonly decision: AdminDecision;
92
+ readonly token: string;
93
+ /** `null` for a set-based "all matching" (`matching`): how many is the store's to answer. */
94
+ readonly count: number | null;
95
+ }
96
+ | { readonly ok: false; readonly kind: 'invalid'; readonly issues: readonly ValidationIssue[] };
97
+
98
+ /** What a batch enqueues one chunk with. `batch-job.ts` implements it; a test passes its own. */
99
+ export type BatchEnqueue = (chunk: {
100
+ readonly resource: AdminResource;
101
+ readonly action: AdminAction;
102
+ readonly ids: readonly string[];
103
+ readonly input: Readonly<Record<string, unknown>>;
104
+ readonly ctx: CrudCtx;
105
+ readonly batchId: string;
106
+ readonly index: number;
107
+ }) => Promise<string>;
108
+
109
+ export interface AdminBatchInput {
110
+ readonly resource: AdminResource;
111
+ readonly action: AdminAction;
112
+ readonly ctx: CrudCtx;
113
+ readonly selection: BatchSelection;
114
+ /** The action's own input, already decoded. One input for every row. */
115
+ readonly input?: Readonly<Record<string, unknown>>;
116
+ /** Echo of `batchConfirmationToken(entity, count)` — required for a destructive action. */
117
+ readonly confirmation?: string | undefined;
118
+ /** Required by an action that declares a threshold; `batchEnqueue` is the shipped one. */
119
+ readonly enqueue?: BatchEnqueue;
120
+ }
121
+
122
+ /** The declared threshold and chunk of a batch action, or `null` for one that always runs inline. */
123
+ export function batchPlan(
124
+ action: Pick<AdminAction, 'name' | 'batch'>,
125
+ ): { readonly threshold: number; readonly chunk: number } | null {
126
+ if (action.batch === undefined || action.batch === true) return null;
127
+ const subject = `the admin action "${action.name}"`;
128
+ // At least 1 and finite: `n > NaN` is false for every n, so an unreadable threshold would run
129
+ // every batch inline, whatever its size — the opposite of what declaring one is for.
130
+ const threshold = finiteCount(subject, 'batch.threshold', action.batch.threshold, 1);
131
+ return {
132
+ threshold,
133
+ chunk: finiteCount(subject, 'batch.chunk', action.batch.chunk ?? threshold, 1),
134
+ };
135
+ }
136
+
137
+ /** The token a destructive batch makes the operator type: the entity and how many rows. */
138
+ export const batchConfirmationToken = (entity: string, count: number): string =>
139
+ confirmationToken(entity, `${String(count)} rows`);
140
+
141
+ interface Selected {
142
+ readonly ids: readonly string[];
143
+ readonly remaining: number;
144
+ }
145
+
146
+ /** The rows one request takes, and how many of the selection are left behind it. */
147
+ async function select(
148
+ resource: AdminResource,
149
+ ctx: CrudCtx,
150
+ selection: BatchSelection,
151
+ limit: number,
152
+ ): Promise<Selected> {
153
+ if (selection.kind === 'ids') {
154
+ const ids = [...new Set(selection.ids.filter((id) => id !== ''))];
155
+ return { ids: ids.slice(0, limit), remaining: Math.max(0, ids.length - limit) };
156
+ }
157
+ const repo = repoOf(resource);
158
+ // Row scope, then scope, then filters — the list's own `where`, so "all matching" is exactly
159
+ // the rows the operator was looking at and never one their `rows` leaves out.
160
+ const where = listWhere(resource, ctx.actor, selection.request).where;
161
+ // By id, ascending: a position that survives rows leaving the filter as the batch changes them.
162
+ const sort = { field: resource.idField, direction: 'asc' } as const;
163
+ const after = selection.after;
164
+ const rows = await repo.list({
165
+ ...(where.length === 0 ? {} : { where }),
166
+ sort,
167
+ limit,
168
+ ...(after === undefined ? {} : { after: { field: resource.idField, value: after, id: after } }),
169
+ });
170
+ const ids = rows.map((row) => rowId(row, resource.idField));
171
+ const last = ids[ids.length - 1];
172
+ if (ids.length < limit || last === undefined) return { ids, remaining: 0 };
173
+ const beyond: readonly AdminFilter[] = [
174
+ ...where,
175
+ { field: resource.idField, op: 'gt', value: last },
176
+ ];
177
+ // Exact when the repo can count; otherwise one probe row says "there is more" and no number.
178
+ const remaining =
179
+ repo.count === undefined
180
+ ? (await repo.list({ where: beyond, sort, limit: 1 })).length
181
+ : await repo.count(beyond);
182
+ return { ids, remaining };
183
+ }
184
+
185
+ /**
186
+ * Run `action` over a selection of `resource`'s rows.
187
+ *
188
+ * Decided once for the batch — the action is a batch action, the actor may run it at all, the
189
+ * confirmation matches, the input passes the action's schema — and then once PER ROW by
190
+ * `invokeRowAction`, which is the button's own gate: a row the policy refuses or the action's
191
+ * `when` excludes is counted `refused` and audited as a denial, a handler that throws is counted
192
+ * `failed` and audited as one, and neither stops the rows after it.
193
+ */
194
+ export async function runAdminBatch(input: AdminBatchInput): Promise<AdminBatchAnswer> {
195
+ const { resource, action, ctx } = input;
196
+ const subject = { entity: resource.name };
197
+ const denied = (decision: AdminDecision): AdminBatchAnswer => ({
198
+ ok: false,
199
+ kind: 'denied',
200
+ decision,
201
+ });
202
+ if (action.batch === undefined) {
203
+ return denied({
204
+ allowed: false,
205
+ permission: action.destructive === true ? ADMIN_DESTROY : ADMIN_WRITE,
206
+ reason: BATCH_NOT_OFFERED_REASON,
207
+ trace: [`batch: ${action.name} does not declare batch`],
208
+ });
209
+ }
210
+ const decision = decideAction(action, ctx.actor, ctx.authz, subject);
211
+ if (!decision.allowed) return denied(decision);
212
+ // "All matching" reads the list: an actor who may not list the table may not select from it.
213
+ if (input.selection.kind === 'all') {
214
+ const listing = decideOperation(resource, 'list', ctx);
215
+ if (!listing.allowed) return denied(listing);
216
+ }
217
+
218
+ if (input.selection.kind === 'all' && action.matching !== undefined) {
219
+ return matchingBatch(input, { ...action, matching: action.matching }, decision);
220
+ }
221
+
222
+ const plan = batchPlan(action);
223
+ const selected = await select(
224
+ resource,
225
+ ctx,
226
+ input.selection,
227
+ plan === null ? MAX_BATCH_ROWS : MAX_BATCH_QUEUED_ROWS,
228
+ );
229
+
230
+ // Named `expected`, never `token`: it is a typed-to-confirm echo the page prints, not a secret.
231
+ const expected = batchConfirmationToken(resource.name, selected.ids.length);
232
+ if (action.destructive === true && input.confirmation !== expected) {
233
+ return {
234
+ ok: false,
235
+ kind: 'confirm',
236
+ token: expected,
237
+ count: selected.ids.length,
238
+ decision: {
239
+ allowed: false,
240
+ permission: ADMIN_DESTROY,
241
+ reason: CONFIRMATION_REQUIRED_REASON,
242
+ trace: [`confirmation: expected "${expected}"`],
243
+ },
244
+ };
245
+ }
246
+
247
+ // Once, before any row: an input the schema refuses is one answer, not one failure per row.
248
+ let own = input.input ?? {};
249
+ if (action.input !== undefined) {
250
+ const parsed = await validateInput(action.input, own);
251
+ if (!parsed.ok) return { ok: false, kind: 'invalid', issues: parsed.issues };
252
+ own = parsed.value;
253
+ }
254
+
255
+ const last = selected.ids[selected.ids.length - 1] ?? null;
256
+ const position = {
257
+ remaining: selected.remaining,
258
+ after: selected.remaining === 0 ? null : last,
259
+ };
260
+
261
+ if (plan !== null && selected.ids.length > plan.threshold && input.enqueue !== undefined) {
262
+ const jobs = await queueBatch(
263
+ { ...input, enqueue: input.enqueue },
264
+ decision,
265
+ plan.chunk,
266
+ selected.ids,
267
+ own,
268
+ );
269
+ return {
270
+ ok: true,
271
+ done: 0,
272
+ refused: 0,
273
+ failed: 0,
274
+ queued: selected.ids.length,
275
+ jobs,
276
+ rows: [],
277
+ ...position,
278
+ };
279
+ }
280
+
281
+ const rows = await runBatchRows({ resource, action, ctx, ids: selected.ids, input: own });
282
+ const count = (outcome: BatchRowOutcome): number =>
283
+ rows.filter((row) => row.outcome === outcome).length;
284
+ return {
285
+ ok: true,
286
+ done: count('done'),
287
+ refused: count('refused'),
288
+ failed: count('failed'),
289
+ queued: 0,
290
+ jobs: [],
291
+ rows: rows.filter((row) => row.outcome !== 'done'),
292
+ ...position,
293
+ };
294
+ }
295
+
296
+ /** "All matching" through the action's own set-based verb: `batch-matching.ts`. */
297
+ async function matchingBatch(
298
+ input: AdminBatchInput,
299
+ action: AdminAction & Required<Pick<AdminAction, 'matching'>>,
300
+ decision: AdminDecision,
301
+ ): Promise<AdminBatchAnswer> {
302
+ if (input.selection.kind !== 'all') return { ok: false, kind: 'denied', decision };
303
+ let own = input.input ?? {};
304
+ if (action.input !== undefined) {
305
+ const parsed = await validateInput(action.input, own);
306
+ if (!parsed.ok) return { ok: false, kind: 'invalid', issues: parsed.issues };
307
+ own = parsed.value;
308
+ }
309
+ const answer = await runMatching({
310
+ resource: input.resource,
311
+ action,
312
+ ctx: input.ctx,
313
+ request: input.selection.request,
314
+ input: own,
315
+ confirmation: input.confirmation,
316
+ decision,
317
+ });
318
+ if (!answer.ok) {
319
+ return {
320
+ ok: false,
321
+ kind: 'confirm',
322
+ token: answer.token,
323
+ count: null,
324
+ decision: answer.decision,
325
+ };
326
+ }
327
+ return {
328
+ ok: true,
329
+ done: answer.result.affected,
330
+ refused: 0,
331
+ failed: 0,
332
+ queued: 0,
333
+ jobs: [],
334
+ remaining: answer.result.remaining,
335
+ // A set has no row to continue after: running it again IS the continuation.
336
+ after: null,
337
+ rows: [],
338
+ };
339
+ }
340
+
341
+ /** The reason on a row whose handler threw — the gate's own, restated so a result names it. */
342
+ const ROW_FAILED_REASON = 'admin.error.action-failed';
343
+
344
+ export interface BatchRowsInput {
345
+ readonly resource: AdminResource;
346
+ readonly action: AdminAction;
347
+ readonly ctx: CrudCtx;
348
+ readonly ids: readonly string[];
349
+ readonly input: Readonly<Record<string, unknown>>;
350
+ }
351
+
352
+ /**
353
+ * One row through the gate. The batch already confirmed the whole selection, so each row is
354
+ * handed its own token — the gate still compares it, and still refuses a row it does not match.
355
+ */
356
+ export async function runBatchRow(input: BatchRowsInput, id: string): Promise<BatchRow> {
357
+ const { resource, action, ctx } = input;
358
+ try {
359
+ const result = await invokeRowAction({
360
+ resource,
361
+ action,
362
+ id,
363
+ ctx,
364
+ input: input.input,
365
+ confirmation: confirmationToken(resource.name, id),
366
+ });
367
+ if (result.ok) return { id, outcome: 'done', reason: result.audit.reason };
368
+ return {
369
+ id,
370
+ outcome: result.kind === 'invalid' ? 'failed' : 'refused',
371
+ reason: result.kind === 'invalid' ? result.audit.reason : result.decision.reason,
372
+ };
373
+ } catch {
374
+ // The gate already wrote the `failed` entry and re-threw. Nothing is read off the thrown
375
+ // value: a batch reports a count and a key, and the next row is still owed its turn.
376
+ return { id, outcome: 'failed', reason: ROW_FAILED_REASON };
377
+ }
378
+ }
379
+
380
+ /**
381
+ * Rows in order, one at a time: a handler is the app's, and nothing says it is safe to race.
382
+ *
383
+ * One row read and one handler per row, ON PURPOSE, and declared so: each row is decided on the
384
+ * row as it is now — the policy and the action's `when` both read it — and a batched read before
385
+ * the loop would decide row 40 on a state rows 1–39 may already have changed.
386
+ */
387
+ export function runBatchRows(input: BatchRowsInput): Promise<readonly BatchRow[]> {
388
+ return expectedQueryLoop(
389
+ 'an admin batch decides and runs each row on that row as it is now, one row at a time',
390
+ async () => {
391
+ const out: BatchRow[] = [];
392
+ for (const id of input.ids) out.push(await runBatchRow(input, id));
393
+ return out;
394
+ },
395
+ );
396
+ }
@@ -0,0 +1,55 @@
1
+ // Every catalog key one declared admin derives — branding, nav, screen titles, resource titles and
2
+ // groups, field labels and hints, sections, scopes, computed columns, action labels and their form
3
+ // fields. `AdminApp.catalogKeys()` returns it and the CLI's `i18n` step audits it, so the rule that
4
+ // draws a key and the rule that asks a catalog for it are one function.
5
+
6
+ import { actionInputFields } from './action-input';
7
+ import { actionLabelKey } from './action-label';
8
+ import type { AdminRoute } from './admin';
9
+ import type { NavGroup } from './nav';
10
+ import type { AdminAction } from './registry';
11
+ import type { AdminResource } from './resource';
12
+ import type { AdminBranding } from './theme';
13
+
14
+ export interface CatalogKeysInput {
15
+ readonly branding: AdminBranding;
16
+ readonly nav: readonly NavGroup[];
17
+ readonly routes: readonly AdminRoute[];
18
+ readonly resources: readonly AdminResource[];
19
+ readonly globalActions: readonly AdminAction[];
20
+ }
21
+
22
+ /**
23
+ * Not here: an enum option's label (`…field.<name>.option.<value>`), which falls back to the value
24
+ * itself by design (`optionLabel`), and the admin's fixed chrome (`admin.form.save`), which is
25
+ * spelled in this package's own source rather than derived from a declaration.
26
+ */
27
+ export function adminCatalogKeys(app: CatalogKeysInput): readonly string[] {
28
+ const keys = new Set<string>([app.branding.nameKey]);
29
+ if (app.branding.logo !== undefined) keys.add(app.branding.logo.altKey);
30
+ for (const group of app.nav) {
31
+ keys.add(group.labelKey);
32
+ for (const item of group.items) keys.add(item.labelKey);
33
+ }
34
+ for (const route of app.routes) keys.add(route.titleKey);
35
+ const action = (declared: AdminAction): void => {
36
+ keys.add(actionLabelKey(declared));
37
+ for (const field of actionInputFields(declared)) keys.add(field.labelKey);
38
+ };
39
+ for (const declared of app.globalActions) action(declared);
40
+ for (const resource of app.resources) {
41
+ keys.add(resource.titleKey);
42
+ keys.add(resource.group);
43
+ for (const field of [...resource.fields, ...resource.secretFields]) {
44
+ keys.add(field.labelKey);
45
+ if (field.hintKey !== undefined) keys.add(field.hintKey);
46
+ }
47
+ for (const section of [...resource.sections, ...resource.formGroups]) {
48
+ if (section.titleKey !== null) keys.add(section.titleKey);
49
+ }
50
+ for (const scope of resource.scopes) keys.add(scope.labelKey);
51
+ for (const column of resource.columns) keys.add(column.labelKey);
52
+ for (const declared of resource.actions) action(declared);
53
+ }
54
+ return [...keys].sort();
55
+ }
@@ -0,0 +1,104 @@
1
+ // What a write is handed, made safe to judge: sealed values split around the entity's schema, the
2
+ // tenant column taken out of the caller's hands, and the audit diff of what the write changed.
3
+ // Pure over the resource — `crud.ts` decides and writes, this file only shapes the input.
4
+
5
+ import { type AuditFieldDiff, diffRows, REDACTED } from './audit';
6
+ import type { CrudCtx } from './crud';
7
+ import type { AdminRow } from './registry';
8
+ import type { AdminResource } from './resource';
9
+
10
+ const redactedFields = (resource: AdminResource): readonly string[] =>
11
+ resource.fields.filter((field) => field.sensitive).map((field) => field.name);
12
+
13
+ export interface SplitInput {
14
+ /** What the entity's `$schema` judges — it omits sealed columns, so they must not reach it. */
15
+ readonly open: Readonly<Record<string, unknown>>;
16
+ /** Sealed values the caller actually supplied. Empty is "unchanged", never "set to empty". */
17
+ readonly secrets: Readonly<Record<string, unknown>>;
18
+ }
19
+
20
+ /**
21
+ * A sealed column is WRITE-ONLY here: it rides around `$schema` (which has no member for it) and
22
+ * straight into the repo, whose own parse judges the plaintext before sealing it. An absent or
23
+ * empty value is dropped, so an edit form that left the box empty changes nothing.
24
+ */
25
+ export function splitSecrets(
26
+ resource: AdminResource,
27
+ input: Readonly<Record<string, unknown>>,
28
+ ): SplitInput {
29
+ const names = new Set(resource.secretFields.map((field) => field.name));
30
+ const open: Record<string, unknown> = {};
31
+ const secrets: Record<string, unknown> = {};
32
+ for (const [key, value] of Object.entries(input)) {
33
+ if (!names.has(key)) open[key] = value;
34
+ else if (value !== undefined && value !== null && value !== '') secrets[key] = value;
35
+ }
36
+ return { open, secrets };
37
+ }
38
+
39
+ /**
40
+ * The tenant column is never a caller's to choose. A create is stamped with the ACTING actor's
41
+ * tenant — the handle refuses any other, and refuses a row that names none — and a patch never
42
+ * carries the column at all, so a posted `orgId` cannot move a row between tenants or even ask to.
43
+ */
44
+ export function withoutTenant(
45
+ resource: AdminResource,
46
+ input: Readonly<Record<string, unknown>>,
47
+ ): Readonly<Record<string, unknown>> {
48
+ const tenant = resource.entity.$tenantColumn ?? null;
49
+ if (tenant === null || !Object.hasOwn(input, tenant)) return input;
50
+ return Object.fromEntries(Object.entries(input).filter(([key]) => key !== tenant));
51
+ }
52
+
53
+ export function withTenant(
54
+ resource: AdminResource,
55
+ ctx: CrudCtx,
56
+ input: Readonly<Record<string, unknown>>,
57
+ ): Readonly<Record<string, unknown>> {
58
+ const tenant = resource.entity.$tenantColumn ?? null;
59
+ const open = withoutTenant(resource, input);
60
+ // An actor with no tenant leaves the column unset, and the entity's own schema refuses the row
61
+ // by the column's name — the admin does not invent a tenant for anyone.
62
+ return tenant === null || ctx.actor.orgId === undefined
63
+ ? open
64
+ : { ...open, [tenant]: ctx.actor.orgId };
65
+ }
66
+
67
+ /**
68
+ * A field declared `on: 'create'` is set once and never patched; one declared `on: 'update'` has no
69
+ * value until the row exists. The form does not draw either on the other side, and this is the
70
+ * same rule for every caller that is not the form — an MCP call, a direct `adminUpdate`.
71
+ */
72
+ export function onlyFor(
73
+ resource: AdminResource,
74
+ side: 'create' | 'update',
75
+ input: Readonly<Record<string, unknown>>,
76
+ ): Readonly<Record<string, unknown>> {
77
+ const other = new Set(
78
+ [...resource.fields, ...resource.secretFields]
79
+ .filter((field) => field.on !== undefined && field.on !== side)
80
+ .map((field) => field.name),
81
+ );
82
+ if (other.size === 0) return input;
83
+ return Object.fromEntries(Object.entries(input).filter(([key]) => !other.has(key)));
84
+ }
85
+
86
+ /**
87
+ * The audit diff of one write. A sealed column gets one entry per WRITE — that it changed, never
88
+ * what it held — and never an entry read off a row: a repository row hides the property, but a
89
+ * hand-written `repo:` override may not, and the diff must not be where a plaintext leaks.
90
+ */
91
+ export function rowDiff(
92
+ resource: AdminResource,
93
+ before: AdminRow | null,
94
+ after: AdminRow | null,
95
+ secrets: Readonly<Record<string, unknown>> = {},
96
+ ): readonly AuditFieldDiff[] {
97
+ const sealed = new Set(resource.secretFields.map((field) => field.name));
98
+ return [
99
+ ...diffRows(before, after, { redact: redactedFields(resource) }).filter(
100
+ (change) => !sealed.has(change.field),
101
+ ),
102
+ ...Object.keys(secrets).map((field) => ({ field, before: REDACTED, after: REDACTED })),
103
+ ];
104
+ }