@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
@@ -0,0 +1,139 @@
1
+ // An action's own form: one control per property of its input schema, the confirmation a
2
+ // destructive one needs, and the issues its schema raised against the fields that carried them.
3
+ // A native `<form method="post">` at the row's URL (or the list's, for a batch) — the page ships
4
+ // no script, so the round trip to the server IS the confirmation dialog.
5
+
6
+ import { t } from '@ultimat3/i18n';
7
+ import { Button, Card, Checkbox, Field, Input, Link, Select, Textarea } from '@ultimat3/ui';
8
+ import type { JSX } from 'solid-js';
9
+ import { ACTION_INPUT_PREFIX, type ActionInputField, actionInputFields } from './action-input';
10
+ import { actionLabelKey } from './action-label';
11
+ import { ConfirmationField, OPERATION_FIELD } from './actions';
12
+ import styles from './admin.module.scss';
13
+ import { issueText } from './form';
14
+ import type { AdminAction } from './registry';
15
+ import type { ValidationIssue } from './validate';
16
+
17
+ export interface AdminActionFormProps {
18
+ readonly action: AdminAction;
19
+ /** Where the form posts: the row's URL, or the list's for a batch. */
20
+ readonly href: string;
21
+ /** `ACTION_OPERATION` for one row, `BATCH_OPERATION` for a selection. */
22
+ readonly operation: string;
23
+ /** What was typed, on a refused post. Empty on the first render. */
24
+ readonly values: Readonly<Record<string, string>>;
25
+ readonly issues: readonly ValidationIssue[];
26
+ /** The token a destructive action makes the operator type. `null`: not destructive. */
27
+ readonly confirmation: string | null;
28
+ /** One line above the fields saying what the action will run on — a row, or a count. */
29
+ readonly subject: string;
30
+ /** Posted back untouched: the selection of a batch, its filter position. */
31
+ readonly hidden?: readonly (readonly [name: string, value: string])[];
32
+ readonly cancelHref: string;
33
+ }
34
+
35
+ function control(
36
+ field: ActionInputField,
37
+ value: string | undefined,
38
+ shared: { readonly id: string; readonly 'aria-describedby': string | undefined },
39
+ ): JSX.Element {
40
+ const name = `${ACTION_INPUT_PREFIX}${field.name}`;
41
+ switch (field.control) {
42
+ case 'checkbox':
43
+ return (
44
+ <Checkbox
45
+ {...shared}
46
+ name={name}
47
+ label={t(field.labelKey)}
48
+ checked={value !== undefined && value !== 'false'}
49
+ />
50
+ );
51
+ case 'select':
52
+ return (
53
+ <Select
54
+ {...shared}
55
+ name={name}
56
+ value={value ?? ''}
57
+ options={[
58
+ ...(field.required ? [] : [{ value: '', label: t('admin.value.empty') }]),
59
+ ...field.values.map((one) => ({ value: one, label: one })),
60
+ ]}
61
+ />
62
+ );
63
+ case 'json':
64
+ return <Textarea {...shared} class="x-admin-json" name={name} value={value ?? ''} />;
65
+ case 'datetime':
66
+ // The stored value is a UTC instant, so the control edits UTC and says so beside the box.
67
+ return (
68
+ <Input {...shared} name={name} type="datetime-local" value={value ?? ''} suffix="UTC" />
69
+ );
70
+ case 'number':
71
+ // `inputmode` on a text field, never `type="number"`: the schema decides it is a number.
72
+ return <Input {...shared} name={name} inputmode="decimal" value={value ?? ''} />;
73
+ default:
74
+ return <Input {...shared} name={name} value={value ?? ''} />;
75
+ }
76
+ }
77
+
78
+ export function AdminActionForm(props: AdminActionFormProps): JSX.Element {
79
+ const labelKey = actionLabelKey(props.action);
80
+ const fields = actionInputFields(props.action);
81
+ // An issue the schema raised against no field of the form is still shown — at the top.
82
+ const loose = props.issues.filter((issue) => !fields.some((field) => field.name === issue.path));
83
+
84
+ return (
85
+ <Card header={<h2>{t(labelKey)}</h2>}>
86
+ <form class={styles['form']} method="post" action={props.href}>
87
+ <input type="hidden" name={OPERATION_FIELD} value={props.operation} />
88
+ <input type="hidden" name="name" value={props.action.name} />
89
+ {(props.hidden ?? []).map(([name, value]) => (
90
+ <input type="hidden" name={name} value={value} />
91
+ ))}
92
+ <p class={styles['note']}>{props.subject}</p>
93
+
94
+ {props.issues.length === 0 ? null : (
95
+ <div class="x-admin-issues" role="alert" tabindex={-1}>
96
+ <h3>{t('admin.form.issues')}</h3>
97
+ <ul>
98
+ {loose.map((issue) => (
99
+ <li>
100
+ {issue.path === '' ? '' : `${issue.path}: `}
101
+ {issueText(issue)}
102
+ </li>
103
+ ))}
104
+ </ul>
105
+ </div>
106
+ )}
107
+
108
+ {fields.map((field) => {
109
+ const own = props.issues.filter((issue) => issue.path === field.name);
110
+ return (
111
+ <Field
112
+ label={t(field.labelKey)}
113
+ required={field.required && field.control !== 'checkbox'}
114
+ error={own.length === 0 ? undefined : own.map(issueText).join(' ')}
115
+ >
116
+ {(shared) =>
117
+ control(field, props.values[field.name], {
118
+ id: shared.id,
119
+ 'aria-describedby': shared['aria-describedby'],
120
+ })
121
+ }
122
+ </Field>
123
+ );
124
+ })}
125
+
126
+ {props.confirmation === null ? null : <ConfirmationField token={props.confirmation} />}
127
+
128
+ <div class={styles['actions']}>
129
+ <Button type="submit" tone={props.action.destructive === true ? 'danger' : undefined}>
130
+ {t(labelKey)}
131
+ </Button>
132
+ <Link appearance="button" variant="ghost" tone="neutral" href={props.cancelHref}>
133
+ {t('admin.form.cancel')}
134
+ </Link>
135
+ </div>
136
+ </form>
137
+ </Card>
138
+ );
139
+ }
@@ -3,6 +3,7 @@
3
3
  // authz, so a button that renders is a call that is allowed and a call that is denied had no
