@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.
Files changed (46) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +115 -0
  3. package/package.json +48 -0
  4. package/src/action-gate.ts +202 -0
  5. package/src/actions.tsx +94 -0
  6. package/src/admin.ts +186 -0
  7. package/src/ai-panes.ts +139 -0
  8. package/src/audit.ts +183 -0
  9. package/src/authz.ts +129 -0
  10. package/src/crud.ts +278 -0
  11. package/src/detail.tsx +121 -0
  12. package/src/dev/data.ts +344 -0
  13. package/src/dev/facts.ts +180 -0
  14. package/src/dev/index.ts +47 -0
  15. package/src/dev/panel-cache.ts +47 -0
  16. package/src/dev/panel-db.ts +59 -0
  17. package/src/dev/panel-jobs.ts +66 -0
  18. package/src/dev/panel-live.ts +46 -0
  19. package/src/dev/panel-mail.ts +51 -0
  20. package/src/dev/panel-manifest.ts +39 -0
  21. package/src/dev/panel-policy.ts +61 -0
  22. package/src/dev/panel-routes.ts +43 -0
  23. package/src/dev/panel-timeline.ts +82 -0
  24. package/src/dev/panel.ts +58 -0
  25. package/src/dev/server.ts +189 -0
  26. package/src/entity-columns.ts +95 -0
  27. package/src/errors.ts +145 -0
  28. package/src/fields.ts +151 -0
  29. package/src/form.tsx +97 -0
  30. package/src/index.ts +214 -0
  31. package/src/layout.tsx +104 -0
  32. package/src/list.tsx +120 -0
  33. package/src/mcp-tools.ts +201 -0
  34. package/src/mcp.ts +304 -0
  35. package/src/nav.ts +97 -0
  36. package/src/pagination.ts +152 -0
  37. package/src/permissions.ts +92 -0
  38. package/src/policy-bridge.ts +64 -0
  39. package/src/registry.ts +181 -0
  40. package/src/resource.ts +321 -0
  41. package/src/routes.ts +35 -0
  42. package/src/search.ts +108 -0
  43. package/src/theme.ts +59 -0
  44. package/src/validate.ts +65 -0
  45. package/src/widget-value.ts +217 -0
  46. package/src/widgets.tsx +262 -0
