@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.
Files changed (89) hide show
  1. package/CLAUDE.md +73 -80
  2. package/README.md +276 -87
  3. package/package.json +19 -17
  4. package/src/action-form.tsx +139 -0
  5. package/src/action-gate.ts +127 -28
  6. package/src/action-input.ts +119 -0
  7. package/src/action-label.ts +9 -0
  8. package/src/action-row.ts +37 -0
  9. package/src/actions.tsx +89 -66
  10. package/src/actor.ts +53 -0
  11. package/src/admin.module.scss +298 -0
  12. package/src/admin.ts +187 -23
  13. package/src/audit-pg.ts +207 -0
  14. package/src/audit-schema.ts +47 -0
  15. package/src/audit.ts +109 -22
  16. package/src/authz.ts +11 -0
  17. package/src/batch-bar.tsx +66 -0
  18. package/src/batch-job.ts +152 -0
  19. package/src/batch-matching.ts +92 -0
  20. package/src/batch.ts +431 -0
  21. package/src/catalog-keys.ts +55 -0
  22. package/src/crud-input.ts +104 -0
  23. package/src/crud-outcome.ts +166 -0
  24. package/src/crud.ts +129 -177
  25. package/src/describe.ts +131 -0
  26. package/src/detail.tsx +115 -43
  27. package/src/dev/data.ts +24 -6
  28. package/src/dev/facts.ts +27 -0
  29. package/src/dev/panel-db.ts +37 -5
  30. package/src/dev/panel-jobs.ts +8 -0
  31. package/src/dev/panel-routes.ts +10 -0
  32. package/src/dev/panel.ts +6 -0
  33. package/src/dev/server.ts +15 -7
  34. package/src/entity-columns.ts +24 -5
  35. package/src/errors.ts +83 -0
  36. package/src/fields.ts +63 -4
  37. package/src/form-decode.ts +91 -0
  38. package/src/form.tsx +78 -45
  39. package/src/index.ts +198 -9
  40. package/src/jobs/fleet-repo.ts +143 -0
  41. package/src/jobs/job-actions.ts +172 -0
  42. package/src/jobs/job-entities.ts +118 -0
  43. package/src/jobs/job-labels.ts +108 -0
  44. package/src/jobs/job-repo.ts +103 -0
  45. package/src/jobs/job-resources.ts +211 -0
  46. package/src/jobs/job-where.ts +170 -0
  47. package/src/jobs/operator.ts +34 -0
  48. package/src/jobs/overview-data.ts +129 -0
  49. package/src/jobs/overview-tab.tsx +61 -0
  50. package/src/jobs/overview.tsx +189 -0
  51. package/src/json-text.ts +52 -0
  52. package/src/layout.tsx +55 -43
  53. package/src/list-columns.tsx +53 -0
  54. package/src/list-filter-bar.tsx +207 -0
  55. package/src/list-filters.ts +217 -0
  56. package/src/list-request.ts +127 -0
  57. package/src/list-scope-tabs.tsx +60 -0
  58. package/src/list-scope.ts +113 -0
  59. package/src/list.tsx +111 -72
  60. package/src/mcp-tools.ts +57 -13
  61. package/src/mcp.ts +108 -38
  62. package/src/mounts.ts +44 -0
  63. package/src/page-guard.tsx +26 -29
  64. package/src/policy-bridge.ts +110 -15
  65. package/src/registry.ts +138 -8
  66. package/src/related.ts +105 -0
  67. package/src/relations.ts +224 -0
  68. package/src/renderers.tsx +89 -0
  69. package/src/repo-entity.ts +204 -0
  70. package/src/resource-fields.ts +116 -0
  71. package/src/resource-layout.ts +89 -0
  72. package/src/resource-list.ts +185 -0
  73. package/src/resource.ts +129 -79
  74. package/src/route-config.ts +71 -0
  75. package/src/routes.ts +55 -54
  76. package/src/row-scope-write.ts +130 -0
  77. package/src/screen-action.tsx +333 -0
  78. package/src/screen-frame.tsx +167 -0
  79. package/src/screen-home.tsx +201 -0
  80. package/src/screen-lookup.tsx +157 -0
  81. package/src/screen-resource.tsx +393 -0
  82. package/src/screen-system.tsx +69 -0
  83. package/src/screens.ts +56 -0
  84. package/src/search.ts +13 -9
  85. package/src/stylesheet-scope.ts +8 -0
  86. package/src/test-ctx.ts +31 -0
  87. package/src/validate.ts +3 -0
  88. package/src/widget-value.ts +36 -8
  89. 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: fields.filter((field) => !field.readOnly && !field.sensitive),
279
- filters: fields.filter((field) => field.filterable),
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 AdminEntityUnknownError({
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 bridge from the admin's route table to @ultimat3/render, and the one place a policy
2
- // is composed onto an admin page. Every admin route is `ssr`: it is behind auth, so its guard runs
3
- // on the server, there is nothing to prerender and nothing a CDN may hold — and `network-only`
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 { t } from '@ultimat3/i18n';
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, AdminPageUnguardedError } from './errors';
10
- import { guardedPage } from './page-guard';
11
- import type { AdminPageComponent } from './pages';
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: a host that serves an
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: ReturnType<typeof defineRoute>;
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
- * The author never writes this `defineRoute` call, so the author cannot omit its `policy` —
31
- * which is the whole mechanism. The coarse gate is `permissions[0]` (always `admin:read`, put
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
- component: route.component === undefined ? null : guardedPage(route, route.component),
40
+ respond: screenFor(app, route),
47
41
  policy,
48
- config: defineRoute({
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
- /** Hand these to the router. Auth stays the host app's: see `AdminApp.auth`. */
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` — the lookup a host performs when it serves an admin
74
- * URL from its own file rather than from `adminRoutes()`.
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
+ }