4
4
  // button. The admin's whole authz story is this file plus authz.ts.
5
5
 
6
+ import { actionLabelKey } from './action-label';
6
7
  import { type AuditEntry, type AuditFieldDiff, type AuditLog, deniedDraft } from './audit';
7
8
  import {
8
9
  type AdminActor,
@@ -11,13 +12,16 @@ import {
11
12
  type AdminSubject,
12
13
  decideAll,
13
14
  } from './authz';
15
+ import { atomicallyAudited } from './crud-outcome';
16
+ import { AdminActionNotApplicableError } from './errors';
14
17
  import {
15
18
  ADMIN_DESTROY,
16
19
  ADMIN_WRITE,
17
20
  CONFIRMATION_REQUIRED_REASON,
18
21
  confirmationToken,
19
22
  } from './permissions';
20
- import type { AdminAction, AdminActionCtx } from './registry';
23
+ import { type AdminAction, type AdminActionCtx, type AdminRow, computedRow } from './registry';
24
+ import { type ValidationIssue, validateInput } from './validate';
21
25
 
22
26
  export interface AdminActionButton {
23
27
  readonly name: string;
@@ -25,6 +29,8 @@ export interface AdminActionButton {
25
29
  readonly destructive: boolean;
26
30
  readonly permission: string;
27
31
  readonly entity: string | null;
32
+ /** The action declares an input schema: its control is a link to its form, never a bare post. */
33
+ readonly form: boolean;
28
34
  /** Carried so the `/_x` policy panel can show why this button is on screen. */
29
35
  readonly decision: AdminDecision;
30
36
  }
@@ -35,6 +41,12 @@ export interface AdminActionButton {
35
41
  */
36
42
  const ACTION_FAILED_REASON = 'admin.error.action-failed';
37
43
 
44
+ /** The reason an action refused by its own `when()` is answered and audited with. */
45
+ export const ACTION_NOT_APPLICABLE_REASON = 'admin.error.action-not-applicable';
46
+
47
+ /** The reason an action whose input its own schema refused is audited with. */
48
+ const ACTION_INVALID_REASON = 'admin.error.invalid-input';
49
+
38
50
  /** The permissions an action needs: the admin-level gate, then the action's own policy. */
39
51
  export function permissionsForAction<Input, Output>(
40
52
  action: AdminAction<Input, Output>,
@@ -56,6 +68,37 @@ export interface ActionGateInput {
56
68
  readonly actor: AdminActor;
57
69
  readonly authz: AdminAuthz;
58
70
  readonly subject?: AdminSubject;
71
+ /** The resource's sealed column names: dropped from the row a `when` is handed. */
72
+ readonly sealed?: readonly string[];
73
+ }
74
+
75
+ const isRow = (value: unknown): value is AdminRow => typeof value === 'object' && value !== null;
76
+
77
+ /**
78
+ * Whether `action` applies to the subject's row — the ONE evaluation of `when`, asked by the
79
+ * button and by the call behind it.
80
+ *
81
+ * A subject with no row at all (`undefined`: a toolbar, a batch bar) is not a row to refuse. A row
82
+ * that was looked for and not found (`null`) is refused, `when` or none — an action on a row is an
83
+ * action on THAT row, and a row the actor's scope leaves out is not there. No `when`: any row
84
+ * applies. A `when` that throws is a refusal too: the rule did not say yes.
85
+ */
86
+ export function actionApplies<Input, Output>(
87
+ action: AdminAction<Input, Output>,
88
+ subject: AdminSubject | undefined,
89
+ sealed: readonly string[] = [],
90
+ ): boolean {
91
+ if (subject === undefined || subject.row === undefined) return true;
92
+ // Looked for and not there — or outside the actor's `rows` — is refused for EVERY action, not
93
+ // only one that declares `when`: an action with no rule about a row's state still acts on a row,
94
+ // and running its handler on an id the actor cannot see was a way round the row scope.
95
+ if (!isRow(subject.row)) return false;
96
+ if (action.when === undefined) return true;
97
+ try {
98
+ return action.when(computedRow(subject.row, sealed)) === true;
99
+ } catch {
100
+ return false;
101
+ }
59
102
  }
60
103
 
61
104
  /** Every action with its decision — what the `/_x` policy panel and tests want to see. */
@@ -68,16 +111,21 @@ export function actionDecisions(
68
111
  }));
69
112
  }
70
113
 
71
- /** Only the buttons this actor may press. A denied action has no button, ever. */
114
+ /**
115
+ * Only the buttons this actor may press ON THIS ROW. A denied action has no button, ever, and
116
+ * neither has one whose `when` excludes the row.
117
+ */
72
118
  export function actionButtons(input: ActionGateInput): readonly AdminActionButton[] {
73
119
  return actionDecisions(input)
74
120
  .filter(({ decision }) => decision.allowed)
121
+ .filter(({ action }) => actionApplies(action, input.subject, input.sealed))
75
122
  .map(({ action, decision }) => ({
76
123
  name: action.name,
77
- labelKey: action.labelKey ?? `admin.action.${action.name}`,
124
+ labelKey: actionLabelKey(action),
78
125
  destructive: action.destructive === true,
79
126
  permission: action.permission,
80
127
  entity: action.entity ?? null,
128
+ form: action.input !== undefined,
81
129
  decision,
82
130
  }));
83
131
  }
@@ -86,9 +134,25 @@ export type InvokeResult<Output> =
86
134
  | { readonly ok: true; readonly value: Output; readonly audit: AuditEntry }
87
135
  | {
88
136
  readonly ok: false;
137
+ readonly kind: 'denied';
89
138
  readonly decision: AdminDecision;
90
139
  readonly confirmationRequired: boolean;
91
140
  readonly audit: AuditEntry;
141
+ }
142
+ /** The action's `when()` excludes the row. `error` is `X_ADMIN_ACTION_NOT_APPLICABLE`. */
143
+ | {
144
+ readonly ok: false;
145
+ readonly kind: 'not-applicable';
146
+ readonly decision: AdminDecision;
147
+ readonly error: AdminActionNotApplicableError;
148
+ readonly audit: AuditEntry;
149
+ }
150
+ /** The action's own input schema refused what was sent: one issue per field. */
151
+ | {
152
+ readonly ok: false;
153
+ readonly kind: 'invalid';
154
+ readonly issues: readonly ValidationIssue[];
155
+ readonly audit: AuditEntry;
92
156
  };
93
157
 
94
158
  export interface InvokeInput<Input, Output> {
@@ -99,6 +163,8 @@ export interface InvokeInput<Input, Output> {
99
163
  readonly audit: AuditLog;
100
164
  readonly requestId: string;
101
165
  readonly subject?: AdminSubject;
166
+ /** The resource's sealed column names: dropped from the row a `when` is handed. */
167
+ readonly sealed?: readonly string[];
102
168
  /**
103
169
  * Echo of `confirmationToken(entity, subject.id)`. Required for a destructive action, and the
104
170
  * gate DERIVES the token it compares against — it took the caller's `expectedConfirmation`
@@ -116,8 +182,10 @@ export interface InvokeInput<Input, Output> {
116
182
  }
117
183
 
118
184
  /**
119
- * Run an admin action. The policy is consulted first, the confirmation second, the handler
120
- * last, and all three outcomes are audited before this function returns.
185
+ * Run an admin action on one subject. In this order: the policy, the action's own `when()` over
186
+ * the row as it is now, the confirmation, the action's input schema, the handler — and every one
187
+ * of the five outcomes is audited before this function returns. The button, the batch bar and the
188
+ * MCP tool all end here, so there is one ordering of those steps and not one per surface.
121
189
  */
122
190
  export async function invokeAdminAction<Input, Output>(
123
191
  args: InvokeInput<Input, Output>,
@@ -127,22 +195,40 @@ export async function invokeAdminAction<Input, Output>(
127
195
  const entityId = args.subject?.id ?? null;
128
196
  const decision = decideAction(action, actor, authz, args.subject);
129
197
 
198
+ const entry = {
199
+ requestId,
200
+ actor,
201
+ operation: action.name,
202
+ kind: 'action',
203
+ entity,
204
+ entityId,
205
+ } as const;
206
+
130
207
  if (!decision.allowed) {
131
208
  return {
132
209
  ok: false,
210
+ kind: 'denied',
133
211
  decision,
134
212
  confirmationRequired: false,
135
- audit: await audit.append(
136
- deniedDraft({
137
- requestId,
138
- actor,
139
- operation: action.name,
140
- kind: 'action',
141
- entity,
142
- entityId,
143
- decision,
144
- }),
145
- ),
213
+ audit: await audit.append(deniedDraft({ ...entry, decision })),
214
+ };
215
+ }
216
+
217
+ // After the grant, before anything else: the same `when` that decided the button, asked of the
218
+ // row as it is NOW. A hidden button is not an authorization.
219
+ if (!actionApplies(action, args.subject, args.sealed)) {
220
+ const refused: AdminDecision = {
221
+ allowed: false,
222
+ permission: action.permission,
223
+ reason: ACTION_NOT_APPLICABLE_REASON,
224
+ trace: [`when: ${action.name} does not apply to ${entity} ${entityId ?? '(no row)'}`],
225
+ };
226
+ return {
227
+ ok: false,
228
+ kind: 'not-applicable',
229
+ decision: refused,
230
+ error: new AdminActionNotApplicableError({ action: action.name, entity, id: entityId }),
231
+ audit: await audit.append(deniedDraft({ ...entry, decision: refused })),
146
232
  };
147
233
  }
148
234
 
@@ -156,22 +242,36 @@ export async function invokeAdminAction<Input, Output>(
156
242
  };
157
243
  return {
158
244
  ok: false,
245
+ kind: 'denied',
159
246
  decision: refused,
160
247
  confirmationRequired: true,
161
- audit: await audit.append(
162
- deniedDraft({
163
- requestId,
164
- actor,
165
- operation: action.name,
166
- kind: 'action',
167
- entity,
168
- entityId,
169
- decision: refused,
170
- }),
171
- ),
248
+ audit: await audit.append(deniedDraft({ ...entry, decision: refused })),
172
249
  };
173
250
  }
174
251
 
252
+ // The action's own schema judges its own input. The row's `id` is the admin's envelope and is
253
+ // no property of that schema, so it rides around the parse and is put back for the handler.
254
+ let input = args.input;
255
+ if (action.input !== undefined) {
256
+ const { id, ...own } = { ...(args.input as Readonly<Record<string, unknown>>) };
257
+ const parsed = await validateInput(action.input, own);
258
+ if (!parsed.ok) {
259
+ return {
260
+ ok: false,
261
+ kind: 'invalid',
262
+ issues: parsed.issues,
263
+ audit: await audit.append({
264
+ ...entry,
265
+ permission: action.permission,
266
+ outcome: 'failed',
267
+ reason: ACTION_INVALID_REASON,
268
+ diff: [],
269
+ }),
270
+ };
271
+ }
272
+ input = { ...parsed.value, ...(id === undefined ? {} : { id }) } as Input;
273
+ }
274
+
175
275
  const ctx: AdminActionCtx = {
176
276
  requestId,
177
277
  actorId: actor.id,
@@ -179,51 +279,24 @@ export async function invokeAdminAction<Input, Output>(
179
279
  timeZone: args.timeZone ?? actor.timeZone ?? 'UTC',
180
280
  };
181
281
 
182
- try {
183
- const value = await action.handle({ input: args.input, ctx });
184
- return {
185
- ok: true,
186
- value,
187
- audit: await audit.append({
188
- requestId,
189
- actor,
190
- operation: action.name,
191
- kind: 'action',
192
- entity,
193
- entityId,
194
- permission: action.permission,
195
- outcome: 'allowed',
196
- reason: decision.reason,
197
- diff: args.diff ?? [],
198
- }),
199
- };
200
- } catch (error) {
201
- // NOTHING is read off `error` before the append, and nothing is read off it at all.
202
- //
203
- // This built an `AdminDecision` first, whose `trace` rendered the caught value with
204
- // `String(error)` — and a `catch` binding is annotated by nobody, so it holds whatever an
205
- // app's handler threw. `Object.create(null)` has no `toString`, no `valueOf` and no
206
- // `Symbol.toPrimitive`, so `String(it)` raises `TypeError: No default value` from inside the
207
- // block that owes the auditor an entry: measured, ZERO entries, and the caller received the
208
- // TypeError instead of what was thrown. Ordering was the second half — the render ran BEFORE
209
- // `append`, so its throw skipped the append rather than merely spoiling one field.
210
- //
211
- // The decision object was also DEAD: only its `reason` was ever read, and `append` takes no
212
- // trace. So there is no destination for a rendered value here and `renderThrowable` is not
213
- // needed either — an audit reason is a key the view renders, never a sentence from a
214
- // database, an upstream or an attacker.
215
- await audit.append({
282
+ // The handler and its entry are ONE unit (`atomicallyAudited`): a handler whose writes ride the
283
+ // ambient transaction commits with its `allowed` entry or not at all, and a throw from either
284
+ // leaves exactly one `failed` entry, appended after the rollback, with nothing read off the
285
+ // thrown value — which reaches the caller unchanged.
286
+ const { value, audit: appended } = await atomicallyAudited(
287
+ audit,
288
+ {
216
289
  requestId,
217
290
  actor,
218
291
  operation: action.name,
219
292
  kind: 'action',
220
293
  entity,
221
- entityId,
222
294
  permission: action.permission,
223
- outcome: 'failed',
224
- reason: ACTION_FAILED_REASON,
225
- diff: [],
226
- });
227
- throw error;
228
- }
295
+ },
296
+ { allowed: decision.reason, failed: ACTION_FAILED_REASON },
297
+ entityId,
298
+ () => action.handle({ input, ctx }),
299
+ () => ({ entityId, diff: args.diff ?? [] }),
300
+ );
301
+ return { ok: true, value, audit: appended };
229
302
  }
@@ -0,0 +1,119 @@
1
+ // An action's input schema, as the form it renders and the posted strings it reads back. The
2
+ // schema is the action's own (`t.object({ … })`); this file reads its introspectable node — the
3
+ // one IR OpenAPI, MCP and HTTP coercion read — and never writes a second description of it.
4
+
5
+ import { coerceNode, requiredKeys, type SchemaNode, tryIntrospect } from '@ultimat3/schema';
6
+ import { posted } from './form-decode';
7
+ import type { AdminAction } from './registry';
8
+
9
+ /** A posted action input is `input.<property>`: the envelope's own names can never collide. */
10
+ export const ACTION_INPUT_PREFIX = 'input.';
11
+
12
+ /** The control one property of an action's input is drawn as. */
13
+ export type ActionInputControl = 'text' | 'number' | 'checkbox' | 'select' | 'datetime' | 'json';
14
+
15
+ export interface ActionInputField {
16
+ readonly name: string;
17
+ /** `admin.input.<action>.<name>` — beside the action's own label key, never under it. */
18
+ readonly labelKey: string;
19
+ readonly control: ActionInputControl;
20
+ readonly required: boolean;
21
+ /** The closed set of a `select`. */
22
+ readonly values: readonly string[];
23
+ readonly node: SchemaNode;
24
+ }
25
+
26
+ const controlOf = (node: SchemaNode): ActionInputControl => {
27
+ switch (node.kind) {
28
+ case 'string':
29
+ return node.format === 'date-time' ? 'datetime' : 'text';
30
+ case 'number':
31
+ return 'number';
32
+ case 'boolean':
33
+ return 'checkbox';
34
+ case 'enum':
35
+ return 'select';
36
+ case 'date':
37
+ return 'datetime';
38
+ default:
39
+ // An object, a list, a union, money: typed as JSON and judged by the schema.
40
+ return 'json';
41
+ }
42
+ };
43
+
44
+ /**
45
+ * The fields of an action's form, in the schema's own property order. Empty for an action with no
46
+ * input schema and for a schema that is not an introspectable object — such an action's button is
47
+ * a confirm, and its schema still judges whatever a caller sends.
48
+ */
49
+ export function actionInputFields(
50
+ action: Pick<AdminAction, 'name' | 'input'>,
51
+ ): readonly ActionInputField[] {
52
+ const node = tryIntrospect(action.input);
53
+ if (node === undefined || node.kind !== 'object' || node.properties === undefined) return [];
54
+ const required = requiredKeys(node);
55
+ return Object.entries(node.properties).map(([name, property]) => ({
56
+ name,
57
+ // Under `admin.input`, never under the action's own `admin.action.<name>`: a JSON catalog is
58
+ // a tree, so the action's label (a leaf) could not also hold its fields (a branch).
59
+ labelKey: `admin.input.${action.name}.${name}`,
60
+ control: controlOf(property),
61
+ required: required.includes(name),
62
+ values: (property.values ?? []).map(String),
63
+ node: property,
64
+ }));
65
+ }
66
+
67
+ const LOCAL_MINUTE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}$/;
68
+
69
+ const jsonOf = (raw: string): unknown => {
70
+ try {
71
+ return JSON.parse(raw);
72
+ } catch {
73
+ // Left as typed: the schema names the field, which a thrown SyntaxError would not.
74
+ return raw;
75
+ }
76
+ };
77
+
78
+ /**
79
+ * The posted form → the input the action's schema judges. Converts and never validates: a value
80
+ * that will not convert is passed through as typed, so the schema refuses it in its own words
81
+ * against the field that carried it. An empty box of an optional field is absent, never `''`.
82
+ */
83
+ export function decodeActionInput(
84
+ action: Pick<AdminAction, 'name' | 'input'>,
85
+ form: Readonly<Record<string, unknown>>,
86
+ ): Readonly<Record<string, unknown>> {
87
+ const out: Record<string, unknown> = {};
88
+ for (const field of actionInputFields(action)) {
89
+ const raw = posted(form, `${ACTION_INPUT_PREFIX}${field.name}`);
90
+ // An unchecked box posts nothing at all, so absence IS the answer for a checkbox.
91
+ if (field.control === 'checkbox') {
92
+ out[field.name] = raw !== undefined && raw !== 'false';
93
+ continue;
94
+ }
95
+ if (raw === undefined || raw === '') {
96
+ if (field.required && field.control === 'text') out[field.name] = '';
97
+ continue;
98
+ }
99
+ if (field.control === 'json') out[field.name] = jsonOf(raw);
100
+ // `datetime-local` posts `YYYY-MM-DDTHH:mm`, and the control says UTC beside the box.
101
+ else if (field.control === 'datetime' && LOCAL_MINUTE.test(raw)) {
102
+ out[field.name] = coerceNode(field.node, `${raw}:00.000Z`);
103
+ } else out[field.name] = coerceNode(field.node, raw);
104
+ }
105
+ return out;
106
+ }
107
+
108
+ /** What a refused form shows again: the strings that were typed, by field. */
109
+ export function postedActionInput(
110
+ action: Pick<AdminAction, 'name' | 'input'>,
111
+ form: Readonly<Record<string, unknown>>,
112
+ ): Readonly<Record<string, string>> {
113
+ const out: Record<string, string> = {};
114
+ for (const field of actionInputFields(action)) {
115
+ const raw = posted(form, `${ACTION_INPUT_PREFIX}${field.name}`);
116
+ if (raw !== undefined) out[field.name] = raw;
117
+ }
118
+ return out;
119
+ }
@@ -0,0 +1,9 @@
1
+ // An action's label key, spelled once. The button, its form, the batch bar, the batch result and
2
+ // `catalogKeys()` all read it here, so the key a screen draws and the key the `i18n` step asks a
3
+ // catalog for cannot be two spellings of one rule.
4
+
5
+ import type { AdminAction } from './registry';
6
+
7
+ /** `labelKey` when the action names one, else `admin.action.<name>` — its MCP tool's name too. */
8
+ export const actionLabelKey = (action: Pick<AdminAction, 'name' | 'labelKey'>): string =>
9
+ action.labelKey ?? `admin.action.${action.name}`;
@@ -0,0 +1,37 @@
1
+ // One action on ONE row of a resource: the row is loaded through the row scope, and the gate
2
+ // decides on it. The detail page's button, each row of a batch and the MCP tool all call this, so
3
+ // "which row did the rule see" has one answer and a row outside `rows(actor)` is no row at all.
4
+
5
+ import { type InvokeResult, invokeAdminAction } from './action-gate';
6
+ import type { CrudCtx } from './crud';
7
+ import { findRow } from './list-scope';
8
+ import type { AdminAction } from './registry';
9
+ import type { AdminResource } from './resource';
10
+
11
+ export interface RowActionInput {
12
+ readonly resource: AdminResource;
13
+ readonly action: AdminAction;
14
+ readonly id: string;
15
+ readonly ctx: CrudCtx;
16
+ /** The action's own input, already decoded. The row's `id` is added here. */
17
+ readonly input?: Readonly<Record<string, unknown>>;
18
+ readonly confirmation?: string | undefined;
19
+ }
20
+
21
+ export async function invokeRowAction(args: RowActionInput): Promise<InvokeResult<unknown>> {
22
+ const { resource, action, id, ctx } = args;
23
+ // Loaded before the guard, the shape `adminDetail` uses: a rule that decides about a row cannot
24
+ // decide without one — and neither can the action's own `when`.
25
+ const row = await findRow(resource, ctx.actor, id);
26
+ return invokeAdminAction({
27
+ action,
28
+ input: { ...(args.input ?? {}), id },
29
+ actor: ctx.actor,
30
+ authz: ctx.authz,
31
+ audit: ctx.audit,
32
+ requestId: ctx.requestId,
33
+ subject: { entity: resource.name, id, row },
34
+ sealed: resource.secretFields.map((field) => field.name),
35
+ ...(args.confirmation === undefined ? {} : { confirmation: args.confirmation }),
36
+ });
37
+ }