@ultimat3/admin 1.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/LICENSE +21 -0
- package/README.md +115 -0
- package/package.json +48 -0
- package/src/action-gate.ts +202 -0
- package/src/actions.tsx +94 -0
- package/src/admin.ts +186 -0
- package/src/ai-panes.ts +139 -0
- package/src/audit.ts +183 -0
- package/src/authz.ts +129 -0
- package/src/crud.ts +278 -0
- package/src/detail.tsx +121 -0
- package/src/dev/data.ts +344 -0
- package/src/dev/facts.ts +180 -0
- package/src/dev/index.ts +47 -0
- package/src/dev/panel-cache.ts +47 -0
- package/src/dev/panel-db.ts +59 -0
- package/src/dev/panel-jobs.ts +66 -0
- package/src/dev/panel-live.ts +46 -0
- package/src/dev/panel-mail.ts +51 -0
- package/src/dev/panel-manifest.ts +39 -0
- package/src/dev/panel-policy.ts +61 -0
- package/src/dev/panel-routes.ts +43 -0
- package/src/dev/panel-timeline.ts +82 -0
- package/src/dev/panel.ts +58 -0
- package/src/dev/server.ts +189 -0
- package/src/entity-columns.ts +95 -0
- package/src/errors.ts +145 -0
- package/src/fields.ts +151 -0
- package/src/form.tsx +97 -0
- package/src/index.ts +214 -0
- package/src/layout.tsx +104 -0
- package/src/list.tsx +120 -0
- package/src/mcp-tools.ts +201 -0
- package/src/mcp.ts +304 -0
- package/src/nav.ts +97 -0
- package/src/pagination.ts +152 -0
- package/src/permissions.ts +92 -0
- package/src/policy-bridge.ts +64 -0
- package/src/registry.ts +181 -0
- package/src/resource.ts +321 -0
- package/src/routes.ts +35 -0
- package/src/search.ts +108 -0
- package/src/theme.ts +59 -0
- package/src/validate.ts +65 -0
- package/src/widget-value.ts +217 -0
- package/src/widgets.tsx +262 -0
package/src/crud.ts
ADDED
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
// The five CRUD operations, each one: policy → confirmation → validation → repo → audit.
|
|
2
|
+
// Views and MCP tools both call these, so there is one ordering of those five steps in the
|
|
3
|
+
// admin rather than one per surface.
|
|
4
|
+
|
|
5
|
+
import { type AuditEntry, type AuditLog, deniedDraft, diffRows } from './audit';
|
|
6
|
+
import { type AdminActor, type AdminAuthz, type AdminDecision, decideAll } from './authz';
|
|
7
|
+
import { type AdminPage, fetchPage, type PageRequest } from './pagination';
|
|
8
|
+
import {
|
|
9
|
+
type AdminOperation,
|
|
10
|
+
adminPermissionFor,
|
|
11
|
+
CONFIRMATION_REQUIRED_REASON,
|
|
12
|
+
confirmationToken,
|
|
13
|
+
entityPermissionFor,
|
|
14
|
+
isDestructive,
|
|
15
|
+
} from './permissions';
|
|
16
|
+
import type { AdminRow } from './registry';
|
|
17
|
+
import { type AdminResource, repoOf } from './resource';
|
|
18
|
+
import { type ValidationIssue, validateInput } from './validate';
|
|
19
|
+
|
|
20
|
+
export interface CrudCtx {
|
|
21
|
+
readonly actor: AdminActor;
|
|
22
|
+
readonly authz: AdminAuthz;
|
|
23
|
+
readonly audit: AuditLog;
|
|
24
|
+
readonly requestId: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export type CrudResult<Row extends AdminRow> =
|
|
28
|
+
| { readonly ok: true; readonly row: Row | null; readonly audit: AuditEntry }
|
|
29
|
+
| {
|
|
30
|
+
readonly ok: false;
|
|
31
|
+
readonly kind: 'denied';
|
|
32
|
+
readonly decision: AdminDecision;
|
|
33
|
+
readonly confirmationRequired: boolean;
|
|
34
|
+
readonly audit: AuditEntry;
|
|
35
|
+
}
|
|
36
|
+
| {
|
|
37
|
+
readonly ok: false;
|
|
38
|
+
readonly kind: 'invalid';
|
|
39
|
+
readonly issues: readonly ValidationIssue[];
|
|
40
|
+
readonly audit: AuditEntry;
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
export type ListResult<Row extends AdminRow> =
|
|
44
|
+
| { readonly ok: true; readonly page: AdminPage<Row> }
|
|
45
|
+
| { readonly ok: false; readonly kind: 'denied'; readonly decision: AdminDecision };
|
|
46
|
+
|
|
47
|
+
/** Both gates, always in this order: the admin-level one, then the entity-level one. */
|
|
48
|
+
export function permissionsForOperation(entity: string, op: AdminOperation): readonly string[] {
|
|
49
|
+
return [adminPermissionFor(op), entityPermissionFor(entity, op)];
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function decideOperation(
|
|
53
|
+
resource: AdminResource,
|
|
54
|
+
op: AdminOperation,
|
|
55
|
+
ctx: CrudCtx,
|
|
56
|
+
id?: string,
|
|
57
|
+
): AdminDecision {
|
|
58
|
+
return decideAll(ctx.authz, permissionsForOperation(resource.name, op), ctx.actor, {
|
|
59
|
+
entity: resource.name,
|
|
60
|
+
...(id === undefined ? {} : { id }),
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** `true` when the operation should be offered at all — nav, buttons, MCP tool list. */
|
|
65
|
+
export function canOperate(resource: AdminResource, op: AdminOperation, ctx: CrudCtx): boolean {
|
|
66
|
+
return resource.operations.includes(op) && decideOperation(resource, op, ctx).allowed;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const redactedFields = (resource: AdminResource): readonly string[] =>
|
|
70
|
+
resource.fields.filter((field) => field.sensitive).map((field) => field.name);
|
|
71
|
+
|
|
72
|
+
async function refuse<Row extends AdminRow>(
|
|
73
|
+
resource: AdminResource<Row>,
|
|
74
|
+
op: AdminOperation,
|
|
75
|
+
ctx: CrudCtx,
|
|
76
|
+
decision: AdminDecision,
|
|
77
|
+
id: string | null,
|
|
78
|
+
confirmationRequired = false,
|
|
79
|
+
): Promise<CrudResult<Row>> {
|
|
80
|
+
return {
|
|
81
|
+
ok: false,
|
|
82
|
+
kind: 'denied',
|
|
83
|
+
decision,
|
|
84
|
+
confirmationRequired,
|
|
85
|
+
audit: await ctx.audit.append(
|
|
86
|
+
deniedDraft({
|
|
87
|
+
requestId: ctx.requestId,
|
|
88
|
+
actor: ctx.actor,
|
|
89
|
+
operation: op,
|
|
90
|
+
kind: 'operation',
|
|
91
|
+
entity: resource.name,
|
|
92
|
+
entityId: id,
|
|
93
|
+
decision,
|
|
94
|
+
}),
|
|
95
|
+
),
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export async function adminList<Row extends AdminRow>(
|
|
100
|
+
resource: AdminResource<Row>,
|
|
101
|
+
ctx: CrudCtx,
|
|
102
|
+
req: PageRequest = {},
|
|
103
|
+
): Promise<ListResult<Row>> {
|
|
104
|
+
const decision = decideOperation(resource, 'list', ctx);
|
|
105
|
+
if (!decision.allowed) return { ok: false, kind: 'denied', decision };
|
|
106
|
+
return { ok: true, page: await fetchPage(resource, req) };
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export async function adminDetail<Row extends AdminRow>(
|
|
110
|
+
resource: AdminResource<Row>,
|
|
111
|
+
ctx: CrudCtx,
|
|
112
|
+
id: string,
|
|
113
|
+
): Promise<CrudResult<Row>> {
|
|
114
|
+
const decision = decideOperation(resource, 'detail', ctx, id);
|
|
115
|
+
if (!decision.allowed) return refuse(resource, 'detail', ctx, decision, id);
|
|
116
|
+
const row = await repoOf(resource).find(id);
|
|
117
|
+
return {
|
|
118
|
+
ok: true,
|
|
119
|
+
row,
|
|
120
|
+
audit: await ctx.audit.append({
|
|
121
|
+
requestId: ctx.requestId,
|
|
122
|
+
actor: ctx.actor,
|
|
123
|
+
operation: 'detail',
|
|
124
|
+
kind: 'operation',
|
|
125
|
+
entity: resource.name,
|
|
126
|
+
entityId: id,
|
|
127
|
+
permission: decision.permission,
|
|
128
|
+
outcome: 'allowed',
|
|
129
|
+
reason: decision.reason,
|
|
130
|
+
}),
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export async function adminCreate<Row extends AdminRow>(
|
|
135
|
+
resource: AdminResource<Row>,
|
|
136
|
+
ctx: CrudCtx,
|
|
137
|
+
input: Readonly<Record<string, unknown>>,
|
|
138
|
+
): Promise<CrudResult<Row>> {
|
|
139
|
+
const decision = decideOperation(resource, 'create', ctx);
|
|
140
|
+
if (!decision.allowed) return refuse(resource, 'create', ctx, decision, null);
|
|
141
|
+
|
|
142
|
+
const parsed = await validateInput(resource.entity.$schema, input);
|
|
143
|
+
if (!parsed.ok) return invalid(resource, 'create', ctx, null, parsed.issues, decision);
|
|
144
|
+
|
|
145
|
+
const row = await repoOf(resource).create(parsed.value);
|
|
146
|
+
return {
|
|
147
|
+
ok: true,
|
|
148
|
+
row,
|
|
149
|
+
audit: await ctx.audit.append({
|
|
150
|
+
requestId: ctx.requestId,
|
|
151
|
+
actor: ctx.actor,
|
|
152
|
+
operation: 'create',
|
|
153
|
+
kind: 'operation',
|
|
154
|
+
entity: resource.name,
|
|
155
|
+
entityId: String(row[resource.idField] ?? ''),
|
|
156
|
+
permission: decision.permission,
|
|
157
|
+
outcome: 'allowed',
|
|
158
|
+
reason: decision.reason,
|
|
159
|
+
diff: diffRows(null, row, { redact: redactedFields(resource) }),
|
|
160
|
+
}),
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
export async function adminUpdate<Row extends AdminRow>(
|
|
165
|
+
resource: AdminResource<Row>,
|
|
166
|
+
ctx: CrudCtx,
|
|
167
|
+
id: string,
|
|
168
|
+
patch: Readonly<Record<string, unknown>>,
|
|
169
|
+
): Promise<CrudResult<Row>> {
|
|
170
|
+
const decision = decideOperation(resource, 'update', ctx, id);
|
|
171
|
+
if (!decision.allowed) return refuse(resource, 'update', ctx, decision, id);
|
|
172
|
+
|
|
173
|
+
const repo = repoOf(resource);
|
|
174
|
+
const before = await repo.find(id);
|
|
175
|
+
const parsed = await validateInput(resource.entity.$schema, { ...(before ?? {}), ...patch });
|
|
176
|
+
if (!parsed.ok) return invalid(resource, 'update', ctx, id, parsed.issues, decision);
|
|
177
|
+
|
|
178
|
+
// Write what the schema validated, not the caller's raw patch — a field the schema would
|
|
179
|
+
// strip (undeclared, or normalized to a different value) must never reach the repo. Scoped
|
|
180
|
+
// to the keys actually submitted, so a partial update stays partial rather than rewriting
|
|
181
|
+
// every field of `before` too.
|
|
182
|
+
const submittedKeys = Object.keys(patch);
|
|
183
|
+
const validatedPatch: Readonly<Record<string, unknown>> = Object.fromEntries(
|
|
184
|
+
submittedKeys.filter((key) => key in parsed.value).map((key) => [key, parsed.value[key]]),
|
|
185
|
+
);
|
|
186
|
+
const after = await repo.update(id, validatedPatch);
|
|
187
|
+
return {
|
|
188
|
+
ok: true,
|
|
189
|
+
row: after,
|
|
190
|
+
audit: await ctx.audit.append({
|
|
191
|
+
requestId: ctx.requestId,
|
|
192
|
+
actor: ctx.actor,
|
|
193
|
+
operation: 'update',
|
|
194
|
+
kind: 'operation',
|
|
195
|
+
entity: resource.name,
|
|
196
|
+
entityId: id,
|
|
197
|
+
permission: decision.permission,
|
|
198
|
+
outcome: 'allowed',
|
|
199
|
+
reason: decision.reason,
|
|
200
|
+
diff: diffRows(before, after, { redact: redactedFields(resource) }),
|
|
201
|
+
}),
|
|
202
|
+
};
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/** Destructive: the caller must echo `confirmationToken(entity, id)` or nothing happens. */
|
|
206
|
+
export async function adminDestroy<Row extends AdminRow>(
|
|
207
|
+
resource: AdminResource<Row>,
|
|
208
|
+
ctx: CrudCtx,
|
|
209
|
+
id: string,
|
|
210
|
+
confirmation: string | undefined,
|
|
211
|
+
): Promise<CrudResult<Row>> {
|
|
212
|
+
const decision = decideOperation(resource, 'delete', ctx, id);
|
|
213
|
+
if (!decision.allowed) return refuse(resource, 'delete', ctx, decision, id);
|
|
214
|
+
|
|
215
|
+
const expected = confirmationToken(resource.name, id);
|
|
216
|
+
if (isDestructive('delete') && confirmation !== expected) {
|
|
217
|
+
return refuse(
|
|
218
|
+
resource,
|
|
219
|
+
'delete',
|
|
220
|
+
ctx,
|
|
221
|
+
{
|
|
222
|
+
allowed: false,
|
|
223
|
+
permission: adminPermissionFor('delete'),
|
|
224
|
+
reason: CONFIRMATION_REQUIRED_REASON,
|
|
225
|
+
trace: [`confirmation: expected "${expected}"`],
|
|
226
|
+
},
|
|
227
|
+
id,
|
|
228
|
+
true,
|
|
229
|
+
);
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
const repo = repoOf(resource);
|
|
233
|
+
const before = await repo.find(id);
|
|
234
|
+
await repo.destroy(id);
|
|
235
|
+
return {
|
|
236
|
+
ok: true,
|
|
237
|
+
row: null,
|
|
238
|
+
audit: await ctx.audit.append({
|
|
239
|
+
requestId: ctx.requestId,
|
|
240
|
+
actor: ctx.actor,
|
|
241
|
+
operation: 'delete',
|
|
242
|
+
kind: 'operation',
|
|
243
|
+
entity: resource.name,
|
|
244
|
+
entityId: id,
|
|
245
|
+
permission: decision.permission,
|
|
246
|
+
outcome: 'allowed',
|
|
247
|
+
reason: decision.reason,
|
|
248
|
+
diff: diffRows(before, null, { redact: redactedFields(resource) }),
|
|
249
|
+
}),
|
|
250
|
+
};
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
async function invalid<Row extends AdminRow>(
|
|
254
|
+
resource: AdminResource<Row>,
|
|
255
|
+
op: AdminOperation,
|
|
256
|
+
ctx: CrudCtx,
|
|
257
|
+
id: string | null,
|
|
258
|
+
issues: readonly ValidationIssue[],
|
|
259
|
+
decision: AdminDecision,
|
|
260
|
+
): Promise<CrudResult<Row>> {
|
|
261
|
+
return {
|
|
262
|
+
ok: false,
|
|
263
|
+
kind: 'invalid',
|
|
264
|
+
issues,
|
|
265
|
+
audit: await ctx.audit.append({
|
|
266
|
+
requestId: ctx.requestId,
|
|
267
|
+
actor: ctx.actor,
|
|
268
|
+
operation: op,
|
|
269
|
+
kind: 'operation',
|
|
270
|
+
entity: resource.name,
|
|
271
|
+
entityId: id,
|
|
272
|
+
permission: decision.permission,
|
|
273
|
+
outcome: 'failed',
|
|
274
|
+
reason: 'admin.error.invalid-input',
|
|
275
|
+
diff: [],
|
|
276
|
+
}),
|
|
277
|
+
};
|
|
278
|
+
}
|
package/src/detail.tsx
ADDED
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
// The detail view: every field as a labelled read-only row, the audit trail for this row,
|
|
2
|
+
// and the actions this actor may run on it. Loading and error states are first-class, not an
|
|
3
|
+
// afterthought — a detail page with no row is a state, not a blank card.
|
|
4
|
+
|
|
5
|
+
import { t } from '@ultimat3/i18n';
|
|
6
|
+
import { Card, ErrorState } from '@ultimat3/ui';
|
|
7
|
+
import type { JSX } from 'solid-js';
|
|
8
|
+
import type { AdminActionButton } from './action-gate';
|
|
9
|
+
import { AdminActions } from './actions';
|
|
10
|
+
import type { AuditEntry } from './audit';
|
|
11
|
+
import type { AdminActor, AdminAuthz } from './authz';
|
|
12
|
+
import { type AdminErrorParts, adminErrorFrom } from './errors';
|
|
13
|
+
import type { AdminRow } from './registry';
|
|
14
|
+
import type { AdminResource } from './resource';
|
|
15
|
+
import type { WidgetContext } from './widget-value';
|
|
16
|
+
import { Widget } from './widgets';
|
|
17
|
+
|
|
18
|
+
export interface AdminDetailProps<Row extends AdminRow> {
|
|
19
|
+
readonly resource: AdminResource<Row>;
|
|
20
|
+
readonly row: Row | null;
|
|
21
|
+
readonly loading: boolean;
|
|
22
|
+
readonly error: AdminErrorParts | null;
|
|
23
|
+
readonly ctx: WidgetContext;
|
|
24
|
+
readonly actor: AdminActor;
|
|
25
|
+
readonly authz: AdminAuthz;
|
|
26
|
+
readonly audit: readonly AuditEntry[];
|
|
27
|
+
readonly basePath: string;
|
|
28
|
+
readonly pending?: AdminActionButton | null;
|
|
29
|
+
readonly confirmation?: string;
|
|
30
|
+
readonly onRun?: (button: AdminActionButton, confirmation: string) => void;
|
|
31
|
+
readonly onRequestConfirm?: (button: AdminActionButton) => void;
|
|
32
|
+
readonly onConfirmationInput?: (value: string) => void;
|
|
33
|
+
readonly onCancel?: () => void;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function AdminDetail<Row extends AdminRow>(props: AdminDetailProps<Row>): JSX.Element {
|
|
37
|
+
if (props.error !== null) {
|
|
38
|
+
return <ErrorState error={adminErrorFrom(props.error)} />;
|
|
39
|
+
}
|
|
40
|
+
if (props.loading) {
|
|
41
|
+
return (
|
|
42
|
+
<Card header={<h2>{t(props.resource.titleKey)}</h2>}>
|
|
43
|
+
<p aria-busy="true">{t('admin.detail.loading')}</p>
|
|
44
|
+
</Card>
|
|
45
|
+
);
|
|
46
|
+
}
|
|
47
|
+
if (props.row === null) {
|
|
48
|
+
return (
|
|
49
|
+
<ErrorState
|
|
50
|
+
error={adminErrorFrom({
|
|
51
|
+
code: 'X_ADMIN_ENTITY_UNKNOWN',
|
|
52
|
+
cause: t('admin.detail.not-found', { entity: props.resource.name }),
|
|
53
|
+
fix: t('admin.detail.not-found.fix'),
|
|
54
|
+
})}
|
|
55
|
+
/>
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const row = props.row;
|
|
60
|
+
const id = String(row[props.resource.idField] ?? '');
|
|
61
|
+
|
|
62
|
+
return (
|
|
63
|
+
<>
|
|
64
|
+
<Card header={<h2>{t(props.resource.titleKey)}</h2>}>
|
|
65
|
+
<AdminActions
|
|
66
|
+
actions={props.resource.actions}
|
|
67
|
+
actor={props.actor}
|
|
68
|
+
authz={props.authz}
|
|
69
|
+
subject={{ entity: props.resource.name, id }}
|
|
70
|
+
pending={props.pending ?? null}
|
|
71
|
+
confirmation={props.confirmation ?? ''}
|
|
72
|
+
onRun={props.onRun}
|
|
73
|
+
onRequestConfirm={props.onRequestConfirm}
|
|
74
|
+
onConfirmationInput={props.onConfirmationInput}
|
|
75
|
+
onCancel={props.onCancel}
|
|
76
|
+
/>
|
|
77
|
+
|
|
78
|
+
<dl class="x-admin-detail">
|
|
79
|
+
{props.resource.fields
|
|
80
|
+
.filter((field) => !field.sensitive)
|
|
81
|
+
.map((field) => (
|
|
82
|
+
<>
|
|
83
|
+
<dt>{t(field.labelKey)}</dt>
|
|
84
|
+
<dd>
|
|
85
|
+
<Widget field={field} value={row[field.name]} ctx={props.ctx} mode="read" />
|
|
86
|
+
</dd>
|
|
87
|
+
</>
|
|
88
|
+
))}
|
|
89
|
+
</dl>
|
|
90
|
+
|
|
91
|
+
<a href={`${props.basePath}${props.resource.path}/${id}/edit`}>{t('admin.detail.edit')}</a>
|
|
92
|
+
</Card>
|
|
93
|
+
|
|
94
|
+
<Card header={<h2>{t('admin.audit.title')}</h2>}>
|
|
95
|
+
{props.audit.length === 0 ? (
|
|
96
|
+
<p class="x-admin-empty">{t('admin.audit.empty')}</p>
|
|
97
|
+
) : (
|
|
98
|
+
<ol class="x-admin-audit">
|
|
99
|
+
{props.audit.map((entry) => (
|
|
100
|
+
<li>
|
|
101
|
+
<code>{entry.at}</code> <span>{entry.actor.id}</span>{' '}
|
|
102
|
+
<span>{t(`admin.operation.${entry.operation}`)}</span>{' '}
|
|
103
|
+
<span data-outcome={entry.outcome}>
|
|
104
|
+
{t(`admin.audit.outcome.${entry.outcome}`)}
|
|
105
|
+
</span>
|
|
106
|
+
<ul>
|
|
107
|
+
{entry.diff.map((change) => (
|
|
108
|
+
<li>
|
|
109
|
+
<code>{change.field}</code>: <del>{String(change.before ?? '')}</del>{' '}
|
|
110
|
+
<ins>{String(change.after ?? '')}</ins>
|
|
111
|
+
</li>
|
|
112
|
+
))}
|
|
113
|
+
</ul>
|
|
114
|
+
</li>
|
|
115
|
+
))}
|
|
116
|
+
</ol>
|
|
117
|
+
)}
|
|
118
|
+
</Card>
|
|
119
|
+
</>
|
|
120
|
+
);
|
|
121
|
+
}
|