@ultimat3/admin 22.15.0 → 24.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CLAUDE.md +83 -91
- package/README.md +282 -89
- package/package.json +19 -17
- package/src/action-form.tsx +139 -0
- package/src/action-gate.ts +141 -68
- package/src/action-input.ts +119 -0
- package/src/action-label.ts +9 -0
- package/src/action-row.ts +37 -0
- package/src/actions.tsx +89 -66
- package/src/actor.ts +53 -0
- package/src/admin.module.scss +298 -0
- package/src/admin.ts +187 -23
- package/src/audit-pg.ts +207 -0
- package/src/audit-schema.ts +47 -0
- package/src/audit.ts +109 -22
- package/src/authz.ts +11 -0
- package/src/batch-bar.tsx +66 -0
- package/src/batch-job.ts +152 -0
- package/src/batch-matching.ts +87 -0
- package/src/batch-queue.ts +90 -0
- package/src/batch.ts +396 -0
- package/src/catalog-keys.ts +55 -0
- package/src/crud-input.ts +104 -0
- package/src/crud-outcome.ts +224 -0
- package/src/crud.ts +172 -176
- package/src/describe.ts +131 -0
- package/src/detail.tsx +115 -43
- package/src/dev/data.ts +27 -12
- package/src/dev/facts.ts +28 -1
- package/src/dev/index.ts +1 -0
- package/src/dev/panel-cache.ts +8 -3
- package/src/dev/panel-db.ts +37 -5
- package/src/dev/panel-jobs.ts +8 -0
- package/src/dev/panel-routes.ts +10 -0
- package/src/dev/panel-timeline.ts +7 -2
- package/src/dev/panel.ts +10 -2
- package/src/dev/server.ts +16 -11
- package/src/dev/sql-runner.ts +31 -0
- package/src/entity-columns.ts +24 -5
- package/src/errors.ts +92 -5
- package/src/fields.ts +63 -4
- package/src/form-decode.ts +132 -0
- package/src/form.tsx +96 -49
- package/src/index.ts +199 -9
- package/src/jobs/fleet-repo.ts +155 -0
- package/src/jobs/job-actions.ts +172 -0
- package/src/jobs/job-entities.ts +118 -0
- package/src/jobs/job-labels.ts +108 -0
- package/src/jobs/job-repo.ts +137 -0
- package/src/jobs/job-resources.ts +211 -0
- package/src/jobs/job-where.ts +170 -0
- package/src/jobs/operator.ts +34 -0
- package/src/jobs/overview-data.ts +129 -0
- package/src/jobs/overview-tab.tsx +61 -0
- package/src/jobs/overview.tsx +189 -0
- package/src/json-text.ts +52 -0
- package/src/keyset-seek.ts +189 -0
- package/src/layout.tsx +55 -43
- package/src/list-columns.tsx +53 -0
- package/src/list-filter-bar.tsx +207 -0
- package/src/list-filters.ts +242 -0
- package/src/list-request.ts +129 -0
- package/src/list-scope-tabs.tsx +60 -0
- package/src/list-scope.ts +113 -0
- package/src/list.tsx +111 -72
- package/src/mcp-tools.ts +57 -13
- package/src/mcp.ts +112 -38
- package/src/mounts.ts +44 -0
- package/src/page-guard.tsx +26 -29
- package/src/pagination.ts +10 -4
- package/src/policy-bridge.ts +115 -18
- package/src/registry.ts +140 -9
- package/src/related.ts +105 -0
- package/src/relations.ts +240 -0
- package/src/renderers.tsx +89 -0
- package/src/repo-entity.ts +195 -0
- package/src/resource-fields.ts +116 -0
- package/src/resource-layout.ts +89 -0
- package/src/resource-list.ts +194 -0
- package/src/resource.ts +129 -79
- package/src/route-config.ts +71 -0
- package/src/routes.ts +55 -54
- package/src/row-scope-write.ts +130 -0
- package/src/row-version.ts +20 -0
- package/src/screen-action.tsx +333 -0
- package/src/screen-frame.tsx +167 -0
- package/src/screen-home.tsx +201 -0
- package/src/screen-lookup.tsx +157 -0
- package/src/screen-resource.tsx +441 -0
- package/src/screen-system.tsx +75 -0
- package/src/screens.ts +56 -0
- package/src/search.ts +22 -10
- package/src/stylesheet-scope.ts +8 -0
- package/src/test-ctx.ts +31 -0
- package/src/validate.ts +3 -0
- package/src/widget-value.ts +36 -8
- package/src/widgets.tsx +84 -16
|
@@ -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
|
+
}
|
package/src/action-gate.ts
CHANGED
|
@@ -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
|
|
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
|
-
/**
|
|
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
|
|
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.
|
|
120
|
-
*
|
|
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
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
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
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
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
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
})
|
|
227
|
-
|
|
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
|
+
}
|