@@ -0,0 +1,64 @@
1
+ // The ONLY place the admin talks to @ultimat3/policy. Everything else in the package depends
2
+ // on the `AdminAuthz` interface, so there is exactly one adaptation point between the app's
3
+ // policies and the dashboard — and no second authz implementation can grow next to it.
4
+
5
+ import { type Actor, userActor } from '@ultimat3/core';
6
+ import { definePermissions, evaluate, type Policy } from '@ultimat3/policy';
7
+ import { type AdminActor, type AdminAuthz, type AdminDecision, allowed, denied } from './authz';
8
+ import { ADMIN_PERMISSIONS } from './permissions';
9
+
10
+ /** The admin's own permission set, registered with the policy layer at import time. */
11
+ export const adminPermissions = definePermissions(ADMIN_PERMISSIONS);
12
+
13
+ /**
14
+ * The admin carries its own actor shape; @ultimat3/policy evaluates core's `Actor`. The
15
+ * mapping lives here because this file is the only one allowed to speak to the policy layer.
16
+ */
17
+ const policyActor = (actor: AdminActor): Actor =>
18
+ userActor({ id: actor.id, roles: actor.roles ?? [] });
19
+
20
+ /**
21
+ * `evaluate()`'s result is read structurally: the policy layer owns its own decision type,
22
+ * and the admin only needs the verdict, a reason key, and the trace it prints in `/_x`.
23
+ */
24
+ function readDecision(permission: string, result: unknown): AdminDecision {
25
+ const bag = (typeof result === 'object' && result !== null ? result : {}) as {
26
+ allowed?: unknown;
27
+ reason?: unknown;
28
+ trace?: unknown;
29
+ };
30
+ const verdict = result === true || bag.allowed === true;
31
+ const reason = typeof bag.reason === 'string' ? bag.reason : 'admin.policy.evaluated';
32
+ const trace = Array.isArray(bag.trace) ? bag.trace.map((line) => String(line)) : [];
33
+ return verdict ? allowed(permission, reason, trace) : denied(permission, reason, trace);
34
+ }
35
+
36
+ export interface PolicyAuthzInput {
37
+ /** Permission name → the policy that decides it. `describeActions()` supplies these. */
38
+ readonly policies: Readonly<Record<string, Policy>>;
39
+ }
40
+
41
+ /**
42
+ * Closed by default: a permission with no registered policy is denied, with the fix in the
43
+ * trace. An admin that fails open is worse than an admin that fails visibly.
44
+ */
45
+ export function policyAuthz(input: PolicyAuthzInput): AdminAuthz {
46
+ return {
47
+ decide({ permission, actor, subject }): AdminDecision {
48
+ const policy = input.policies[permission];
49
+ if (policy === undefined) {
50
+ return denied(permission, 'admin.policy.missing', [
51
+ `no policy registered for "${permission}"`,
52
+ `fix: definePermissions({ '${permission}': … }) or can('${permission}') on the action`,
53
+ ]);
54
+ }
55
+ // `EvaluateArgs` carries exactly one payload. An admin subject with no action input IS
56
+ // that payload: a row-level rule has nothing but the entity and id to decide on.
57
+ const payload = subject?.input ?? subject;
58
+ return readDecision(
59
+ permission,
60
+ evaluate(policy, { actor: policyActor(actor), input: payload }),
61
+ );
62
+ },
63
+ };
64
+ }
@@ -0,0 +1,181 @@
1
+ // The structural subset of a registered entity the admin reads, and the query IR it speaks.
2
+ //
3
+ // WHY a subset instead of importing `Entity` itself: the admin must keep deriving after an
4
+ // entity gains a column kind it has never heard of, so the surface is named — `$columns`,
5
+ // `$primaryKey`, `$describe()` — and one file changes when it grows. `RegisteredEntity` below
6
+ // is the compile-time proof that a real `entity()` result satisfies it; it is checked by
7
+ // `tsc`, not asserted in a comment, because the admin used to read fields no entity had.
8
+
9
+ import type { Entity, Repo } from '@ultimat3/entity';
10
+
11
+ /**
12
+ * What the author declared about one column — `@ultimat3/entity`'s `ColumnMeta`, narrowed to
13
+ * what the admin reads. `kind` stays `string`: a kind with no widget is a loud
14
+ * X_ADMIN_FIELD_UNSUPPORTED at derive time, never a type error in the app that declared it.
15
+ */
16
+ export interface AdminColumnMeta {
17
+ /** `uuid` · `text` · `char` · `boolean` · `integer` · `bigint` · `timestamptz` · `jsonb` · `money`. */
18
+ readonly kind: string;
19
+ readonly notNull: boolean;
20
+ readonly primaryKey: boolean;
21
+ readonly unique: boolean;
22
+ /** Indexed columns become the list filters — a filter with no index is a table scan. */
23
+ readonly index: boolean;
24
+ /** Declared max length. A bounded string is one line; an unbounded one is prose. */
25
+ readonly length?: number;
26
+ /** A closed set — `enumerated`, `locale`, `tz`. Forces the `select` widget. */
27
+ readonly values?: readonly string[];
28
+ /**
29
+ * `kind: 'generated'` means the DB or the framework writes it (`id`, `createdAt`), which is
30
+ * what makes a field read-only. A literal `.default('free')` is a starting value, not that.
31
+ */
32
+ readonly default?: { readonly kind: string };
33
+ readonly onUpdate?: { readonly kind: string };
34
+ /**
35
+ * A thunk, because schema modules import each other in a cycle. The admin never calls it —
36
+ * the column→entity binding that resolves it is private to @ultimat3/entity, so `$describe()`
37
+ * hands back the resolved target — but its presence is what makes the column a foreign key.
38
+ */
39
+ readonly references?: () => unknown;
40
+ }
41
+
42
+ /** One column of a registered entity. An `@ultimat3/entity` `Column` satisfies this. */
43
+ export interface AdminColumn {
44
+ readonly $meta: AdminColumnMeta;
45
+ }
46
+
47
+ /** One column of `$describe()` output. Money is the one property that becomes two of these. */
48
+ export interface AdminColumnDescription {
49
+ /** The property key on the row, which is what the admin renders and filters by. */
50
+ readonly property: string;
51
+ /** `"<entity>.<column>"` for a foreign key, else `null`. Already resolved. */
52
+ readonly references: string | null;
53
+ }
54
+
55
+ /** The plain-data projection of an entity. The admin reads it for FK targets only. */
56
+ export interface AdminEntityDescription {
57
+ readonly columns: readonly AdminColumnDescription[];
58
+ }
59
+
60
+ export interface AdminEntity {
61
+ readonly $name: string;
62
+ /** Property keys of the primary key. Composite for a join table; the admin addresses rows
63
+ * by the first, which is the only column a single-id URL and an `AdminRepo` can carry. */
64
+ readonly $primaryKey: readonly string[];
65
+ readonly $columns: Readonly<Record<string, AdminColumn>>;
66
+ /** The Standard Schema the entity validates with; forms hand input straight to it. */
67
+ readonly $schema: unknown;
68
+ /** Resolves foreign-key targets. See `entity-columns.ts` for what the admin takes from it. */
69
+ $describe(): AdminEntityDescription;
70
+ }
71
+
72
+ /** `T` must satisfy `Surface` or this does not compile. The whole point of the two below. */
73
+ type Satisfies<Surface, T extends Surface> = T;
74
+
75
+ /**
76
+ * The claim, checked: a real `entity()` result IS an `AdminEntity`. The day `entity()` renames
77
+ * a member, `tsc` fails here — instead of `Object.keys(entity.columns)` failing in the first
78
+ * request the dashboard serves.
79
+ */
80
+ export type RegisteredEntity = Satisfies<AdminEntity, Entity<AdminRow>>;
81
+
82
+ /**
83
+ * A repo as `@ultimat3/entity` registers it — deliberately NOT claimed to be an `AdminRepo`:
84
+ * the verbs differ (`findMany`/`insert` vs `list`/`create`) and its cursor is an opaque signed
85
+ * string where the admin speaks a keyset bound, so the host binds an adapter over it.
86
+ */
87
+ export type RegisteredRepo = Repo;
88
+
89
+ export type FilterOp = 'eq' | 'neq' | 'contains' | 'gt' | 'lt' | 'in' | 'is-null';
90
+
91
+ export interface AdminFilter {
92
+ readonly field: string;
93
+ readonly op: FilterOp;
94
+ readonly value: string | number | boolean | null | readonly string[];
95
+ }
96
+
97
+ export interface AdminSort {
98
+ readonly field: string;
99
+ readonly direction: 'asc' | 'desc';
100
+ }
101
+
102
+ export interface KeysetBound {
103
+ readonly field: string;
104
+ readonly value: string;
105
+ readonly id: string;
106
+ }
107
+
108
+ /**
109
+ * The read query the admin sends a repo. There is no `offset` and there never will be:
110
+ * offset pagination re-scans on every page and skips rows when the table is written to
111
+ * while an operator is paging through it.
112
+ */
113
+ export interface AdminListQuery {
114
+ readonly where?: readonly AdminFilter[];
115
+ readonly sort: AdminSort;
116
+ /** Rows to return. The repo is asked for `limit + 1` to detect a next page. */
117
+ readonly limit: number;
118
+ /**
119
+ * Keyset bound: the sort-field value of the last row of the previous page, plus that
120
+ * row's id as the tie-break so pages stay stable when the sort column has duplicates.
121
+ */
122
+ readonly after?: KeysetBound;
123
+ readonly before?: KeysetBound;
124
+ }
125
+
126
+ export interface AdminRepo<Row> {
127
+ list(query: AdminListQuery): Promise<readonly Row[]>;
128
+ find(id: string): Promise<Row | null>;
129
+ create(input: Readonly<Record<string, unknown>>): Promise<Row>;
130
+ update(id: string, patch: Readonly<Record<string, unknown>>): Promise<Row>;
131
+ destroy(id: string): Promise<void>;
132
+ count?(where?: readonly AdminFilter[]): Promise<number>;
133
+ }
134
+
135
+ /** What the admin runs an action with. The action's own `ctx` is richer; this is the slice
136
+ * the admin can honestly provide from an HTTP request or an MCP call. */
137
+ export interface AdminActionCtx {
138
+ readonly requestId: string;
139
+ readonly actorId: string;
140
+ readonly locale: string;
141
+ readonly timeZone: string;
142
+ }
143
+
144
+ /**
145
+ * A registered `action` as the admin surfaces it. `permission` is not optional: an action
146
+ * with no policy is an open door, and the admin refuses to render one
147
+ * (X_ADMIN_POLICY_MISSING).
148
+ */
149
+ export interface AdminAction<Input = Readonly<Record<string, unknown>>, Output = unknown> {
150
+ readonly name: string;
151
+ readonly permission: string;
152
+ /** The entity the button belongs to. Absent = a global action in the toolbar. */
153
+ readonly entity?: string;
154
+ readonly destructive?: boolean;
155
+ readonly labelKey?: string;
156
+ /** Mirrors the action's own `mcp` block; the admin MCP surface honours `expose`. */
157
+ readonly mcp?: { readonly expose?: boolean; readonly description?: string };
158
+ /** The action's Standard Schema, handed to the form and to the MCP tool definition. */
159
+ readonly input?: unknown;
160
+ handle(args: { input: Input; ctx: AdminActionCtx }): Promise<Output>;
161
+ }
162
+
163
+ /** A registered `job` as the admin's Jobs page needs it — `describeJobs()` output. */
164
+ export interface AdminJobSummary {
165
+ readonly name: string;
166
+ readonly queue?: string;
167
+ readonly steps?: readonly string[];
168
+ readonly retry?: { readonly attempts: number; readonly backoff: string };
169
+ }
170
+
171
+ /** A row as the admin handles it: an opaque record it reads fields out of by name. */
172
+ export type AdminRow = Readonly<Record<string, unknown>>;
173
+
174
+ export function readField(row: AdminRow, field: string): unknown {
175
+ return row[field];
176
+ }
177
+
178
+ export function rowId(row: AdminRow, idField: string): string {
179
+ const value = row[idField];
180
+ return typeof value === 'string' ? value : String(value ?? '');
181
+ }
@@ -0,0 +1,321 @@
1
+ // Entity registry → a working CRUD resource, with nothing configured. Columns become
2
+ // fields, indexed columns become filters, text columns become search, `createdAt` becomes
3
+ // the default sort, i18n keys become labels. Every derived decision is overridable per
4
+ // field, but the zero-config result is the one the generator emits and the one the docs show.
5
+
6
+ import { type AdminColumnFacts, adminColumnsOf } from './entity-columns';
7
+ import {
8
+ AdminEntityUnknownError,
9
+ AdminFieldUnsupportedError,
10
+ AdminPolicyMissingError,
11
+ } from './errors';
12
+ import {
13
+ type AdminField,
14
+ type AdminFieldType,
15
+ type AdminWidget,
16
+ fieldTypeFromColumn,
17
+ filterable,
18
+ listable,
19
+ searchable,
20
+ sortable,
21
+ widgetFor,
22
+ } from './fields';
23
+ import { ADMIN_OPERATIONS, type AdminOperation } from './permissions';
24
+ import type { AdminAction, AdminEntity, AdminRepo, AdminRow, AdminSort } from './registry';
25
+
26
+ /** Six columns is what fits a laptop viewport without horizontal scroll. */
27
+ const MAX_LIST_FIELDS = 6;
28
+ const DEFAULT_PAGE_SIZE = 25;
29
+ const LABEL_CANDIDATES = ['name', 'title', 'slug', 'label', 'email'] as const;
30
+ const SORT_CANDIDATES = ['createdAt', 'created_at', 'updatedAt', 'updated_at'] as const;
31
+
32
+ export interface AdminFieldOverride {
33
+ readonly type?: AdminFieldType;
34
+ readonly widget?: AdminWidget;
35
+ readonly labelKey?: string;
36
+ /** Out of every surface: list, detail, form, MCP schema. */
37
+ readonly hidden?: boolean;
38
+ readonly inList?: boolean;
39
+ readonly readOnly?: boolean;
40
+ readonly required?: boolean;
41
+ readonly sensitive?: boolean;
42
+ readonly filterable?: boolean;
43
+ readonly sortable?: boolean;
44
+ readonly searchable?: boolean;
45
+ readonly currency?: string;
46
+ readonly values?: readonly string[];
47
+ readonly relation?: { readonly entity: string; readonly labelField?: string };
48
+ }
49
+
50
+ export interface AdminResourceOptions<Row extends AdminRow = AdminRow> {
51
+ readonly repo?: AdminRepo<Row>;
52
+ readonly path?: string;
53
+ readonly titleKey?: string;
54
+ readonly group?: string;
55
+ /**
56
+ * The field that names a row in a reference widget, a search hit, a breadcrumb. An entity
57
+ * declares no such thing, so this is the only way to say it out loud; omitted, it is derived
58
+ * from the conventional names below.
59
+ */
60
+ readonly labelField?: string;
61
+ readonly fields?: Readonly<Record<string, AdminFieldOverride>>;
62
+ /** Explicit list columns, in order. Omit to let the derivation pick. */
63
+ readonly listFields?: readonly string[];
64
+ readonly defaultSort?: AdminSort;
65
+ readonly pageSize?: number;
66
+ readonly operations?: readonly AdminOperation[];
67
+ readonly actions?: readonly AdminAction[];
68
+ }
69
+
70
+ export interface AdminResource<Row extends AdminRow = AdminRow> {
71
+ readonly name: string;
72
+ /** Mount-relative, e.g. `/posts`. */
73
+ readonly path: string;
74
+ readonly titleKey: string;
75
+ readonly group: string;
76
+ readonly idField: string;
77
+ /** The column that names a row in a reference widget, a search hit, or a breadcrumb. */
78
+ readonly labelField: string;
79
+ readonly entity: AdminEntity;
80
+ readonly fields: readonly AdminField[];
81
+ readonly listFields: readonly AdminField[];
82
+ readonly formFields: readonly AdminField[];
83
+ readonly filters: readonly AdminField[];
84
+ readonly searchFields: readonly AdminField[];
85
+ readonly defaultSort: AdminSort;
86
+ readonly pageSize: number;
87
+ readonly operations: readonly AdminOperation[];
88
+ readonly actions: readonly AdminAction[];
89
+ readonly repo?: AdminRepo<Row>;
90
+ field(name: string): AdminField;
91
+ }
92
+
93
+ const pluralize = (name: string): string => {
94
+ if (/[sxz]$/.test(name) || /(ch|sh)$/.test(name)) return `${name}es`;
95
+ if (/[^aeiou]y$/.test(name)) return `${name.slice(0, -1)}ies`;
96
+ return `${name}s`;
97
+ };
98
+
99
+ function deriveField(
100
+ entity: AdminEntity,
101
+ column: AdminColumnFacts,
102
+ override: AdminFieldOverride | undefined,
103
+ ): AdminField {
104
+ const name = column.name;
105
+ const type = override?.type ?? fieldTypeFromColumn(entity.$name, name, column);
106
+ const widget = override?.widget ?? widgetFor(type);
107
+ const generated = column.generated || column.primaryKey;
108
+ // An entity has no notion of a secret column, and a currency belongs to the value rather
109
+ // than the column, so both are admin-side declarations or they are absent. Never guessed.
110
+ const sensitive = override?.sensitive ?? false;
111
+ const currency = override?.currency;
112
+ const values = override?.values ?? column.values;
113
+ const relation =
114
+ override?.relation ??
115
+ // The FK's value IS the target column's value, so that column is the honest default label.
116
+ (column.references === undefined
117
+ ? undefined
118
+ : { entity: column.references.entity, labelField: column.references.column });
119
+
120
+ return {
121
+ entity: entity.$name,
122
+ name,
123
+ type,
124
+ widget,
125
+ labelKey: override?.labelKey ?? `admin.${entity.$name}.field.${name}`,
126
+ required: override?.required ?? (!column.nullable && !generated),
127
+ readOnly: override?.readOnly ?? generated,
128
+ sensitive,
129
+ inList: override?.inList ?? (listable(type) && !sensitive),
130
+ filterable: override?.filterable ?? filterable(type, column),
131
+ sortable: override?.sortable ?? sortable(type, column),
132
+ // Generated columns are excluded from search: an id is found by exact lookup, and a
133
+ // `contains` over a uuid column is a scan that returns nothing useful.
134
+ searchable: override?.searchable ?? (searchable(type) && !sensitive && !generated),
135
+ ...(values === undefined ? {} : { values }),
136
+ ...(currency === undefined ? {} : { currency }),
137
+ ...(relation === undefined ? {} : { relation }),
138
+ };
139
+ }
140
+
141
+ /**
142
+ * The declared key wins — but a composite one is refused outright, never silently reduced to
143
+ * its first member: a route id and an `AdminRepo` id both carry exactly one string, so two rows
144
+ * sharing only that first member would become indistinguishable for read, update and delete.
145
+ */
146
+ function idFieldOf(entity: AdminEntity, columns: readonly AdminColumnFacts[]): string {
147
+ if (entity.$primaryKey.length > 1) {
148
+ throw new AdminFieldUnsupportedError({
149
+ entity: entity.$name,
150
+ field: entity.$primaryKey.join(','),
151
+ cause:
152
+ 'has a composite primary key; the admin addresses a row by a single id and cannot yet carry every key part',
153
+ fix: 'give the entity a single-column id, or wait for composite-key support in AdminRepo',
154
+ });
155
+ }
156
+ const declared = entity.$primaryKey[0] ?? columns.find((column) => column.primaryKey)?.name;
157
+ if (declared !== undefined) return declared;
158
+ if (columns.some((column) => column.name === 'id')) return 'id';
159
+ throw new AdminEntityUnknownError({
160
+ entity: entity.$name,
161
+ known: columns.map((column) => column.name),
162
+ cause: `entity "${entity.$name}" has no primary key and no "id" column, so the admin cannot address a row`,
163
+ });
164
+ }
165
+
166
+ function labelFieldOf(
167
+ entity: AdminEntity,
168
+ fields: readonly AdminField[],
169
+ idField: string,
170
+ declared: string | undefined,
171
+ ): string {
172
+ if (declared !== undefined) {
173
+ // A label nobody can read is a table of ids and a search that returns them: say so here,
174
+ // not by silently falling back to the id column three surfaces later.
175
+ if (!fields.some((field) => field.name === declared)) {
176
+ throw new AdminFieldUnsupportedError({
177
+ entity: entity.$name,
178
+ field: declared,
179
+ cause: 'named as labelField but not a visible field of this resource',
180
+ fix: `adminResource(${entity.$name}, { labelField: '${idField}' }) # a field that is not hidden`,
181
+ });
182
+ }
183
+ return declared;
184
+ }
185
+ for (const candidate of LABEL_CANDIDATES) {
186
+ if (fields.some((field) => field.name === candidate)) return candidate;
187
+ }
188
+ const firstText = fields.find(
189
+ (field) => (field.type === 'text' || field.type === 'textarea') && field.name !== idField,
190
+ );
191
+ return firstText?.name ?? idField;
192
+ }
193
+
194
+ function defaultSortOf(fields: readonly AdminField[], idField: string): AdminSort {
195
+ for (const candidate of SORT_CANDIDATES) {
196
+ const field = fields.find((f) => f.name === candidate && f.sortable);
197
+ if (field !== undefined) return { field: field.name, direction: 'desc' };
198
+ }
199
+ return { field: idField, direction: 'desc' };
200
+ }
201
+
202
+ function pickListFields(
203
+ fields: readonly AdminField[],
204
+ labelField: string,
205
+ explicit: readonly string[] | undefined,
206
+ entityName: string,
207
+ ): readonly AdminField[] {
208
+ if (explicit !== undefined) {
209
+ return explicit.map((name) => {
210
+ const field = fields.find((f) => f.name === name);
211
+ if (field === undefined) {
212
+ throw new AdminFieldUnsupportedError({
213
+ entity: entityName,
214
+ field: name,
215
+ cause: 'listed in listFields but not a column of the entity',
216
+ fix: `remove "${name}" from listFields, or add the column with x g migration`,
217
+ });
218
+ }
219
+ return field;
220
+ });
221
+ }
222
+ const label = fields.filter((field) => field.name === labelField);
223
+ const rest = fields.filter((field) => field.inList && field.name !== labelField);
224
+ return [...label, ...rest].slice(0, MAX_LIST_FIELDS);
225
+ }
226
+
227
+ function assertActionsHavePolicies(actions: readonly AdminAction[]): void {
228
+ for (const action of actions) {
229
+ // Widened because the registry is JSON at the boundary: a hand-written action object
230
+ // that forgot `policy` reaches us with the field absent, not just empty.
231
+ const permission: string | undefined = action.permission;
232
+ if (permission === undefined || permission.trim() === '') {
233
+ throw new AdminPolicyMissingError({ subject: action.name, kind: 'action' });
234
+ }
235
+ }
236
+ }
237
+
238
+ /** Derive list / detail / create / edit / delete from one registered entity. */
239
+ export function adminResource<Row extends AdminRow = AdminRow>(
240
+ entity: AdminEntity,
241
+ opts: AdminResourceOptions<Row> = {},
242
+ ): AdminResource<Row> {
243
+ const columns = adminColumnsOf(entity);
244
+ if (columns.length === 0) {
245
+ throw new AdminEntityUnknownError({
246
+ entity: entity.$name,
247
+ known: [],
248
+ cause: `entity "${entity.$name}" declares no columns`,
249
+ });
250
+ }
251
+
252
+ const idField = idFieldOf(entity, columns);
253
+ const overrides = opts.fields ?? {};
254
+ const fields = columns
255
+ .filter((column) => overrides[column.name]?.hidden !== true)
256
+ .map((column) => deriveField(entity, column, overrides[column.name]));
257
+
258
+ const labelField = labelFieldOf(entity, fields, idField, opts.labelField);
259
+ const actions = opts.actions ?? [];
260
+ assertActionsHavePolicies(actions);
261
+
262
+ const resource: AdminResource<Row> = {
263
+ name: entity.$name,
264
+ path: opts.path ?? `/${pluralize(entity.$name)}`,
265
+ titleKey: opts.titleKey ?? `admin.${entity.$name}.title`,
266
+ group: opts.group ?? 'admin.group.data',
267
+ idField,
268
+ labelField,
269
+ entity,
270
+ fields,
271
+ listFields: pickListFields(fields, labelField, opts.listFields, entity.$name),
272
+ formFields: fields.filter((field) => !field.readOnly && !field.sensitive),
273
+ filters: fields.filter((field) => field.filterable),
274
+ searchFields: fields.filter((field) => field.searchable),
275
+ defaultSort: opts.defaultSort ?? defaultSortOf(fields, idField),
276
+ pageSize: opts.pageSize ?? DEFAULT_PAGE_SIZE,
277
+ operations: opts.operations ?? ADMIN_OPERATIONS,
278
+ actions,
279
+ ...(opts.repo === undefined ? {} : { repo: opts.repo }),
280
+ field(name: string): AdminField {
281
+ const field = fields.find((f) => f.name === name);
282
+ if (field === undefined) {
283
+ throw new AdminFieldUnsupportedError({
284
+ entity: entity.$name,
285
+ field: name,
286
+ cause: 'not a field of this resource (hidden, or not a column)',
287
+ fix: `x manifest # then check adminResource(${entity.$name}).fields`,
288
+ });
289
+ }
290
+ return field;
291
+ },
292
+ };
293
+ return resource;
294
+ }
295
+
296
+ /** The bound repo, or the one error that says which resource forgot it. */
297
+ export function repoOf<Row extends AdminRow>(resource: AdminResource<Row>): AdminRepo<Row> {
298
+ if (resource.repo === undefined) {
299
+ throw new AdminEntityUnknownError({
300
+ entity: resource.name,
301
+ known: [],
302
+ cause: `resource "${resource.name}" has no repo bound, so the admin cannot read or write rows`,
303
+ });
304
+ }
305
+ return resource.repo;
306
+ }
307
+
308
+ /** Look a resource up by entity name. The only place a name→resource miss is reported. */
309
+ export function resourceFor<Row extends AdminRow = AdminRow>(
310
+ resources: readonly AdminResource<Row>[],
311
+ name: string,
312
+ ): AdminResource<Row> {
313
+ const found = resources.find((resource) => resource.name === name);
314
+ if (found === undefined) {
315
+ throw new AdminEntityUnknownError({
316
+ entity: name,
317
+ known: resources.map((resource) => resource.name),
318
+ });
319
+ }
320
+ return found;
321
+ }
package/src/routes.ts ADDED
@@ -0,0 +1,35 @@
1
+ // The one bridge from the admin's route table to @ultimat3/render. Every admin page is
2
+ // `spa`: it is behind auth, so there is nothing to prerender and nothing a CDN may hold —
3
+ // and `network-only` keeps a stale org's rows out of a service worker cache.
4
+
5
+ import { t } from '@ultimat3/i18n';
6
+ import { defineRoute } from '@ultimat3/render';
7
+ import type { AdminApp, AdminRoute } from './admin';
8
+
9
+ export interface AdminRouteConfig {
10
+ readonly path: string;
11
+ readonly view: AdminRoute['view'];
12
+ readonly entity: string | null;
13
+ readonly permissions: readonly string[];
14
+ readonly config: ReturnType<typeof defineRoute>;
15
+ }
16
+
17
+ export function adminRouteConfig(route: AdminRoute): AdminRouteConfig {
18
+ return {
19
+ path: route.path,
20
+ view: route.view,
21
+ entity: route.entity,
22
+ permissions: route.permissions,
23
+ config: defineRoute({
24
+ render: 'spa',
25
+ offline: 'network-only',
26
+ hydrate: 'idle',
27
+ meta: () => ({ title: t(route.titleKey) }),
28
+ }),
29
+ };
30
+ }
31
+
32
+ /** Hand these to the router. Auth stays the host app's: see `AdminApp.auth`. */
33
+ export function adminRoutes(app: AdminApp): readonly AdminRouteConfig[] {
34
+ return app.routes.map(adminRouteConfig);
35
+ }