@ultimat3/admin 1.2.0 → 2.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/src/mcp.ts CHANGED
@@ -27,12 +27,7 @@ import {
27
27
  type CrudResult,
28
28
  } from './crud';
29
29
  import type { AdminFieldType } from './fields';
30
- import {
31
- type AdminMcpTool,
32
- type AdminToolField,
33
- adminMcpTools,
34
- adminToolCatalog,
35
- } from './mcp-tools';
30
+ import { type AdminMcpTool, adminMcpTools, adminToolCatalog } from './mcp-tools';
36
31
  import { confirmationToken } from './permissions';
37
32
  import type { AdminAction, AdminRow } from './registry';
38
33
  import { adminSearch } from './search';
@@ -187,13 +182,23 @@ const JSON_TYPE: Readonly<Record<AdminFieldType, NonNullable<JsonSchema['type']>
187
182
  file: 'string',
188
183
  };
189
184
 
190
- const inputSchema = (fields: readonly AdminToolField[]): JsonSchema => ({
185
+ /**
186
+ * `additionalProperties` is closed for every derived CRUD tool — the admin knows every field an
187
+ * entity has, so an argument outside that set is a mistake worth refusing at the client.
188
+ *
189
+ * It is OPEN for an action tool, and that is the whole point: the fields `mcp-tools.ts` declares
190
+ * there are the admin's own envelope (`id`, `confirmation`), while the action's real input is its
191
+ * own Standard Schema, which this package does not own and does not project. Closed would refuse
192
+ * exactly the arguments the action needs; a made-up field list would describe a contract nobody
193
+ * validates against. Open lets the action's own validation be the one that decides.
194
+ */
195
+ const inputSchema = (tool: AdminMcpTool): JsonSchema => ({
191
196
  type: 'object',
192
197
  properties: Object.fromEntries(
193
- fields.map((field) => [field.name, { type: JSON_TYPE[field.type] }]),
198
+ tool.input.map((field) => [field.name, { type: JSON_TYPE[field.type] }]),
194
199
  ),
195
- required: fields.filter((field) => field.required).map((field) => field.name),
196
- additionalProperties: false,
200
+ required: tool.input.filter((field) => field.required).map((field) => field.name),
201
+ additionalProperties: tool.kind === 'action',
197
202
  });
198
203
 
199
204
  /**
@@ -256,7 +261,7 @@ function toMcpTool(opts: AdminMcpOptions, requestId: () => string, tool: AdminMc
256
261
  return {
257
262
  name: tool.name,
258
263
  description: tool.description,
259
- inputSchema: inputSchema(tool.input),
264
+ inputSchema: inputSchema(tool),
260
265
  destructive: tool.destructive,
261
266
  // Visibility IS the gate: a tool this actor may not call is absent from `tools/list` and
262
267
  // answers ToolNotFound on call, never Forbidden — Forbidden would confirm the tool exists
package/src/nav.ts CHANGED
@@ -2,6 +2,7 @@
2
2
  // `<entity>:read` pair that gates the page itself. A nav item an actor cannot open is not a
3
3
  // nav item — the alternative is a sidebar full of 403s.
4
4
 
5
+ import { isAllowed } from './authz';
5
6
  import type { CrudCtx } from './crud';
6
7
  import { canOperate } from './crud';
7
8
  import type { AdminResource } from './resource';
@@ -12,6 +13,12 @@ export interface NavItem {
12
13
  readonly href: string;
13
14
  /** The entity this item lists, or `null` for a built-in page. */
14
15
  readonly entity: string | null;
16
+ /**
17
+ * What opening it needs. Set for a custom page (`pages.ts`), absent for a resource item —
18
+ * whose gate is its own `list` operation. Without it, an item with no entity was visible to
19
+ * everyone, which put a link to an ops screen in the sidebar of an actor the page refuses.
20
+ */
21
+ readonly permissions?: readonly string[];
15
22
  }
16
23
 
17
24
  export interface NavGroup {
@@ -86,6 +93,9 @@ export function visibleNav(
86
93
  ctx: CrudCtx,
87
94
  ): readonly NavGroup[] {
88
95
  const visible = (item: NavItem): boolean => {
96
+ if (item.permissions !== undefined && !isAllowed(ctx.authz, item.permissions, ctx.actor)) {
97
+ return false;
98
+ }
89
99
  if (item.entity === null) return true;
90
100
  const resource = resources.find((candidate) => candidate.name === item.entity);
91
101
  return resource !== undefined && canOperate(resource, 'list', ctx);
@@ -0,0 +1,58 @@
1
+ // The wrapper that makes a custom page's authz unskippable. `routes.ts` never hands the router
2
+ // the author's component — it hands this one, which asks the SAME `decideAll` every CRUD call
3
+ // and every nav item asks, audits the refusal, and only then calls the author's code.
4
+
5
+ import { t } from '@ultimat3/i18n';
6
+ import type { JSX } from 'solid-js';
7
+ import type { AdminRoute } from './admin';
8
+ import { deniedDraft } from './audit';
9
+ import { type AdminDecision, decideAll } from './authz';
10
+ import type { AdminPageComponent, AdminPageProps } from './pages';
11
+
12
+ /**
13
+ * The refusal, rendered as the page. Not a redirect and not a 404: an operator who is missing a
14
+ * grant needs to read WHICH permission refused them, which is the same string the audit row and
15
+ * the `/_x` policy panel carry.
16
+ */
17
+ export function AdminPageDenied(props: {
18
+ readonly titleKey: string;
19
+ readonly decision: AdminDecision;
20
+ }): JSX.Element {
21
+ return (
22
+ <section class="x-admin-denied" role="alert">
23
+ <h1>{t(props.titleKey)}</h1>
24
+ <p>
25
+ {t('admin.denied.body', {
26
+ permission: props.decision.permission,
27
+ reason: props.decision.reason,
28
+ })}
29
+ </p>
30
+ </section>
31
+ );
32
+ }
33
+
34
+ /**
35
+ * Wrap once, at route-table build time. The author's component is never reachable from
36
+ * `adminRoutes()`, so "the page that forgot its policy line" has nowhere left to exist.
37
+ */
38
+ export function guardedPage(route: AdminRoute, component: AdminPageComponent): AdminPageComponent {
39
+ return async (props: AdminPageProps): Promise<JSX.Element> => {
40
+ const decision = decideAll(props.ctx.authz, route.permissions, props.ctx.actor);
41
+ if (!decision.allowed) {
42
+ // The page IS the subject here, so its path is what the audit row names — there is no
43
+ // entity and no row id to key a refused screen by.
44
+ await props.ctx.audit.append(
45
+ deniedDraft({
46
+ requestId: props.ctx.requestId,
47
+ actor: props.ctx.actor,
48
+ operation: 'page',
49
+ kind: 'operation',
50
+ entity: route.path,
51
+ decision,
52
+ }),
53
+ );
54
+ return <AdminPageDenied titleKey={route.titleKey} decision={decision} />;
55
+ }
56
+ return component(props);
57
+ };
58
+ }
package/src/pages.ts ADDED
@@ -0,0 +1,117 @@
1
+ // The `pages:` seam — a screen no generator would write (a reconciliation fixer, a proxy health
2
+ // board, a deploy button), declared as data so it lands in the SAME route table, nav and
3
+ // permission pair the generated screens use. Nothing renders here: this file decides a page's
4
+ // path and its permissions, so `routes.ts` has exactly one thing to guard.
5
+
6
+ import type { JSX } from 'solid-js';
7
+ import type { AdminRoute } from './admin';
8
+ import type { CrudCtx } from './crud';
9
+ import { AdminPagePathInvalidError, AdminPageUnguardedError } from './errors';
10
+ import type { NavItem } from './nav';
11
+ import { ADMIN_READ } from './permissions';
12
+
13
+ /**
14
+ * What a page component is handed. `ctx` is the same per-request handle every CRUD call takes,
15
+ * and it is REQUIRED BY THE TYPE: a component that cannot be called without a ctx cannot be
16
+ * mounted without the guard that reads one.
17
+ */
18
+ export interface AdminPageProps {
19
+ readonly ctx: CrudCtx;
20
+ readonly params: Readonly<Record<string, string>>;
21
+ readonly url: string;
22
+ }
23
+
24
+ /** An ordinary SolidJS component. Async, because a page with data has no `load` seam. */
25
+ export type AdminPageComponent = (props: AdminPageProps) => JSX.Element | Promise<JSX.Element>;
26
+
27
+ export interface AdminCustomPage {
28
+ /** Rooted at the admin's `basePath`: `/ops`, never `/admin/ops` and never `ops`. */
29
+ readonly path: string;
30
+ readonly titleKey: string;
31
+ readonly component: AdminPageComponent;
32
+ /** The nav group key. Omitted means reachable by URL but not linked. */
33
+ readonly navGroup?: string;
34
+ /** At least one. `admin:read` is composed in front of it; empty is X_ADMIN_PAGE_UNGUARDED. */
35
+ readonly permissions: readonly string[];
36
+ }
37
+
38
+ /**
39
+ * The frame's gate, then the page's own — the exact pair `permissionsForOperation()` builds for a
40
+ * resource, so a custom page is decided by the same `decideAll` and cannot invent a third shape.
41
+ */
42
+ export function pagePermissions(page: AdminCustomPage): readonly string[] {
43
+ const out: string[] = [ADMIN_READ];
44
+ for (const permission of page.permissions) {
45
+ const trimmed = permission.trim();
46
+ if (trimmed !== '' && !out.includes(trimmed)) out.push(trimmed);
47
+ }
48
+ return out;
49
+ }
50
+
51
+ function assertGuarded(page: AdminCustomPage): void {
52
+ const declared = page.permissions.filter((permission) => permission.trim() !== '');
53
+ if (declared.length === 0) throw new AdminPageUnguardedError({ path: page.path });
54
+ }
55
+
56
+ function assertPath(page: AdminCustomPage, fullPath: string, taken: ReadonlySet<string>): void {
57
+ const refuse = (cause: string, fix: string): never => {
58
+ throw new AdminPagePathInvalidError({ path: page.path, cause, fix });
59
+ };
60
+ if (!page.path.startsWith('/')) {
61
+ refuse('is not rooted', `write path: '/${page.path}' — every page path starts with a slash`);
62
+ }
63
+ if (page.path.length < 2 || page.path.endsWith('/') || page.path.includes('//')) {
64
+ refuse('is not a usable path', "write path: '/ops' — one leading slash, no trailing slash");
65
+ }
66
+ if (taken.has(fullPath)) {
67
+ refuse(
68
+ `is already served by another admin route (${fullPath})`,
69
+ `rename the page, e.g. path: '${page.path}-ops'`,
70
+ );
71
+ }
72
+ }
73
+
74
+ /**
75
+ * Pages as routes. `taken` is every path the generated screens already claimed, so a page that
76
+ * would shadow `/admin/posts` fails at declaration rather than silently winning or losing a
77
+ * router race.
78
+ */
79
+ export function pageRoutes(
80
+ basePath: string,
81
+ pages: readonly AdminCustomPage[],
82
+ taken: readonly string[],
83
+ ): readonly AdminRoute[] {
84
+ const claimed = new Set(taken);
85
+ const routes: AdminRoute[] = [];
86
+ for (const page of pages) {
87
+ assertGuarded(page);
88
+ const fullPath = `${basePath}${page.path}`;
89
+ assertPath(page, fullPath, claimed);
90
+ claimed.add(fullPath);
91
+ routes.push({
92
+ path: fullPath,
93
+ view: 'page',
94
+ entity: null,
95
+ titleKey: page.titleKey,
96
+ permissions: pagePermissions(page),
97
+ component: page.component,
98
+ });
99
+ }
100
+ return routes;
101
+ }
102
+
103
+ /** Nav entries for the pages that asked for one, carrying the permissions that hide them. */
104
+ export function pageNavItems(
105
+ pages: readonly AdminCustomPage[],
106
+ ): readonly (NavItem & { readonly group: string })[] {
107
+ return pages
108
+ .filter((page) => page.navGroup !== undefined)
109
+ .map((page) => ({
110
+ key: page.path,
111
+ labelKey: page.titleKey,
112
+ href: page.path,
113
+ entity: null,
114
+ permissions: pagePermissions(page),
115
+ group: page.navGroup ?? '',
116
+ }));
117
+ }
package/src/pagination.ts CHANGED
@@ -71,6 +71,7 @@ export interface AdminPage<Row extends AdminRow> {
71
71
  readonly pageSize: number;
72
72
  readonly nextCursor: string | null;
73
73
  readonly prevCursor: string | null;
74
+ /** A page exists AFTER this one — what the Next control is enabled by, in both directions. */
74
75
  readonly hasMore: boolean;
75
76
  }
76
77
 
@@ -103,7 +104,21 @@ const cursorValue = (row: AdminRow, field: string): string => {
103
104
  return value === null || value === undefined ? '' : String(value);
104
105
  };
105
106
 
106
- /** Turn `limit + 1` rows into a page plus the cursors that walk off either end. */
107
+ /**
108
+ * Turn `limit + 1` rows into a page plus the cursors that walk off either end.
109
+ *
110
+ * The extra row is on the side the keyset walked TOWARD, so the direction decides everything:
111
+ * - `after` (and the first page, which has no cursor): the overflow row is the tail, and it means
112
+ * a next page exists. A previous page exists iff a cursor got us here.
113
+ * - `before`: the overflow row is the HEAD — the repo returns the page in the query's sort order,
114
+ * so the row furthest back is index 0 — and it means a *previous* page exists. A next page
115
+ * always exists, because paging backwards is only reachable from a later page.
116
+ *
117
+ * Reading `hasMore` as "the fetch overflowed" regardless of direction disabled Next on every
118
+ * backward page (the operator could not walk back to where they came from) and left Previous
119
+ * enabled past the first row; trimming the tail on a backward page silently skipped the row next
120
+ * to the cursor. One flag, both bugs.
121
+ */
107
122
  export function pageFrom<Row extends AdminRow>(
108
123
  resource: AdminResource<Row>,
109
124
  req: PageRequest,
@@ -111,11 +126,20 @@ export function pageFrom<Row extends AdminRow>(
111
126
  ): AdminPage<Row> {
112
127
  const sort = req.sort ?? resource.defaultSort;
113
128
  const pageSize = Math.max(1, Math.min(req.limit ?? resource.pageSize, 200));
114
- const hasMore = fetched.length > pageSize;
115
- const rows = hasMore ? fetched.slice(0, pageSize) : fetched;
129
+ const incoming = decodeAdminCursor(resource, req.cursor);
130
+ const backwards = incoming?.direction === 'before';
131
+ const overflow = fetched.length > pageSize;
132
+
133
+ const rows = !overflow
134
+ ? fetched
135
+ : backwards
136
+ ? fetched.slice(fetched.length - pageSize)
137
+ : fetched.slice(0, pageSize);
116
138
  const last = rows[rows.length - 1];
117
139
  const first = rows[0];
118
- const incoming = decodeAdminCursor(resource, req.cursor);
140
+
141
+ const hasMore = backwards ? true : overflow;
142
+ const hasPrevious = backwards ? overflow : incoming !== null;
119
143
 
120
144
  return {
121
145
  rows,
@@ -132,7 +156,7 @@ export function pageFrom<Row extends AdminRow>(
132
156
  })
133
157
  : null,
134
158
  prevCursor:
135
- incoming !== null && first !== undefined
159
+ hasPrevious && first !== undefined
136
160
  ? encodeAdminCursor(resource, {
137
161
  direction: 'before',
138
162
  field: sort.field,
package/src/registry.ts CHANGED
@@ -120,6 +120,12 @@ export interface AdminListQuery {
120
120
  * row's id as the tie-break so pages stay stable when the sort column has duplicates.
121
121
  */
122
122
  readonly after?: KeysetBound;
123
+ /**
124
+ * The mirror of `after`. Rows come back in `sort`'s own order either way — the admin renders
125
+ * what the repo returns and never re-sorts — so on a `before` query the `limit + 1`st row is the
126
+ * one FURTHEST back, at index 0, and `pageFrom` trims the head rather than the tail. A repo that
127
+ * answered nearest-first would hand the operator a reversed page.
128
+ */
123
129
  readonly before?: KeysetBound;
124
130
  }
125
131
 
package/src/routes.ts CHANGED
@@ -1,29 +1,58 @@
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.
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. A generated view is `spa`: it is behind auth, so there is
3
+ // nothing to prerender and nothing a CDN may hold — and `network-only` keeps a stale org's rows
4
+ // out of a service worker cache. A custom page is `ssr`, because its guard runs on the server.
4
5
 
5
6
  import { t } from '@ultimat3/i18n';
6
- import { defineRoute } from '@ultimat3/render';
7
+ import { defineRoute, type RouteGuard } from '@ultimat3/render';
7
8
  import type { AdminApp, AdminRoute } from './admin';
9
+ import { AdminPagePathInvalidError, AdminPageUnguardedError } from './errors';
10
+ import { guardedPage } from './page-guard';
11
+ import type { AdminPageComponent } from './pages';
8
12
 
9
13
  export interface AdminRouteConfig {
10
14
  readonly path: string;
11
15
  readonly view: AdminRoute['view'];
12
16
  readonly entity: string | null;
13
17
  readonly permissions: readonly string[];
18
+ /** The GUARDED component of a custom page; `null` for a generated view. */
19
+ readonly component: AdminPageComponent | null;
20
+ /**
21
+ * 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.
24
+ */
25
+ readonly policy: RouteGuard;
14
26
  readonly config: ReturnType<typeof defineRoute>;
15
27
  }
16
28
 
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: 'spa'` and `'ssr'` would both ship a public shell.
35
+ */
17
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 custom = route.component !== undefined;
40
+ const policy: RouteGuard = { permission };
41
+
18
42
  return {
19
43
  path: route.path,
20
44
  view: route.view,
21
45
  entity: route.entity,
22
46
  permissions: route.permissions,
47
+ component: route.component === undefined ? null : guardedPage(route, route.component),
48
+ policy,
23
49
  config: defineRoute({
24
- render: 'spa',
50
+ // A custom page renders server data behind the guard; a generated view is a shell that
51
+ // fetches through the admin's own gated calls. One mode each, never an author's choice.
52
+ render: custom ? 'ssr' : 'spa',
25
53
  offline: 'network-only',
26
- hydrate: 'idle',
54
+ hydrate: custom ? 'never' : 'idle',
55
+ policy,
27
56
  meta: () => ({ title: t(route.titleKey) }),
28
57
  }),
29
58
  };
@@ -33,3 +62,32 @@ export function adminRouteConfig(route: AdminRoute): AdminRouteConfig {
33
62
  export function adminRoutes(app: AdminApp): readonly AdminRouteConfig[] {
34
63
  return app.routes.map(adminRouteConfig);
35
64
  }
65
+
66
+ /**
67
+ * The one route the admin declares for `path` — the lookup a host performs when it serves an admin
68
+ * URL from its own file rather than from `adminRoutes()`.
69
+ *
70
+ * It exists because the alternative is what the deployed demo shipped: a page file typing
71
+ * `policy: { permission: 'admin:read' }` beside a route table that separately declared
72
+ * `permissionsForOperation(...)` for the same URL. Two declarations of one URL's authz agree until
73
+ * one of them is edited, and nothing was ever going to notice. Reading the gate from here means
74
+ * there is one declaration and one reader.
75
+ *
76
+ * A path the table does not declare is refused rather than answered with a default: a mount with
77
+ * no route is a screen whose permissions nothing composed, which is exactly the shape `pages:`
78
+ * exists to make impossible.
79
+ */
80
+ export function adminRouteFor(app: AdminApp, path: string): AdminRouteConfig {
81
+ const route = app.routes.find((candidate) => candidate.path === path);
82
+ if (route === undefined) {
83
+ throw new AdminPagePathInvalidError({
84
+ path,
85
+ cause: 'is not a route this admin declares',
86
+ fix:
87
+ 'serve one of the paths defineAdmin() built — ' +
88
+ `${app.routes.map((candidate) => candidate.path).join(', ')} — ` +
89
+ 'or declare this one in `pages:` on defineAdmin()',
90
+ });
91
+ }
92
+ return adminRouteConfig(route);
93
+ }
package/src/search.ts CHANGED
@@ -2,6 +2,7 @@
2
2
  // search config to drift from the entities, and no result an actor could not have opened —
3
3
  // the same `admin:read` + `<entity>:read` pair gates the hit and the detail page it links to.
4
4
 
5
+ import { expectedQueryLoop } from '@ultimat3/db';
5
6
  import type { CrudCtx } from './crud';
6
7
  import { canOperate } from './crud';
7
8
  import type { AdminFilter, AdminRow } from './registry';
@@ -41,6 +42,11 @@ export interface AdminSearchInput {
41
42
  * One query per searchable field rather than one query with an OR: the admin's query IR is
42
43
  * a conjunction by design (each filter maps to an indexed predicate), and three small
43
44
  * indexed lookups beat one unindexed disjunction.
45
+ *
46
+ * That argument is declared to the runtime and not only to the reader — `expectedQueryLoop`
47
+ * carries it onto every statement the loop issues, so a statement diagnostic reports the loops
48
+ * nobody argued for and never this one. At most `MAX_FIELDS_PER_RESOURCE` queries, and the scope
49
+ * ends with the loop: anything the repo does afterwards is judged normally.
44
50
  */
45
51
  async function searchResource(
46
52
  resource: AdminResource,
@@ -52,24 +58,29 @@ async function searchResource(
52
58
  const seen = new Set<string>();
53
59
  const hits: AdminSearchHit[] = [];
54
60
 
55
- for (const field of fields) {
56
- const where: readonly AdminFilter[] = [{ field: field.name, op: 'contains', value: term }];
57
- const rows = await repo.list({ where, sort: resource.defaultSort, limit });
58
- for (const row of rows) {
59
- const id = rowId(row, resource.idField);
60
- if (id === '' || seen.has(id)) continue;
61
- seen.add(id);
62
- hits.push({
63
- entity: resource.name,
64
- id,
65
- label: labelOf(row, resource),
66
- matchedField: field.name,
67
- href: `${resource.path}/${id}`,
68
- });
69
- if (hits.length >= limit) return hits;
70
- }
71
- }
72
- return hits;
61
+ return expectedQueryLoop(
62
+ 'admin search runs one indexed lookup per text field, which beats one unindexed OR',
63
+ async () => {
64
+ for (const field of fields) {
65
+ const where: readonly AdminFilter[] = [{ field: field.name, op: 'contains', value: term }];
66
+ const rows = await repo.list({ where, sort: resource.defaultSort, limit });
67
+ for (const row of rows) {
68
+ const id = rowId(row, resource.idField);
69
+ if (id === '' || seen.has(id)) continue;
70
+ seen.add(id);
71
+ hits.push({
72
+ entity: resource.name,
73
+ id,
74
+ label: labelOf(row, resource),
75
+ matchedField: field.name,
76
+ href: `${resource.path}/${id}`,
77
+ });
78
+ if (hits.length >= limit) return hits;
79
+ }
80
+ }
81
+ return hits;
82
+ },
83
+ );
73
84
  }
74
85
 
75
86
  function labelOf(row: AdminRow, resource: AdminResource): string {
@@ -4,6 +4,7 @@
4
4
  // this returns and make no decisions of their own.
5
5
 
6
6
  import type { Money } from '@ultimat3/money';
7
+ import { isCurrencyCode } from '@ultimat3/schema';
7
8
  import { AdminFieldUnsupportedError } from './errors';
8
9
  import type { AdminField } from './fields';
9
10
 
@@ -12,6 +13,18 @@ export interface WidgetContext {
12
13
  readonly timeZone: string;
13
14
  /** BCP-47, for the number/date/money formatters the widgets call. */
14
15
  readonly locale: string;
16
+ /**
17
+ * Where a foreign-key value links to, or `null` for "do not link it". Absent renders the id as
18
+ * plain text.
19
+ *
20
+ * WHY a seam and not a derivation: the widget used to build `/${entity}s/${value}`, which is
21
+ * English pluralisation by string concatenation and drops the admin's own `basePath` — a link
22
+ * that is wrong on every admin not mounted at `/` and on every entity whose plural is not a
23
+ * trailing `s`. The route table is `AdminApp`'s (`basePath` + `AdminResource.path`, which is
24
+ * already pluralised once, in `resource.ts`), and a widget three layers down cannot see it. The
25
+ * caller that builds this context can. A wrong link is worse than no link.
26
+ */
27
+ readonly hrefFor?: (entity: string, id: string) => string | null;
15
28
  }
16
29
 
17
30
  export interface SelectOption {
@@ -59,8 +72,6 @@ const fail = (field: AdminField, cause: string, fix: string): never => {
59
72
  throw new AdminFieldUnsupportedError({ entity: field.entity, field: field.name, cause, fix });
60
73
  };
61
74
 
62
- const CURRENCY = /^[A-Z]{3}$/;
63
-
64
75
  /**
65
76
  * `money()` puts a `bigint` on the row — Postgres `bigint` minor units — where `Money` is a
66
77
  * number. This is the one place that widening happens, and it refuses rather than round: a
@@ -101,7 +112,12 @@ export function assertMoney(field: AdminField, value: unknown): Money | null {
101
112
  );
102
113
  }
103
114
  const currency = typeof bag.currency === 'string' ? bag.currency : field.currency;
104
- if (currency === undefined || !CURRENCY.test(currency)) {
115
+ // `isCurrencyCode` is `@ultimat3/schema`'s (tier 0), never a local regex: this widget refuses
116
+ // exactly what `t.money`, the published OpenAPI `pattern` and the Postgres CHECK refuse, so a
117
+ // row the app wrote can never be one the admin declines to render. It takes `unknown`, which is
118
+ // what makes the `undefined` case — no row currency and no declared `field.currency` — the same
119
+ // branch as a malformed one.
120
+ if (!isCurrencyCode(currency)) {
105
121
  return fail(
106
122
  field,
107
123
  `money has no ISO-4217 currency (got ${String(bag.currency)})`,
package/src/widgets.tsx CHANGED
@@ -2,6 +2,7 @@
2
2
  // switch, so money is a Money widget and a timestamp is a DateTime widget with a zone in
3
3
  // every one of those places — there is no second place to get it wrong.
4
4
 
5
+ import { safeUrl } from '@ultimat3/core';
5
6
  import { t } from '@ultimat3/i18n';
6
7
  import {
7
8
  Checkbox,
@@ -65,7 +66,7 @@ const inputValueFor = (iso: string, precision: 'date' | 'instant'): string =>
65
66
  iso.slice(0, precision === 'date' ? 10 : 16);
66
67
 
67
68
  /** Read-mode rendering of already-guarded props. Never formats; the widgets do that. */
68
- function readView(props: WidgetProps, field: AdminField): JSX.Element {
69
+ function readView(props: WidgetProps, field: AdminField, ctx: WidgetContext): JSX.Element {
69
70
  switch (props.widget) {
70
71
  case 'money':
71
72
  return props.value === null ? (
@@ -95,17 +96,18 @@ function readView(props: WidgetProps, field: AdminField): JSX.Element {
95
96
  );
96
97
  case 'json-editor':
97
98
  return <pre class="x-admin-json">{props.value}</pre>;
98
- case 'reference':
99
- return props.value === null ? (
100
- <span>{t('admin.value.empty')}</span>
101
- ) : (
102
- <a href={`/${props.entity}s/${props.value}`}>{props.value}</a>
103
- );
99
+ case 'reference': {
100
+ if (props.value === null) return <span>{t('admin.value.empty')}</span>;
101
+ // `ctx.hrefFor` or nothing. The route table lives on `AdminApp`; this file only ever knew
102
+ // the target entity's NAME, and turning that into a URL by appending an `s` is a guess.
103
+ const href = ctx.hrefFor?.(props.entity, props.value) ?? null;
104
+ return href === null ? <span>{props.value}</span> : <a href={href}>{props.value}</a>;
105
+ }
104
106
  case 'upload':
105
107
  return props.value === null ? (
106
108
  <span>{t('admin.value.empty')}</span>
107
109
  ) : (
108
- <a href={props.value.url}>{props.value.name}</a>
110
+ <a href={safeUrl(props.value.url, 'href') ?? undefined}>{props.value.name}</a>
109
111
  );
110
112
  default:
111
113
  return <span>{String(props.value ?? '')}</span>;
@@ -258,5 +260,5 @@ function locales(): readonly string[] {
258
260
 
259
261
  export function Widget(input: WidgetInput): JSX.Element {
260
262
  const props = widgetProps(input.field, input.value, input.ctx);
261
- return input.mode === 'read' ? readView(props, input.field) : editView(props, input);
263
+ return input.mode === 'read' ? readView(props, input.field, input.ctx) : editView(props, input);
262
264
  }