@ultimat3/admin 22.14.0 → 23.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CLAUDE.md +73 -80
- package/README.md +276 -87
- package/package.json +19 -17
- package/src/action-form.tsx +139 -0
- package/src/action-gate.ts +127 -28
- 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 +92 -0
- package/src/batch.ts +431 -0
- package/src/catalog-keys.ts +55 -0
- package/src/crud-input.ts +104 -0
- package/src/crud-outcome.ts +166 -0
- package/src/crud.ts +129 -177
- package/src/describe.ts +131 -0
- package/src/detail.tsx +115 -43
- package/src/dev/data.ts +24 -6
- package/src/dev/facts.ts +27 -0
- 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.ts +6 -0
- package/src/dev/server.ts +15 -7
- package/src/entity-columns.ts +24 -5
- package/src/errors.ts +83 -0
- package/src/fields.ts +63 -4
- package/src/form-decode.ts +91 -0
- package/src/form.tsx +78 -45
- package/src/index.ts +198 -9
- package/src/jobs/fleet-repo.ts +143 -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 +103 -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/layout.tsx +55 -43
- package/src/list-columns.tsx +53 -0
- package/src/list-filter-bar.tsx +207 -0
- package/src/list-filters.ts +217 -0
- package/src/list-request.ts +127 -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 +108 -38
- package/src/mounts.ts +44 -0
- package/src/page-guard.tsx +26 -29
- package/src/policy-bridge.ts +110 -15
- package/src/registry.ts +138 -8
- package/src/related.ts +105 -0
- package/src/relations.ts +224 -0
- package/src/renderers.tsx +89 -0
- package/src/repo-entity.ts +204 -0
- package/src/resource-fields.ts +116 -0
- package/src/resource-layout.ts +89 -0
- package/src/resource-list.ts +185 -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/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 +393 -0
- package/src/screen-system.tsx +69 -0
- package/src/screens.ts +56 -0
- package/src/search.ts +13 -9
- 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 -12
package/src/resource.ts
CHANGED
|
@@ -4,25 +4,33 @@
|
|
|
4
4
|
// field, but the zero-config result is the one the generator emits and the one the docs show.
|
|
5
5
|
|
|
6
6
|
import { finiteCount } from '@ultimat3/core';
|
|
7
|
-
import { type AdminColumnFacts, adminColumnsOf } from './entity-columns';
|
|
7
|
+
import { type AdminColumnFacts, adminColumnsOf, adminSealedColumnsOf } from './entity-columns';
|
|
8
8
|
import {
|
|
9
9
|
AdminEntityUnknownError,
|
|
10
10
|
AdminFieldUnsupportedError,
|
|
11
11
|
AdminPolicyMissingError,
|
|
12
|
+
AdminRepoUnboundError,
|
|
12
13
|
} from './errors';
|
|
13
|
-
import {
|
|
14
|
-
type AdminField,
|
|
15
|
-
type AdminFieldType,
|
|
16
|
-
type AdminWidget,
|
|
17
|
-
fieldTypeFromColumn,
|
|
18
|
-
filterable,
|
|
19
|
-
listable,
|
|
20
|
-
searchable,
|
|
21
|
-
sortable,
|
|
22
|
-
widgetFor,
|
|
23
|
-
} from './fields';
|
|
14
|
+
import type { AdminField } from './fields';
|
|
24
15
|
import { ADMIN_OPERATIONS, type AdminOperation } from './permissions';
|
|
25
16
|
import type { AdminAction, AdminEntity, AdminRepo, AdminRow, AdminSort } from './registry';
|
|
17
|
+
import { type AdminFieldOverride, deriveField, secretField } from './resource-fields';
|
|
18
|
+
import {
|
|
19
|
+
type AdminFormGroupOptions,
|
|
20
|
+
type AdminSection,
|
|
21
|
+
type AdminSectionOptions,
|
|
22
|
+
layoutOf,
|
|
23
|
+
} from './resource-layout';
|
|
24
|
+
import {
|
|
25
|
+
type AdminComputedColumn,
|
|
26
|
+
type AdminComputedColumnOptions,
|
|
27
|
+
type AdminRowScope,
|
|
28
|
+
type AdminScope,
|
|
29
|
+
type AdminScopeOptions,
|
|
30
|
+
computedColumnsOf,
|
|
31
|
+
rowScopeOf,
|
|
32
|
+
scopesOf,
|
|
33
|
+
} from './resource-list';
|
|
26
34
|
|
|
27
35
|
/** Six columns is what fits a laptop viewport without horizontal scroll. */
|
|
28
36
|
const MAX_LIST_FIELDS = 6;
|
|
@@ -30,27 +38,15 @@ const DEFAULT_PAGE_SIZE = 25;
|
|
|
30
38
|
const LABEL_CANDIDATES = ['name', 'title', 'slug', 'label', 'email'] as const;
|
|
31
39
|
const SORT_CANDIDATES = ['createdAt', 'created_at', 'updatedAt', 'updated_at'] as const;
|
|
32
40
|
|
|
33
|
-
export interface AdminFieldOverride {
|
|
34
|
-
readonly type?: AdminFieldType;
|
|
35
|
-
readonly widget?: AdminWidget;
|
|
36
|
-
readonly labelKey?: string;
|
|
37
|
-
/** Out of every surface: list, detail, form, MCP schema. */
|
|
38
|
-
readonly hidden?: boolean;
|
|
39
|
-
readonly inList?: boolean;
|
|
40
|
-
readonly readOnly?: boolean;
|
|
41
|
-
readonly required?: boolean;
|
|
42
|
-
readonly sensitive?: boolean;
|
|
43
|
-
readonly filterable?: boolean;
|
|
44
|
-
readonly sortable?: boolean;
|
|
45
|
-
readonly searchable?: boolean;
|
|
46
|
-
readonly currency?: string;
|
|
47
|
-
readonly values?: readonly string[];
|
|
48
|
-
readonly relation?: { readonly entity: string; readonly labelField?: string };
|
|
49
|
-
}
|
|
50
|
-
|
|
51
41
|
export interface AdminResourceOptions<Row extends AdminRow = AdminRow> {
|
|
52
42
|
readonly repo?: AdminRepo<Row>;
|
|
53
43
|
readonly path?: string;
|
|
44
|
+
/**
|
|
45
|
+
* The noun this resource's permissions are named after — `<permission>:read|write|delete`.
|
|
46
|
+
* Default: the entity's name. Several resources that are one subject to an operator share one
|
|
47
|
+
* (the jobs screens are all `job:read`), so a role map grants a subject once, not per table.
|
|
48
|
+
*/
|
|
49
|
+
readonly permission?: string;
|
|
54
50
|
readonly titleKey?: string;
|
|
55
51
|
readonly group?: string;
|
|
56
52
|
/**
|
|
@@ -62,6 +58,35 @@ export interface AdminResourceOptions<Row extends AdminRow = AdminRow> {
|
|
|
62
58
|
readonly fields?: Readonly<Record<string, AdminFieldOverride>>;
|
|
63
59
|
/** Explicit list columns, in order. Omit to let the derivation pick. */
|
|
64
60
|
readonly listFields?: readonly string[];
|
|
61
|
+
/**
|
|
62
|
+
* Computed list columns, drawn after `listFields` in declaration order:
|
|
63
|
+
* `age: { value: (row) => row.createdAt, render: 'relative-time' }`. `render` is one of the six
|
|
64
|
+
* built-in renderers or a component. A name that is also an entity column is refused.
|
|
65
|
+
*/
|
|
66
|
+
readonly columns?: Readonly<Record<string, AdminComputedColumnOptions<Row>>>;
|
|
67
|
+
/**
|
|
68
|
+
* Named predicates, drawn as tabs: `open: { where: [...] }`, `mine: { where: (actor) => [...] }`.
|
|
69
|
+
* At most one `default: true`; `count: true` is one extra query per tab that asks for it.
|
|
70
|
+
*/
|
|
71
|
+
readonly scopes?: Readonly<Record<string, AdminScopeOptions>>;
|
|
72
|
+
/**
|
|
73
|
+
* The rows this ACTOR may see at all. Applied to every read of the resource — list, search,
|
|
74
|
+
* detail, lookup, labels, MCP — so it is the one place a second audience is declared.
|
|
75
|
+
*/
|
|
76
|
+
readonly rows?: AdminRowScope;
|
|
77
|
+
/**
|
|
78
|
+
* The detail page, as titled groups of fields. A field named in none falls into a default
|
|
79
|
+
* section drawn last, so a column added to the entity is never hidden by this list.
|
|
80
|
+
*/
|
|
81
|
+
readonly sections?: readonly AdminSectionOptions[];
|
|
82
|
+
/** The same arrangement for the create and edit form. Independent of `sections`. */
|
|
83
|
+
readonly formGroups?: readonly AdminFormGroupOptions[];
|
|
84
|
+
/**
|
|
85
|
+
* `hasMany` relations of the entity, by name — `['comments']` — drawn on the detail page as the
|
|
86
|
+
* RELATED resource's own list (its columns, its row scope, its policy) filtered to this row.
|
|
87
|
+
* The names are `@ultimat3/entity`'s: the one a preload names.
|
|
88
|
+
*/
|
|
89
|
+
readonly related?: readonly string[];
|
|
65
90
|
readonly defaultSort?: AdminSort;
|
|
66
91
|
readonly pageSize?: number;
|
|
67
92
|
readonly operations?: readonly AdminOperation[];
|
|
@@ -81,6 +106,8 @@ export interface AdminResource<Row extends AdminRow = AdminRow> {
|
|
|
81
106
|
* the framework can own (axiom 8) — `path:` is how an app spells its own.
|
|
82
107
|
*/
|
|
83
108
|
readonly path: string;
|
|
109
|
+
/** The permission noun — `<permission>:read` lists it. The entity's name unless declared. */
|
|
110
|
+
readonly permission: string;
|
|
84
111
|
readonly titleKey: string;
|
|
85
112
|
readonly group: string;
|
|
86
113
|
readonly idField: string;
|
|
@@ -90,8 +117,31 @@ export interface AdminResource<Row extends AdminRow = AdminRow> {
|
|
|
90
117
|
readonly fields: readonly AdminField[];
|
|
91
118
|
readonly listFields: readonly AdminField[];
|
|
92
119
|
readonly formFields: readonly AdminField[];
|
|
120
|
+
/**
|
|
121
|
+
* The entity's `.sealed()` columns, as WRITE-ONLY inputs: a form posts them, nothing reads them.
|
|
122
|
+
* Deliberately a list of its own and in none of the others — `fields`, `listFields`,
|
|
123
|
+
* `formFields`, `filters` and `searchFields` are all read back by column name somewhere, and a
|
|
124
|
+
* sealed property answers that read with its plaintext.
|
|
125
|
+
*/
|
|
126
|
+
readonly secretFields: readonly AdminField[];
|
|
127
|
+
/**
|
|
128
|
+
* The label field first — a list's search box is its `contains` filter — then every field the
|
|
129
|
+
* derivation offers. What a URL's `f.<field>` may name, and nothing else.
|
|
130
|
+
*/
|
|
93
131
|
readonly filters: readonly AdminField[];
|
|
94
132
|
readonly searchFields: readonly AdminField[];
|
|
133
|
+
/** Computed list columns, in declaration order. */
|
|
134
|
+
readonly columns: readonly AdminComputedColumn[];
|
|
135
|
+
/** Declared scopes, in tab order. Empty for a resource that declares none. */
|
|
136
|
+
readonly scopes: readonly AdminScope[];
|
|
137
|
+
/** `rows`, validated per call. Absent for a resource every permitted actor sees whole. */
|
|
138
|
+
readonly rowScope?: AdminRowScope;
|
|
139
|
+
/** The detail page's groups. Always at least one: undeclared fields have a default section. */
|
|
140
|
+
readonly sections: readonly AdminSection[];
|
|
141
|
+
/** The form's groups, over `formFields` and `secretFields` both. */
|
|
142
|
+
readonly formGroups: readonly AdminSection[];
|
|
143
|
+
/** Declared `related` relation names, in order. `related.ts` resolves each against the admin. */
|
|
144
|
+
readonly related: readonly string[];
|
|
95
145
|
readonly defaultSort: AdminSort;
|
|
96
146
|
readonly pageSize: number;
|
|
97
147
|
readonly operations: readonly AdminOperation[];
|
|
@@ -100,48 +150,6 @@ export interface AdminResource<Row extends AdminRow = AdminRow> {
|
|
|
100
150
|
field(name: string): AdminField;
|
|
101
151
|
}
|
|
102
152
|
|
|
103
|
-
function deriveField(
|
|
104
|
-
entity: AdminEntity,
|
|
105
|
-
column: AdminColumnFacts,
|
|
106
|
-
override: AdminFieldOverride | undefined,
|
|
107
|
-
): AdminField {
|
|
108
|
-
const name = column.name;
|
|
109
|
-
const type = override?.type ?? fieldTypeFromColumn(entity.$name, name, column);
|
|
110
|
-
const widget = override?.widget ?? widgetFor(type);
|
|
111
|
-
const generated = column.generated || column.primaryKey;
|
|
112
|
-
// An entity has no notion of a secret column, and a currency belongs to the value rather
|
|
113
|
-
// than the column, so both are admin-side declarations or they are absent. Never guessed.
|
|
114
|
-
const sensitive = override?.sensitive ?? false;
|
|
115
|
-
const currency = override?.currency;
|
|
116
|
-
const values = override?.values ?? column.values;
|
|
117
|
-
const relation =
|
|
118
|
-
override?.relation ??
|
|
119
|
-
// The FK's value IS the target column's value, so that column is the honest default label.
|
|
120
|
-
(column.references === undefined
|
|
121
|
-
? undefined
|
|
122
|
-
: { entity: column.references.entity, labelField: column.references.column });
|
|
123
|
-
|
|
124
|
-
return {
|
|
125
|
-
entity: entity.$name,
|
|
126
|
-
name,
|
|
127
|
-
type,
|
|
128
|
-
widget,
|
|
129
|
-
labelKey: override?.labelKey ?? `admin.${entity.$name}.field.${name}`,
|
|
130
|
-
required: override?.required ?? (!column.nullable && !generated),
|
|
131
|
-
readOnly: override?.readOnly ?? generated,
|
|
132
|
-
sensitive,
|
|
133
|
-
inList: override?.inList ?? (listable(type) && !sensitive),
|
|
134
|
-
filterable: override?.filterable ?? filterable(type, column),
|
|
135
|
-
sortable: override?.sortable ?? sortable(type, column),
|
|
136
|
-
// Generated columns are excluded from search: an id is found by exact lookup, and a
|
|
137
|
-
// `contains` over a uuid column is a scan that returns nothing useful.
|
|
138
|
-
searchable: override?.searchable ?? (searchable(type, column) && !sensitive && !generated),
|
|
139
|
-
...(values === undefined ? {} : { values }),
|
|
140
|
-
...(currency === undefined ? {} : { currency }),
|
|
141
|
-
...(relation === undefined ? {} : { relation }),
|
|
142
|
-
};
|
|
143
|
-
}
|
|
144
|
-
|
|
145
153
|
/**
|
|
146
154
|
* The declared key wins — but a composite one is refused outright, never silently reduced to
|
|
147
155
|
* its first member: a route id and an `AdminRepo` id both carry exactly one string, so two rows
|
|
@@ -230,6 +238,18 @@ function pickListFields(
|
|
|
230
238
|
return [...label, ...rest].slice(0, MAX_LIST_FIELDS);
|
|
231
239
|
}
|
|
232
240
|
|
|
241
|
+
/**
|
|
242
|
+
* The label first, whether or not it is indexed: a list's search box IS the label's `contains`
|
|
243
|
+
* filter, and a list an operator cannot search by name is the one they leave for a SQL console.
|
|
244
|
+
* Only a text label qualifies — a `contains` over a uuid is a database error, not an empty result.
|
|
245
|
+
*/
|
|
246
|
+
function filtersOf(fields: readonly AdminField[], labelField: string): readonly AdminField[] {
|
|
247
|
+
const label = fields.filter(
|
|
248
|
+
(field) => field.name === labelField && field.filterKind === 'text' && !field.sensitive,
|
|
249
|
+
);
|
|
250
|
+
return [...label, ...fields.filter((field) => field.filterable && !label.includes(field))];
|
|
251
|
+
}
|
|
252
|
+
|
|
233
253
|
function assertActionsHavePolicies(actions: readonly AdminAction[]): void {
|
|
234
254
|
for (const action of actions) {
|
|
235
255
|
// Widened because the registry is JSON at the boundary: a hand-written action object
|
|
@@ -264,10 +284,18 @@ export function adminResource<Row extends AdminRow = AdminRow>(
|
|
|
264
284
|
const labelField = labelFieldOf(entity, fields, idField, opts.labelField);
|
|
265
285
|
const actions = opts.actions ?? [];
|
|
266
286
|
assertActionsHavePolicies(actions);
|
|
287
|
+
const declared = { name: entity.$name, entity };
|
|
288
|
+
const rowScope = rowScopeOf(declared, opts.rows);
|
|
289
|
+
|
|
290
|
+
const formFields = fields.filter((field) => !field.readOnly && !field.sensitive);
|
|
291
|
+
const secretFields = adminSealedColumnsOf(entity)
|
|
292
|
+
.filter((column) => overrides[column.name]?.hidden !== true)
|
|
293
|
+
.map((column) => secretField(entity, column, overrides[column.name]));
|
|
267
294
|
|
|
268
295
|
const resource: AdminResource<Row> = {
|
|
269
296
|
name: entity.$name,
|
|
270
297
|
path: opts.path ?? `/${entity.$name}`,
|
|
298
|
+
permission: opts.permission ?? entity.$name,
|
|
271
299
|
titleKey: opts.titleKey ?? `admin.${entity.$name}.title`,
|
|
272
300
|
group: opts.group ?? 'admin.group.data',
|
|
273
301
|
idField,
|
|
@@ -275,9 +303,35 @@ export function adminResource<Row extends AdminRow = AdminRow>(
|
|
|
275
303
|
entity,
|
|
276
304
|
fields,
|
|
277
305
|
listFields: pickListFields(fields, labelField, opts.listFields, entity.$name),
|
|
278
|
-
formFields
|
|
279
|
-
|
|
306
|
+
formFields,
|
|
307
|
+
secretFields,
|
|
308
|
+
filters: filtersOf(fields, labelField),
|
|
280
309
|
searchFields: fields.filter((field) => field.searchable),
|
|
310
|
+
columns: computedColumnsOf(declared, opts.columns),
|
|
311
|
+
scopes: scopesOf(declared, opts.scopes, opts.repo),
|
|
312
|
+
...(rowScope === undefined ? {} : { rowScope }),
|
|
313
|
+
sections: layoutOf(
|
|
314
|
+
{
|
|
315
|
+
entity: entity.$name,
|
|
316
|
+
option: 'sections',
|
|
317
|
+
// What the detail page draws: every field but a `sensitive` one. A sealed column is in no
|
|
318
|
+
// list a reader iterates, so it cannot be named here either.
|
|
319
|
+
fields: fields.filter((field) => !field.sensitive),
|
|
320
|
+
excluded:
|
|
321
|
+
'is not a field the detail page draws (hidden, sensitive, sealed, or not a column)',
|
|
322
|
+
},
|
|
323
|
+
opts.sections,
|
|
324
|
+
),
|
|
325
|
+
formGroups: layoutOf(
|
|
326
|
+
{
|
|
327
|
+
entity: entity.$name,
|
|
328
|
+
option: 'formGroups',
|
|
329
|
+
fields: [...formFields, ...secretFields],
|
|
330
|
+
excluded: 'is not a form input (read-only, generated, sensitive, hidden, or not a column)',
|
|
331
|
+
},
|
|
332
|
+
opts.formGroups,
|
|
333
|
+
),
|
|
334
|
+
related: opts.related ?? [],
|
|
281
335
|
defaultSort: opts.defaultSort ?? defaultSortOf(fields, idField),
|
|
282
336
|
// Refused here rather than at the first listing, because `pagination.ts` clamps with
|
|
283
337
|
// `Math.max(1, Math.min(x, 200))` and neither of those validates: a `NaN` survives both, so
|
|
@@ -306,11 +360,7 @@ export function adminResource<Row extends AdminRow = AdminRow>(
|
|
|
306
360
|
/** The bound repo, or the one error that says which resource forgot it. */
|
|
307
361
|
export function repoOf<Row extends AdminRow>(resource: AdminResource<Row>): AdminRepo<Row> {
|
|
308
362
|
if (resource.repo === undefined) {
|
|
309
|
-
throw new
|
|
310
|
-
entity: resource.name,
|
|
311
|
-
known: [],
|
|
312
|
-
cause: `resource "${resource.name}" has no repo bound, so the admin cannot read or write rows`,
|
|
313
|
-
});
|
|
363
|
+
throw new AdminRepoUnboundError({ entity: resource.name, handles: [] });
|
|
314
364
|
}
|
|
315
365
|
return resource.repo;
|
|
316
366
|
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
// The one bridge from the admin's route table to @ultimat3/render's: the `defineRoute` config
|
|
2
|
+
// every admin route is served under, and the declaration that puts those routes in the framework's
|
|
3
|
+
// one route list. No screen is reached from here, so `defineAdmin()` can declare its routes
|
|
4
|
+
// without loading a view — `routes.ts` binds each route to its screen.
|
|
5
|
+
|
|
6
|
+
import { t } from '@ultimat3/i18n';
|
|
7
|
+
import {
|
|
8
|
+
defineRoute,
|
|
9
|
+
type RouteConfig,
|
|
10
|
+
type RouteGuard,
|
|
11
|
+
registerMountedRoutes,
|
|
12
|
+
} from '@ultimat3/render';
|
|
13
|
+
import type { AdminRoute } from './admin';
|
|
14
|
+
import { AdminPageUnguardedError } from './errors';
|
|
15
|
+
|
|
16
|
+
/** What `RouteDescriptor.file` reads for an admin route: the package, since no app file is one. */
|
|
17
|
+
export const ADMIN_ROUTE_FILE = '@ultimat3/admin';
|
|
18
|
+
|
|
19
|
+
export interface AdminRouteDefinition {
|
|
20
|
+
/** The coarse gate — the SAME object `config.policy` carries. */
|
|
21
|
+
readonly policy: RouteGuard;
|
|
22
|
+
readonly config: RouteConfig;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* The author never writes this `defineRoute` call, so the author cannot omit its `policy` —
|
|
27
|
+
* which is the whole mechanism. The coarse gate is `permissions[0]` (always `admin:read`, put
|
|
28
|
+
* there by `permissionsForOperation`/`pagePermissions`); the rest of the pair is decided per
|
|
29
|
+
* request, in `crud.ts` for a resource screen and in `guardedScreen()` for every other one.
|
|
30
|
+
* An empty list is refused here too: `render: 'ssr'` with no policy is a public dashboard.
|
|
31
|
+
*/
|
|
32
|
+
export function adminRouteDefinition(route: AdminRoute): AdminRouteDefinition {
|
|
33
|
+
const permission = route.permissions[0];
|
|
34
|
+
if (permission === undefined) throw new AdminPageUnguardedError({ path: route.path });
|
|
35
|
+
const policy: RouteGuard = { permission };
|
|
36
|
+
return {
|
|
37
|
+
policy,
|
|
38
|
+
config: defineRoute({
|
|
39
|
+
// One mode for every admin route, never an author's choice. `ssr` renders the rows behind
|
|
40
|
+
// the guard, once per request, and `hydrate: 'never'` means the screen ships no JS at all:
|
|
41
|
+
// every control on an admin screen is a link or a native form, so there is nothing to
|
|
42
|
+
// hydrate. `never` is a REFUSAL and not just a default — an `island()` rendered on this
|
|
43
|
+
// route throws `X_ISLAND_NOT_HYDRATED` (`@ultimat3/render`'s `island-collector.ts`), and no
|
|
44
|
+
// admin module declares one.
|
|
45
|
+
render: 'ssr',
|
|
46
|
+
offline: 'network-only',
|
|
47
|
+
hydrate: 'never',
|
|
48
|
+
policy,
|
|
49
|
+
// Never indexed and never followed, whatever the screen answers — a list, a refusal, a 404:
|
|
50
|
+
// an admin URL in a search result is a row id handed to a crawler.
|
|
51
|
+
meta: () => ({ title: t(route.titleKey), robots: { index: false, follow: false } }),
|
|
52
|
+
}),
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Declare an admin's routes to the framework's route table — as MOUNTED routes, each with every
|
|
58
|
+
* permission that gates it — so `x routes`, the `/_x` routes panel, the manifest and `sw.js` list
|
|
59
|
+
* them from the one projection everything else is listed from. Keyed by base path: re-declaring
|
|
60
|
+
* the admin (a save under `x dev`) replaces its set.
|
|
61
|
+
*/
|
|
62
|
+
export function mountAdminRoutes(basePath: string, routes: readonly AdminRoute[]): void {
|
|
63
|
+
registerMountedRoutes(
|
|
64
|
+
{ key: basePath, by: 'defineAdmin', file: ADMIN_ROUTE_FILE, surface: 'app' },
|
|
65
|
+
routes.map((route) => ({
|
|
66
|
+
path: route.path,
|
|
67
|
+
config: adminRouteDefinition(route).config,
|
|
68
|
+
permissions: route.permissions,
|
|
69
|
+
})),
|
|
70
|
+
);
|
|
71
|
+
}
|
package/src/routes.ts
CHANGED
|
@@ -1,83 +1,56 @@
|
|
|
1
|
-
// The one
|
|
2
|
-
// is
|
|
3
|
-
//
|
|
4
|
-
// keeps a stale org's rows out of a service worker cache.
|
|
1
|
+
// The one place a route gets its SCREEN, and the matcher a host asks per request. The config a
|
|
2
|
+
// route is served under — `ssr`, gated, `network-only` — is `route-config.ts`'s; this file binds
|
|
3
|
+
// it to what renders there.
|
|
5
4
|
|
|
6
|
-
import {
|
|
7
|
-
import { defineRoute, type RouteGuard } from '@ultimat3/render';
|
|
5
|
+
import { compilePattern, type RouteConfig, type RouteGuard } from '@ultimat3/render';
|
|
8
6
|
import type { AdminApp, AdminRoute } from './admin';
|
|
9
|
-
import { AdminPagePathInvalidError
|
|
10
|
-
import {
|
|
11
|
-
import type {
|
|
7
|
+
import { AdminPagePathInvalidError } from './errors';
|
|
8
|
+
import { adminRouteDefinition } from './route-config';
|
|
9
|
+
import type { AdminRouteRequest, AdminRouteResponse } from './screen-frame';
|
|
10
|
+
import { screenFor } from './screens';
|
|
12
11
|
|
|
13
12
|
export interface AdminRouteConfig {
|
|
14
13
|
readonly path: string;
|
|
15
14
|
readonly view: AdminRoute['view'];
|
|
16
15
|
readonly entity: string | null;
|
|
17
16
|
readonly permissions: readonly string[];
|
|
18
|
-
/** The GUARDED component of a custom page; `null` for a generated view. */
|
|
19
|
-
readonly component: AdminPageComponent | null;
|
|
20
17
|
/**
|
|
21
18
|
* The coarse gate, already composed — the SAME object `config.policy` carries. It is here, and
|
|
22
|
-
* not read back off `config`, because `RouteConfig.policy` is optional
|
|
23
|
-
* admin URL from its own file has to be able to take the gate without proving it exists.
|
|
19
|
+
* not read back off `config`, because `RouteConfig.policy` is optional.
|
|
24
20
|
*/
|
|
25
21
|
readonly policy: RouteGuard;
|
|
26
|
-
readonly config:
|
|
22
|
+
readonly config: RouteConfig;
|
|
23
|
+
/**
|
|
24
|
+
* Answer one request to this route — a GET, or the form the screen posted back at itself — as
|
|
25
|
+
* a status and a framed body, or the redirect a write that worked earns. Never absent: a
|
|
26
|
+
* generated view renders its rows and a custom page is wrapped, so no route of the table is a
|
|
27
|
+
* path with nothing behind it. The screen decides before it renders; a host only serialises.
|
|
28
|
+
*/
|
|
29
|
+
respond(request: AdminRouteRequest): Promise<AdminRouteResponse>;
|
|
27
30
|
}
|
|
28
31
|
|
|
29
|
-
/**
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
* there by `permissionsForOperation`/`pagePermissions`); the rest of the pair is decided per
|
|
33
|
-
* request by `decideAll`, in `crud.ts` for a generated view and in `page-guard.tsx` for a page.
|
|
34
|
-
* An empty list is refused here too: `render: 'ssr'` with no policy is a public dashboard.
|
|
35
|
-
*/
|
|
36
|
-
export function adminRouteConfig(route: AdminRoute): AdminRouteConfig {
|
|
37
|
-
const permission = route.permissions[0];
|
|
38
|
-
if (permission === undefined) throw new AdminPageUnguardedError({ path: route.path });
|
|
39
|
-
const policy: RouteGuard = { permission };
|
|
40
|
-
|
|
32
|
+
/** One route of the table, with its gate, its served config and its screen. */
|
|
33
|
+
export function adminRouteConfig(app: AdminApp, route: AdminRoute): AdminRouteConfig {
|
|
34
|
+
const { policy, config } = adminRouteDefinition(route);
|
|
41
35
|
return {
|
|
42
36
|
path: route.path,
|
|
43
37
|
view: route.view,
|
|
44
38
|
entity: route.entity,
|
|
45
39
|
permissions: route.permissions,
|
|
46
|
-
|
|
40
|
+
respond: screenFor(app, route),
|
|
47
41
|
policy,
|
|
48
|
-
config
|
|
49
|
-
// One mode for every admin route, never an author's choice. A generated view was `spa` — a
|
|
50
|
-
// shell the browser was supposed to fill — until `spa` was deleted: nothing ever built the
|
|
51
|
-
// client bundle it preloaded and `renderSpa` never read the route's component, so every
|
|
52
|
-
// generated view served an empty `<div id="x-root">`. `ssr` renders the same rows behind the
|
|
53
|
-
// same guard, once per request, and `hydrate: 'never'` means the screen ships no JS at all:
|
|
54
|
-
// an admin view is a pure function of its props (no `createSignal`, no local state), so
|
|
55
|
-
// there is nothing to hydrate. `never` is a REFUSAL and not just a default — an `island()`
|
|
56
|
-
// rendered on this route throws `X_ISLAND_NOT_HYDRATED` (`@ultimat3/render`'s
|
|
57
|
-
// `island-collector.ts`), and no admin module declares one.
|
|
58
|
-
render: 'ssr',
|
|
59
|
-
offline: 'network-only',
|
|
60
|
-
hydrate: 'never',
|
|
61
|
-
policy,
|
|
62
|
-
meta: () => ({ title: t(route.titleKey) }),
|
|
63
|
-
}),
|
|
42
|
+
config,
|
|
64
43
|
};
|
|
65
44
|
}
|
|
66
45
|
|
|
67
|
-
/**
|
|
46
|
+
/** Every route of the table, each with its screen. Auth stays the host app's: see `AdminApp.auth`. */
|
|
68
47
|
export function adminRoutes(app: AdminApp): readonly AdminRouteConfig[] {
|
|
69
|
-
return app.routes.map(adminRouteConfig);
|
|
48
|
+
return app.routes.map((route) => adminRouteConfig(app, route));
|
|
70
49
|
}
|
|
71
50
|
|
|
72
51
|
/**
|
|
73
|
-
* The one route the admin declares for `path` —
|
|
74
|
-
*
|
|
75
|
-
*
|
|
76
|
-
* It exists because the alternative is what the deployed demo shipped: a page file typing
|
|
77
|
-
* `policy: { permission: 'admin:read' }` beside a route table that separately declared
|
|
78
|
-
* `permissionsForOperation(...)` for the same URL. Two declarations of one URL's authz agree until
|
|
79
|
-
* one of them is edited, and nothing was ever going to notice. Reading the gate from here means
|
|
80
|
-
* there is one declaration and one reader.
|
|
52
|
+
* The one route the admin declares for `path` — a read of the table, for a caller that wants a
|
|
53
|
+
* route's gate or its screen without matching a URL.
|
|
81
54
|
*
|
|
82
55
|
* A path the table does not declare is refused rather than answered with a default: a mount with
|
|
83
56
|
* no route is a screen whose permissions nothing composed, which is exactly the shape `pages:`
|
|
@@ -95,5 +68,33 @@ export function adminRouteFor(app: AdminApp, path: string): AdminRouteConfig {
|
|
|
95
68
|
'or declare this one in `pages:` on defineAdmin()',
|
|
96
69
|
});
|
|
97
70
|
}
|
|
98
|
-
return adminRouteConfig(route);
|
|
71
|
+
return adminRouteConfig(app, route);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export interface AdminRouteMatch {
|
|
75
|
+
readonly route: AdminRouteConfig;
|
|
76
|
+
readonly params: Readonly<Record<string, string>>;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* The route a REQUEST's pathname names, with its params — what a host mounting the admin under
|
|
81
|
+
* one catch-all asks per request. `null` is "no admin screen lives there", which the host answers
|
|
82
|
+
* 404. The most specific pattern wins, so `/posts/new` is the create form and never the detail
|
|
83
|
+
* of a row called `new`. Matched against the table as it is NOW, so a page added in a save is
|
|
84
|
+
* served without the host re-reading anything.
|
|
85
|
+
*/
|
|
86
|
+
export function adminRouteMatch(app: AdminApp, pathname: string): AdminRouteMatch | null {
|
|
87
|
+
let best: { route: AdminRoute; params: Record<string, string>; specificity: number } | null =
|
|
88
|
+
null;
|
|
89
|
+
for (const route of app.routes) {
|
|
90
|
+
const pattern = compilePattern(route.path);
|
|
91
|
+
const found = pattern.regex.exec(pathname);
|
|
92
|
+
if (found === null || (best !== null && best.specificity >= pattern.specificity)) continue;
|
|
93
|
+
const params: Record<string, string> = {};
|
|
94
|
+
pattern.keys.forEach((key, index) => {
|
|
95
|
+
params[key] = decodeURIComponent(found[index + 1] ?? '');
|
|
96
|
+
});
|
|
97
|
+
best = { route, params, specificity: pattern.specificity };
|
|
98
|
+
}
|
|
99
|
+
return best === null ? null : { route: adminRouteConfig(app, best.route), params: best.params };
|
|
99
100
|
}
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
// `rows`, applied to a WRITE: the row a create or an update would leave behind must satisfy the
|
|
2
|
+
// acting actor's row scope, or the write is refused before the repo is asked for it. The one place
|
|
3
|
+
// this package evaluates a predicate itself — a row that does not exist yet cannot be asked of the
|
|
4
|
+
// repo, which is how every READ answers the same question (`list-scope.ts`, `findRow`).
|
|
5
|
+
|
|
6
|
+
import type { AdminActor, AdminDecision } from './authz';
|
|
7
|
+
import type { AdminFilter, AdminRow } from './registry';
|
|
8
|
+
import type { AdminResource } from './resource';
|
|
9
|
+
|
|
10
|
+
/** The key a write whose result would sit outside the actor's row scope is refused with. */
|
|
11
|
+
export const ROW_OUT_OF_SCOPE_REASON = 'admin.error.row-out-of-scope';
|
|
12
|
+
|
|
13
|
+
export interface OutsideRowScope {
|
|
14
|
+
readonly field: string;
|
|
15
|
+
readonly op: AdminFilter['op'];
|
|
16
|
+
/**
|
|
17
|
+
* `false` when the predicate could not be decided exactly here — an ordering over text is a
|
|
18
|
+
* collation's to answer — and the write is refused for that reason rather than for a mismatch.
|
|
19
|
+
*/
|
|
20
|
+
readonly decidable: boolean;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const absent = (value: unknown): boolean => value === null || value === undefined;
|
|
24
|
+
|
|
25
|
+
/** An instant as milliseconds, whichever of the two shapes it travels in; `undefined` otherwise. */
|
|
26
|
+
const instantOf = (value: unknown): number | undefined => {
|
|
27
|
+
if (value instanceof Date) return value.getTime();
|
|
28
|
+
if (typeof value !== 'string' || !/^\d{4}-\d{2}-\d{2}T/.test(value)) return undefined;
|
|
29
|
+
const at = Date.parse(value);
|
|
30
|
+
return Number.isNaN(at) ? undefined : at;
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
/** Equal as the column's value: a `Date` against the ISO text a filter carries is one instant. */
|
|
34
|
+
const equal = (held: unknown, wanted: unknown): boolean => {
|
|
35
|
+
if (held instanceof Date || wanted instanceof Date) {
|
|
36
|
+
const a = instantOf(held);
|
|
37
|
+
return a !== undefined && a === instantOf(wanted);
|
|
38
|
+
}
|
|
39
|
+
return typeof held === 'object' ? false : String(held) === String(wanted);
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
/** `-1 | 0 | 1` for two numbers or two instants; `undefined` for anything a collation orders. */
|
|
43
|
+
const ordered = (held: unknown, wanted: unknown): number | undefined => {
|
|
44
|
+
const pair =
|
|
45
|
+
typeof held === 'number' && typeof wanted === 'number'
|
|
46
|
+
? ([held, wanted] as const)
|
|
47
|
+
: held instanceof Date
|
|
48
|
+
? ([instantOf(held), instantOf(wanted)] as const)
|
|
49
|
+
: undefined;
|
|
50
|
+
const [a, b] = pair ?? [];
|
|
51
|
+
if (a === undefined || b === undefined || Number.isNaN(a) || Number.isNaN(b)) return undefined;
|
|
52
|
+
return a < b ? -1 : a > b ? 1 : 0;
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
/** `true` in scope, `false` out of it, `undefined` when this file cannot say. */
|
|
56
|
+
function satisfies(row: AdminRow, filter: AdminFilter): boolean | undefined {
|
|
57
|
+
const held = Object.hasOwn(row, filter.field) ? row[filter.field] : undefined;
|
|
58
|
+
if (filter.op === 'is-null') return absent(held) === (filter.value !== false);
|
|
59
|
+
// SQL's three-valued logic: a NULL on either side matches nothing, `neq` included.
|
|
60
|
+
if (absent(held) || absent(filter.value)) return false;
|
|
61
|
+
switch (filter.op) {
|
|
62
|
+
case 'eq':
|
|
63
|
+
return equal(held, filter.value);
|
|
64
|
+
case 'neq':
|
|
65
|
+
return !equal(held, filter.value);
|
|
66
|
+
case 'in':
|
|
67
|
+
return Array.isArray(filter.value) && filter.value.some((one) => equal(held, one));
|
|
68
|
+
case 'contains':
|
|
69
|
+
// `LIKE` is case-sensitive and collation-free for a literal substring.
|
|
70
|
+
return typeof held === 'string' ? held.includes(String(filter.value)) : undefined;
|
|
71
|
+
default: {
|
|
72
|
+
const order = ordered(held, filter.value);
|
|
73
|
+
if (order === undefined) return undefined;
|
|
74
|
+
if (filter.op === 'gt') return order > 0;
|
|
75
|
+
if (filter.op === 'gte') return order >= 0;
|
|
76
|
+
if (filter.op === 'lt') return order < 0;
|
|
77
|
+
return order <= 0;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** The first predicate `row` does not satisfy, or `null` when it satisfies every one. */
|
|
83
|
+
export function outsideRowScope(
|
|
84
|
+
row: AdminRow,
|
|
85
|
+
where: readonly AdminFilter[],
|
|
86
|
+
): OutsideRowScope | null {
|
|
87
|
+
for (const filter of where) {
|
|
88
|
+
const verdict = satisfies(row, filter);
|
|
89
|
+
if (verdict === true) continue;
|
|
90
|
+
return { field: filter.field, op: filter.op, decidable: verdict !== undefined };
|
|
91
|
+
}
|
|
92
|
+
return null;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* What a write would leave, judged against the acting actor's scope. `null` for a resource that
|
|
97
|
+
* declares no `rows` — and for an actor whose scope is the empty conjunction, who sees every row.
|
|
98
|
+
*
|
|
99
|
+
* `touched` narrows an UPDATE to the predicates over the fields it writes: the stored row is
|
|
100
|
+
* already inside the scope (the repo said so when it was loaded), so only a field the patch
|
|
101
|
+
* changes can move it out — and a predicate this file cannot decide is then never asked about a
|
|
102
|
+
* column nobody touched.
|
|
103
|
+
*/
|
|
104
|
+
export function writeOutsideScope(
|
|
105
|
+
resource: AdminResource,
|
|
106
|
+
actor: AdminActor,
|
|
107
|
+
candidate: AdminRow,
|
|
108
|
+
touched?: readonly string[],
|
|
109
|
+
): OutsideRowScope | null {
|
|
110
|
+
if (resource.rowScope === undefined) return null;
|
|
111
|
+
const scope = resource.rowScope(actor);
|
|
112
|
+
return outsideRowScope(
|
|
113
|
+
candidate,
|
|
114
|
+
touched === undefined ? scope : scope.filter((filter) => touched.includes(filter.field)),
|
|
115
|
+
);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** The refusal a write outside the scope is answered and audited with. */
|
|
119
|
+
export function outOfScopeDecision(permission: string, outside: OutsideRowScope): AdminDecision {
|
|
120
|
+
return {
|
|
121
|
+
allowed: false,
|
|
122
|
+
permission,
|
|
123
|
+
reason: ROW_OUT_OF_SCOPE_REASON,
|
|
124
|
+
trace: [
|
|
125
|
+
outside.decidable
|
|
126
|
+
? `rows: ${outside.field} ${outside.op} is not satisfied by the row this write would leave`
|
|
127
|
+
: `rows: ${outside.field} ${outside.op} cannot be decided before the write, so it is refused`,
|
|
128
|
+
],
|
|
129
|
+
};
|
|
130
|
+
}
|