@ultimat3/admin 23.0.0 → 25.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 (64) hide show
  1. package/CLAUDE.md +32 -30
  2. package/README.md +51 -16
  3. package/package.json +17 -17
  4. package/src/action-gate.ts +33 -42
  5. package/src/admin.module.scss +118 -4
  6. package/src/audit-pg.ts +2 -2
  7. package/src/audit.ts +1 -3
  8. package/src/authz.ts +8 -8
  9. package/src/batch-matching.ts +26 -31
  10. package/src/batch-queue.ts +90 -0
  11. package/src/batch.ts +11 -51
  12. package/src/crud-input.ts +2 -1
  13. package/src/crud-outcome.ts +96 -38
  14. package/src/crud.ts +48 -4
  15. package/src/describe.ts +6 -0
  16. package/src/dev/data.ts +5 -8
  17. package/src/dev/dev-t.ts +9 -0
  18. package/src/dev/facts.ts +1 -1
  19. package/src/dev/index.ts +1 -0
  20. package/src/dev/jobs-state-chart.tsx +38 -0
  21. package/src/dev/panel-cache.ts +8 -3
  22. package/src/dev/panel-jobs.ts +10 -5
  23. package/src/dev/panel-timeline.ts +16 -3
  24. package/src/dev/panel.ts +10 -5
  25. package/src/dev/server.ts +43 -17
  26. package/src/dev/sql-runner.ts +31 -0
  27. package/src/dev/timeline-style.ts +47 -0
  28. package/src/dev/timeline-waterfall.ts +96 -0
  29. package/src/errors.ts +9 -5
  30. package/src/form-decode.ts +55 -5
  31. package/src/form.tsx +19 -5
  32. package/src/index.ts +11 -9
  33. package/src/jobs/fleet-repo.ts +15 -3
  34. package/src/jobs/job-repo.ts +51 -17
  35. package/src/jobs/operator.ts +5 -5
  36. package/src/jobs/overview-tab.tsx +2 -2
  37. package/src/keyset-seek.ts +189 -0
  38. package/src/layout.tsx +34 -8
  39. package/src/list-filters.ts +30 -5
  40. package/src/list-request.ts +8 -6
  41. package/src/list.tsx +1 -1
  42. package/src/mcp-scopes.ts +36 -0
  43. package/src/mcp-tools.ts +16 -2
  44. package/src/mcp.ts +27 -11
  45. package/src/pagination.ts +32 -22
  46. package/src/policy-bridge.ts +16 -14
  47. package/src/registry.ts +9 -1
  48. package/src/related.ts +2 -2
  49. package/src/relations.ts +26 -10
  50. package/src/repo-entity.ts +37 -46
  51. package/src/resource-list.ts +9 -0
  52. package/src/resource.ts +10 -0
  53. package/src/row-version.ts +20 -0
  54. package/src/screen-home-access.tsx +117 -0
  55. package/src/screen-home-counts.ts +42 -0
  56. package/src/screen-home-kpis.tsx +64 -0
  57. package/src/screen-home.module.scss +169 -0
  58. package/src/screen-home.tsx +21 -82
  59. package/src/screen-lookup.tsx +1 -1
  60. package/src/screen-resource.tsx +60 -12
  61. package/src/screen-system.tsx +8 -2
  62. package/src/search.ts +12 -4
  63. package/src/widgets.tsx +2 -6
  64. package/src/inert-jsx.ts +0 -182
@@ -1,9 +1,15 @@
1
- // The job rows as an `AdminRepo`: a page is ONE `JobIntrospection.list` call, newest first and
2
- // keyset by `(createdAt, id)` — the store's own order, and the only one it answers — and a detail
3
- // is the store's own trace, its input already redacted. Read-only: every change is an action.
1
+ // The job rows as an `AdminRepo`: a page is the store's own `JobIntrospection.list`, newest first
2
+ // and keyset by `(createdAt, id)` — the only order it answers — and a detail is the store's own
3
+ // trace, its input already redacted. Read-only: every change is an action.
4
4
 
5
5
  import { assert } from '@ultimat3/core';
6
- import { inspectJob, type JobRecord, jobCursor, MAX_JOB_PAGE } from '@ultimat3/jobs';
6
+ import {
7
+ inspectJob,
8
+ type JobFilter,
9
+ type JobRecord,
10
+ jobCursor,
11
+ MAX_JOB_PAGE,
12
+ } from '@ultimat3/jobs';
7
13
  import { AdminFilterInvalidError } from '../errors';
8
14
  import type { AdminListQuery, AdminRepo, AdminRow, KeysetBound } from '../registry';
9
15
  import { JOBS_RESOURCE, jobWhere } from './job-where';
@@ -37,7 +43,12 @@ export function jobRow(record: JobRecord): AdminRow {
37
43
 
38
44
  /** A keyset bound as the store's cursor: the bound row's instant in ms, and its id. */
39
45
  const cursorOf = (bound: KeysetBound): string =>
40
- jobCursor({ createdAt: Date.parse(bound.value), id: bound.id });
46
+ // `createdAt` is never NULL on a job row, so a bound without a value names no position: `NaN`
47
+ // here, which the store refuses as an unreadable cursor rather than seeking from the epoch.
48
+ jobCursor({
49
+ createdAt: bound.value === null ? Number.NaN : Date.parse(bound.value),
50
+ id: bound.id,
51
+ });
41
52
 
42
53
  function assertOrder(query: AdminListQuery): void {
43
54
  const { field, direction } = query.sort;
@@ -50,23 +61,46 @@ function assertOrder(query: AdminListQuery): void {
50
61
  });
51
62
  }
52
63
 
64
+ /**
65
+ * The rows one admin query asks for. The admin asks ONE row past its page to learn whether a next
66
+ * one exists, and the store answers `MAX_JOB_PAGE` at most — so a 200-row page (the admin's own
67
+ * ceiling, and what the MCP list tool accepts) was answered 200 rows, read as "no more", and every
68
+ * row after it was on no page. Past the store's ceiling, the overflow is a second read from where
69
+ * the first one stopped: after its last row walking forward, before its first walking back.
70
+ */
71
+ async function records(filter: JobFilter, query: AdminListQuery): Promise<readonly JobRecord[]> {
72
+ const { introspect } = jobsOperator();
73
+ const backward = query.before !== undefined;
74
+ const bound =
75
+ query.after !== undefined
76
+ ? { after: cursorOf(query.after) }
77
+ : query.before !== undefined
78
+ ? { before: cursorOf(query.before) }
79
+ : {};
80
+ const first = await introspect.list({
81
+ ...filter,
82
+ limit: Math.min(query.limit, MAX_JOB_PAGE),
83
+ ...bound,
84
+ });
85
+ const over = query.limit - MAX_JOB_PAGE;
86
+ if (over <= 0 || first.length < MAX_JOB_PAGE) return first;
87
+ const edge = backward ? first[0] : first[first.length - 1];
88
+ if (edge === undefined) return first;
89
+ const more = await introspect.list({
90
+ ...filter,
91
+ limit: Math.min(over, MAX_JOB_PAGE),
92
+ ...(backward ? { before: jobCursor(edge) } : { after: jobCursor(edge) }),
93
+ });
94
+ return backward ? [...more, ...first] : [...first, ...more];
95
+ }
96
+
53
97
  async function list(query: AdminListQuery): Promise<readonly AdminRow[]> {
54
98
  assertOrder(query);
55
99
  const where = jobWhere(query.where ?? []);
56
100
  if (where.empty) return [];
57
- const { introspect } = jobsOperator();
58
- const records = await introspect.list({
59
- ...where.filter,
60
- // The admin asks one row past its page to learn whether there is a next one; the store's page
61
- // is bounded, and a full store page simply reads as "maybe more".
62
- limit: Math.min(query.limit, MAX_JOB_PAGE),
63
- ...(query.after === undefined ? {} : { after: cursorOf(query.after) }),
64
- ...(query.before === undefined ? {} : { before: cursorOf(query.before) }),
65
- });
101
+ const found = await records(where.filter, query);
66
102
  const exact = where.exactId;
67
- return (exact === undefined ? records : records.filter((record) => record.id === exact)).map(
68
- jobRow,
69
- );
103
+ return (exact === undefined ? found : found.filter((record) => record.id === exact)).map(jobRow);
70
104
  }
71
105
 
72
106
  /** The detail: the row, plus what `inspectJob` says of it — redacted input, steps, the stack. */
@@ -2,11 +2,11 @@
2
2
  // boot installs it after `defineAdmin()` has run, and `x dev` swaps it — and its operator surface.
3
3
  // A process with no driver, or one that ships no operator surface, is refused with the fix.
4
4
 
5
+ import { NotImplementedError } from '@ultimat3/core';
5
6
  import {
6
7
  DriverUnavailableError,
7
8
  type JobDriver,
8
9
  type JobIntrospection,
9
- JobsNotImplementedError,
10
10
  jobDriver,
11
11
  } from '@ultimat3/jobs';
12
12
 
@@ -21,13 +21,13 @@ export function jobsOperator(): JobsOperator {
21
21
  throw new DriverUnavailableError({
22
22
  driver: 'none',
23
23
  cause: 'the jobs dashboard was asked for rows and this process installed no queue',
24
- fix: 'run the app through x dev or the container entry, which install the queue — or in a test: setJobDriver(createMemoryDriver())',
24
+ fix: 'run the app through x dev or the container entry, which install the queue — or in a test: setJobDriver(memoryJobDriver())',
25
25
  });
26
26
  }
27
27
  if (driver.introspect === undefined) {
28
- throw new JobsNotImplementedError({
29
- feature: `the operator surface of the "${driver.name}" jobs driver`,
30
- fix: 'call setJobDriver(createPgDriver()) at boot — the pg and memory drivers implement introspect',
28
+ throw new NotImplementedError({
29
+ cause: `the operator surface of the "${driver.name}" jobs driver is not implemented: the driver has no introspect`,
30
+ fix: 'call setJobDriver(postgresJobDriver()) at boot — the pg and memory drivers implement introspect',
31
31
  });
32
32
  }
33
33
  return { driver, introspect: driver.introspect };
@@ -2,7 +2,7 @@
2
2
  // own queue (the dev driver under `x dev`) — one jobs UI, not a second one in the dev panel. Only
3
3
  // ever reached by `dev/panel-jobs.ts`'s dynamic `import()`, so `/_x`'s mount graph stays free of it.
4
4
 
5
- import { createContext, runWithContext } from '@ultimat3/core';
5
+ import { ctxOf, runWithContext } from '@ultimat3/core';
6
6
  import { renderToHtml } from '@ultimat3/render/server';
7
7
  import { listHref } from '../list-request';
8
8
  import { adminMounts } from '../mounts';
@@ -33,7 +33,7 @@ export async function jobsTabHtml(tabPath: string, params: URLSearchParams): Pro
33
33
  const json = `${tabPath}?json=1`;
34
34
  const data = await overviewData(range, Date.now());
35
35
  // The framework catalog's locale for every label, whatever the app's default is.
36
- return runWithContext(createContext({ locale: 'en', tz: 'UTC' }), () =>
36
+ return runWithContext(ctxOf({ locale: 'en', tz: 'UTC' }), () =>
37
37
  renderToHtml(
38
38
  <JobsOverview
39
39
  data={data}
@@ -0,0 +1,189 @@
1
+ // The keyset seek on the PAIR `(sort, id)`, for a typed handle whose chain is a conjunction: the
2
+ // row-value comparison `(sort, id) > (v, id₀)` is read as disjoint segments in walk order — the
3
+ // rest of the boundary's class by id, then everything past the class, then the NULLs — so a sort
4
+ // value shared by any number of rows, and a NULL one, is a position like any other.
5
+
6
+ import type { AdminListQuery, AdminRow, AdminTableRead, KeysetBound } from './registry';
7
+
8
+ type TableOperator = Parameters<AdminTableRead['andWhere']>[1];
9
+ type Direction = 'asc' | 'desc';
10
+
11
+ /** One `andWhere` term. `value` is absent for the two null tests. */
12
+ export interface SeekTerm {
13
+ readonly field: string;
14
+ readonly op: TableOperator;
15
+ readonly value?: unknown;
16
+ }
17
+
18
+ export interface SeekColumn {
19
+ readonly field: string;
20
+ readonly idField: string;
21
+ readonly nullable: boolean;
22
+ /**
23
+ * A `timestamptz`. The row hands it back as a `Date` — milliseconds — while Postgres keeps and
24
+ * orders microseconds, so a cursor can only name the boundary's MILLISECOND. Its class is that
25
+ * millisecond `[v, v + 1ms)`, ordered by id inside: the order both drivers can agree on.
26
+ */
27
+ readonly instant: boolean;
28
+ /** The bound's text back to what the handle compares: a `Date` for an instant. */
29
+ readonly typed: (value: string) => unknown;
30
+ }
31
+
32
+ const flip = (direction: Direction): Direction => (direction === 'asc' ? 'desc' : 'asc');
33
+
34
+ const millisOf = (row: AdminRow, field: string): number | null => {
35
+ const value = row[field];
36
+ return value instanceof Date ? value.getTime() : null;
37
+ };
38
+
39
+ /** Where one walk segment reads. */
40
+ interface Segments {
41
+ readonly read: (
42
+ where: readonly SeekTerm[],
43
+ order: 'id' | 'pair',
44
+ limit: number,
45
+ ) => Promise<AdminRow[]>;
46
+ readonly column: SeekColumn;
47
+ readonly walk: Direction;
48
+ }
49
+
50
+ /** The terms that hold the boundary's class: equal, the same millisecond, or NULL. */
51
+ const classOf = (column: SeekColumn, value: unknown): readonly SeekTerm[] => {
52
+ if (value === null) return [{ field: column.field, op: 'is-null' }];
53
+ if (column.instant && value instanceof Date) {
54
+ return [
55
+ { field: column.field, op: 'gte', value },
56
+ { field: column.field, op: 'lt', value: new Date(value.getTime() + 1) },
57
+ ];
58
+ }
59
+ return [{ field: column.field, op: 'eq', value }];
60
+ };
61
+
62
+ /** Strictly past the class of a present `value`, in the walk's direction. NULLs never match. */
63
+ const pastClass = (column: SeekColumn, value: unknown, walk: Direction): readonly SeekTerm[] => {
64
+ if (walk === 'desc') return [{ field: column.field, op: 'lt', value }];
65
+ if (column.instant && value instanceof Date) {
66
+ return [{ field: column.field, op: 'gte', value: new Date(value.getTime() + 1) }];
67
+ }
68
+ return [{ field: column.field, op: 'gt', value }];
69
+ };
70
+
71
+ /** One class, by id, after `afterId` when given — exact whatever the class's internal order. */
72
+ const classRows = (
73
+ at: Segments,
74
+ where: readonly SeekTerm[],
75
+ afterId: string | undefined,
76
+ limit: number,
77
+ ): Promise<AdminRow[]> =>
78
+ limit <= 0
79
+ ? Promise.resolve([])
80
+ : at.read(
81
+ afterId === undefined
82
+ ? where
83
+ : [
84
+ ...where,
85
+ { field: at.column.idField, op: at.walk === 'asc' ? 'gt' : 'lt', value: afterId },
86
+ ],
87
+ 'id',
88
+ limit,
89
+ );
90
+
91
+ /**
92
+ * Present values past `where`, `limit` of them in `(sort, id)` order. For an instant the LAST
93
+ * millisecond read may be cut part-way through and its rows are in microsecond order, so it is
94
+ * dropped and re-read whole by id — the order the next page's seek resumes in. Every earlier
95
+ * millisecond on the page is complete, and is put in that same order.
96
+ */
97
+ async function valueRows(
98
+ at: Segments,
99
+ where: readonly SeekTerm[],
100
+ limit: number,
101
+ ): Promise<AdminRow[]> {
102
+ if (limit <= 0) return [];
103
+ const { column } = at;
104
+ const present: readonly SeekTerm[] =
105
+ where.length === 0 && column.nullable ? [{ field: column.field, op: 'is-not-null' }] : where;
106
+ if (!column.instant) return at.read(present, 'pair', limit);
107
+ const rows = await at.read(present, 'pair', limit + 1);
108
+ if (rows.length <= limit) return byMillisecondThenId(rows, column, at.walk);
109
+ const tail = millisOf(rows[limit - 1] ?? {}, column.field);
110
+ const extra = millisOf(rows[limit] ?? {}, column.field);
111
+ const kept = rows.slice(0, limit);
112
+ if (tail === null || tail !== extra) return byMillisecondThenId(kept, column, at.walk);
113
+ const whole = kept.filter((row) => millisOf(row, column.field) !== tail);
114
+ const rest = await classRows(
115
+ at,
116
+ classOf(column, new Date(tail)),
117
+ undefined,
118
+ limit - whole.length,
119
+ );
120
+ return [...byMillisecondThenId(whole, column, at.walk), ...rest];
121
+ }
122
+
123
+ /**
124
+ * Rows of one millisecond in id order. Code-unit order: it agrees with the database for a `uuid`
125
+ * key, which is the framework's; a text key under a non-C collation can disagree inside one
126
+ * millisecond, and only there.
127
+ */
128
+ const byMillisecondThenId = (
129
+ rows: readonly AdminRow[],
130
+ column: SeekColumn,
131
+ walk: Direction,
132
+ ): AdminRow[] => {
133
+ const sign = walk === 'asc' ? 1 : -1;
134
+ return [...rows].sort((a, b) => {
135
+ const at = (millisOf(a, column.field) ?? 0) - (millisOf(b, column.field) ?? 0);
136
+ if (at !== 0) return sign * at;
137
+ const left = String(a[column.idField]);
138
+ const right = String(b[column.idField]);
139
+ return sign * (left < right ? -1 : left > right ? 1 : 0);
140
+ });
141
+ };
142
+
143
+ /**
144
+ * The page a query asks for, read in WALK order — forward from an `after` bound (or from the top),
145
+ * backward from a `before` one — and answered in the query's own sort order either way.
146
+ *
147
+ * NULL is the largest value, as both drivers order it: last ascending, first descending.
148
+ */
149
+ export async function seekPage(
150
+ query: AdminListQuery,
151
+ column: SeekColumn,
152
+ read: (
153
+ where: readonly SeekTerm[],
154
+ order: { readonly by: 'id' | 'pair'; readonly direction: Direction },
155
+ limit: number,
156
+ ) => Promise<AdminRow[]>,
157
+ ): Promise<readonly AdminRow[]> {
158
+ const backward = query.before !== undefined;
159
+ const walk = backward ? flip(query.sort.direction) : query.sort.direction;
160
+ const at: Segments = {
161
+ column,
162
+ walk,
163
+ read: (where, by, limit) => read(where, { by, direction: walk }, limit),
164
+ };
165
+ const out: AdminRow[] = [];
166
+ const room = (): number => query.limit - out.length;
167
+ const bound: KeysetBound | undefined = query.after ?? query.before;
168
+ const nulls = (afterId?: string): Promise<AdminRow[]> =>
169
+ column.nullable ? classRows(at, classOf(column, null), afterId, room()) : Promise.resolve([]);
170
+
171
+ if (bound === undefined) {
172
+ if (walk === 'desc') out.push(...(await nulls()));
173
+ out.push(...(await valueRows(at, [], room())));
174
+ if (walk === 'asc') out.push(...(await nulls()));
175
+ } else if (bound.value === null) {
176
+ out.push(...(await nulls(bound.id)));
177
+ if (walk === 'desc') out.push(...(await valueRows(at, [], room())));
178
+ } else {
179
+ const value = column.typed(bound.value);
180
+ // The id IS the sort: its class is one row, so only the strict half is read.
181
+ if (column.field !== column.idField) {
182
+ out.push(...(await classRows(at, classOf(column, value), bound.id, room())));
183
+ }
184
+ out.push(...(await valueRows(at, pastClass(column, value, walk), room())));
185
+ if (walk === 'asc') out.push(...(await nulls()));
186
+ }
187
+ const page = out.slice(0, query.limit);
188
+ return backward ? page.reverse() : page;
189
+ }
package/src/layout.tsx CHANGED
@@ -1,14 +1,18 @@
1
1
  // The admin shell: skip link, sidebar (brand, who is acting, search, permission-filtered nav, the
2
2
  // way back to the app), main region. Landmarks and a visible focus order are the accessibility
3
3
  // contract; the theme attributes come from the token system, so nothing here knows a colour.
4
- // Every control is a link or a native form — the shell ships no script.
4
+ // Every control is a link, a native form or a native popover — the shell ships no script: below
5
+ // `md` the sidebar is a `popover` panel the menu button opens by `popovertarget`, and an engine
6
+ // with no popovers keeps it as a block above the screen (`admin.module.scss`).
5
7
 
6
8
  // why: `*.module.scss` is typed by ONE ambient declaration, and it is `@ultimat3/ui`'s. A copy
7
9
  // here would be a `.d.ts` in `src/`, which the package-shape gate reads as a build artifact.
8
10
  /// <reference path="../../ui/src/scss.d.ts" />
9
11
 
10
12
  import { t } from '@ultimat3/i18n';
11
- import { Input } from '@ultimat3/ui';
13
+ import { Icon, Input, UI_KEYS } from '@ultimat3/ui';
14
+ import { iconMenu } from '@ultimat3/ui/icons/menu';
15
+ import { iconX } from '@ultimat3/ui/icons/x';
12
16
  import type { JSX } from 'solid-js';
13
17
  import type { AdminApp } from './admin';
14
18
  import styles from './admin.module.scss';
@@ -27,8 +31,11 @@ export interface AdminLayoutProps {
27
31
  readonly children: JSX.Element;
28
32
  }
29
33
 
34
+ /** The panel's id: the menu button and the close button both name it. */
35
+ const SIDE_ID = 'x-admin-side';
36
+
30
37
  /** The one rendering of "who am I acting as", so no two screens can disagree about it. */
31
- export const actorLabel = (actor: AdminActor | null | undefined): string =>
38
+ export const adminActorLabel = (actor: AdminActor | null | undefined): string =>
32
39
  actor === null || actor === undefined || actor.id === 'anonymous'
33
40
  ? t('admin.actor.anonymous')
34
41
  : t('admin.actor.signedIn', { id: actor.id, roles: (actor.roles ?? []).join(', ') });
@@ -44,10 +51,29 @@ export function AdminLayout(props: AdminLayoutProps): JSX.Element {
44
51
  data-density={theme['data-density']}
45
52
  style={theme.style}
46
53
  >
47
- <aside class={styles['side']}>
48
- <a class={styles['skip']} href="#x-admin-main">
49
- {t('admin.a11y.skip-to-content')}
50
- </a>
54
+ {/* Ahead of the panel, not inside it: a closed panel is `display: none`, and so would be
55
+ the first Tab stop on every admin screen. */}
56
+ <a class={styles['skip']} href="#x-admin-main">
57
+ {t('admin.a11y.skip-to-content')}
58
+ </a>
59
+
60
+ <div class={styles['bar']}>
61
+ <button type="button" class={styles['menuButton']} popovertarget={SIDE_ID}>
62
+ <Icon glyph={iconMenu} size="md" />
63
+ {t(UI_KEYS.menu)}
64
+ </button>
65
+ </div>
66
+
67
+ <aside id={SIDE_ID} class={styles['side']} popover="auto">
68
+ <button
69
+ type="button"
70
+ class={styles['close']}
71
+ popovertarget={SIDE_ID}
72
+ popovertargetaction="hide"
73
+ aria-label={t(UI_KEYS.close)}
74
+ >
75
+ <Icon glyph={iconX} size="md" />
76
+ </button>
51
77
 
52
78
  <a class={styles['brand']} href={base}>
53
79
  {props.app.branding.logo === undefined ? null : (
@@ -59,7 +85,7 @@ export function AdminLayout(props: AdminLayoutProps): JSX.Element {
59
85
  )}
60
86
  <span>{t(props.app.branding.nameKey)}</span>
61
87
  </a>
62
- <p class={styles['actor']}>{actorLabel(props.actor)}</p>
88
+ <p class={styles['actor']}>{adminActorLabel(props.actor)}</p>
63
89
 
64
90
  {/* A native GET at the search route: the term rides the URL, so a result page is a link
65
91
  an operator can send and the form needs no handler. */}
@@ -61,12 +61,32 @@ const LOCAL_MINUTE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}$/;
61
61
  /** An instant names its offset. A bare local time has no zone, and the admin never invents one. */
62
62
  const ZONED = /(?:Z|[+-]\d{2}:\d{2})$/;
63
63
 
64
+ /**
65
+ * A number as an operator types one: decimal digits, a sign, a point. `Number()` alone read a
66
+ * blank as `0`, `0x10` as 16 and `1e3` as 1000 — a filter nobody asked for, answered as a list.
67
+ */
68
+ const DECIMAL = /^-?\d+(?:\.\d+)?$/;
69
+
70
+ /**
71
+ * The `YYYY-MM-DD` a value opens with names a day the calendar HAS. `Date` rolls `2026-02-30`
72
+ * over to March 2nd without a word, so the shape test passed and the driver was handed a day
73
+ * nobody typed.
74
+ */
75
+ const onCalendar = (raw: string): boolean => {
76
+ const match = /^(\d{4})-(\d{2})-(\d{2})/.exec(raw);
77
+ if (match === null) return false;
78
+ const [year, month, day] = [Number(match[1]), Number(match[2]), Number(match[3])];
79
+ if (month < 1 || month > 12 || day < 1) return false;
80
+ return day <= new Date(Date.UTC(year, month, 0)).getUTCDate();
81
+ };
82
+
64
83
  /**
65
84
  * A typed instant → the ISO string a driver compares. `datetime-local` posts `YYYY-MM-DDTHH:mm`
66
85
  * and the control says UTC beside the box — the same contract the edit form states — and a bare
67
86
  * date is that day's first instant in UTC.
68
87
  */
69
88
  const instantOf = (raw: string): string | undefined => {
89
+ if (!onCalendar(raw)) return undefined;
70
90
  const text = DATE.test(raw)
71
91
  ? `${raw}T00:00:00.000Z`
72
92
  : LOCAL_MINUTE.test(raw)
@@ -92,14 +112,19 @@ function scalarOf(field: AdminField, raw: unknown): Scalar | Refusal {
92
112
  }
93
113
  case 'range': {
94
114
  if (field.type === 'number') {
95
- const parsed = typeof raw === 'number' ? raw : typeof raw === 'string' ? Number(raw) : NaN;
96
- return raw !== '' && Number.isFinite(parsed)
97
- ? parsed
98
- : { cause: `takes a number, got ${shown(raw)}` };
115
+ const parsed =
116
+ typeof raw === 'number'
117
+ ? raw
118
+ : typeof raw === 'string' && DECIMAL.test(raw)
119
+ ? Number(raw)
120
+ : NaN;
121
+ return Number.isFinite(parsed) ? parsed : { cause: `takes a number, got ${shown(raw)}` };
99
122
  }
100
123
  if (typeof raw !== 'string') return { cause: `takes a date as text, got ${shown(raw)}` };
101
124
  if (field.type === 'date') {
102
- return DATE.test(raw) ? raw : { cause: `takes a YYYY-MM-DD date, got ${shown(raw)}` };
125
+ return DATE.test(raw) && onCalendar(raw)
126
+ ? raw
127
+ : { cause: `takes a YYYY-MM-DD date, got ${shown(raw)}` };
103
128
  }
104
129
  return (
105
130
  instantOf(raw) ?? {
@@ -6,7 +6,7 @@
6
6
  //
7
7
  // ?cursor=<signed> keyset position (pagination.ts)
8
8
  // ?sort=<field>:<asc|desc> a sortable field; the direction defaults to desc
9
- // ?scope=<name> a declared scope; absent is the resource's default
9
+ // ?scope=<name> a declared scope; absent is the resource's default, `*` is none
10
10
  // ?f.<field>=<value> a filter, by the field's default operator
11
11
  // ?f.<field>.<op>=<value> a filter, by a named operator; repeat the parameter for `in`
12
12
  //
@@ -18,6 +18,7 @@ import type { AdminListRequest } from './list-scope';
18
18
  import { scopeNamed } from './list-scope';
19
19
  import type { AdminFilter, AdminSort } from './registry';
20
20
  import type { AdminResource } from './resource';
21
+ import { NO_SCOPE } from './resource-list';
21
22
 
22
23
  export const CURSOR_PARAM = 'cursor';
23
24
  export const SORT_PARAM = 'sort';
@@ -61,7 +62,7 @@ export function pageRequestOf(resource: AdminResource, url: URL): AdminListReque
61
62
  const filters: AdminFilter[] = [];
62
63
  let cursor: string | undefined;
63
64
  let sort: AdminSort | undefined;
64
- let scope: string | undefined;
65
+ let scope: string | null | undefined;
65
66
 
66
67
  for (const key of new Set(url.searchParams.keys())) {
67
68
  const values = url.searchParams.getAll(key).filter((value) => value !== '');
@@ -70,7 +71,8 @@ export function pageRequestOf(resource: AdminResource, url: URL): AdminListReque
70
71
  if (key === CURSOR_PARAM) cursor = last;
71
72
  else if (key === SORT_PARAM) sort = sortOf(resource, last);
72
73
  // Resolved here for its refusal alone: the name is what travels, the actor decides its rows.
73
- else if (key === SCOPE_PARAM) scope = scopeNamed(resource, last)?.name;
74
+ else if (key === SCOPE_PARAM)
75
+ scope = last === NO_SCOPE ? null : scopeNamed(resource, last)?.name;
74
76
  else if (key.startsWith(FILTER_PREFIX)) {
75
77
  filters.push(checkedFilter(resource, { ...filterKey(key), value: values }));
76
78
  } else {
@@ -108,9 +110,9 @@ export function listHref(
108
110
  location: ListLocation = {},
109
111
  ): string {
110
112
  const query = new URLSearchParams();
111
- if (location.scope !== undefined && location.scope !== null) {
112
- query.set(SCOPE_PARAM, location.scope);
113
- }
113
+ // `null` is "no scope, not even the default" — the request's own meaning of it — so it is
114
+ // written, as `*`; only an absent scope is left to the resource's default.
115
+ if (location.scope !== undefined) query.set(SCOPE_PARAM, location.scope ?? NO_SCOPE);
114
116
  for (const filter of location.filters ?? []) {
115
117
  const field = resource.filters.find((known) => known.name === filter.field);
116
118
  if (field === undefined) continue;
package/src/list.tsx CHANGED
@@ -139,7 +139,7 @@ export function AdminList<Row extends AdminRow>(props: AdminListProps<Row>): JSX
139
139
  rows={page.rows}
140
140
  rowKey={idOf}
141
141
  sort={{ key: page.sort.field, direction: page.sort.direction }}
142
- nextCursor={page.hasMore ? (page.nextCursor ?? undefined) : undefined}
142
+ nextCursor={page.hasMore ? page.nextCursor : undefined}
143
143
  prevCursor={page.prevCursor ?? undefined}
144
144
  hrefFor={(cursor) => props.hrefFor({ ...state, cursor })}
145
145
  // The header's NEXT state, as a URL. "Unsorted" is the list with no sort in it — the
@@ -0,0 +1,36 @@
1
+ // The admin MCP surface's scopes: which admin permission each tool needs as a token scope, and
2
+ // what a resolved token carries. The gate itself is `@ultimat3/mcp`'s registry — this file only
3
+ // declares the cut (`defineAppMcp({ scopes })`) and the token's half of it.
4
+
5
+ import type { McpScopes } from '@ultimat3/mcp';
6
+ import { expandPermissions } from './authz';
7
+ import type { AdminMcpTool } from './mcp-tools';
8
+ import { ADMIN_DESTROY, ADMIN_PERMISSIONS } from './permissions';
9
+
10
+ const isAdminPermission = (scope: string): boolean =>
11
+ (ADMIN_PERMISSIONS as readonly string[]).includes(scope);
12
+
13
+ /**
14
+ * The scopes a token resolved to, as the registry checks them: by string membership, so the
15
+ * admin's implications are expanded here (`admin:destroy` ⇒ `admin:write` ⇒ `admin:read`) and a
16
+ * token granted `admin:write` is not refused a read.
17
+ *
18
+ * `undefined` — the app's resolver stated no scopes — is a token as strong as its actor: every
19
+ * admin scope, with the actor's own policy still deciding each tool. That is what every admin MCP
20
+ * token was before tools carried a scope, so an app that never issued a narrower token is
21
+ * unchanged. An EMPTY list is a token granted nothing, never "unstated". A scope that is no admin
22
+ * permission (`posts:write`) is dropped: the admin's tools are gated on its own vocabulary only.
23
+ */
24
+ export function adminTokenScopes(granted: readonly string[] | undefined): ReadonlySet<string> {
25
+ return new Set(expandPermissions(granted ?? [ADMIN_DESTROY]).filter(isAdminPermission));
26
+ }
27
+
28
+ /**
29
+ * Scope → the tools it covers, for `defineAppMcp({ scopes })`. Every tool is in exactly one entry
30
+ * (its `scope`), so the map is total by construction and `X_MCP_SCOPE_UNCOVERED` cannot fire.
31
+ */
32
+ export function adminToolScopes(tools: readonly AdminMcpTool[]): McpScopes {
33
+ const by = new Map<string, string[]>();
34
+ for (const tool of tools) by.set(tool.scope, [...(by.get(tool.scope) ?? []), tool.name]);
35
+ return Object.fromEntries(by);
36
+ }
package/src/mcp-tools.ts CHANGED
@@ -5,12 +5,12 @@
5
5
  // Tool descriptions are literal English on purpose: they are protocol payload read by a
6
6
  // model, not UI copy read by a person, so they are not `t()` keys.
7
7
 
8
- import { decideAction, permissionsForAction } from './action-gate';
8
+ import { adminPermissionForAction, decideAction, permissionsForAction } from './action-gate';
9
9
  import type { AdminApp } from './admin';
10
10
  import { type AdminDecision, decideAll } from './authz';
11
11
  import { type CrudCtx, decideOperation, permissionsForOperation } from './crud';
12
12
  import type { AdminFieldType } from './fields';
13
- import type { AdminOperation } from './permissions';
13
+ import { type AdminOperation, type AdminPermission, adminPermissionFor } from './permissions';
14
14
  import type { AdminAction } from './registry';
15
15
  import type { AdminResource } from './resource';
16
16
 
@@ -32,6 +32,12 @@ export interface AdminMcpTool {
32
32
  readonly entity: string | null;
33
33
  readonly action: string | null;
34
34
  readonly permissions: readonly string[];
35
+ /**
36
+ * The MCP scope a token must carry to call it: the admin-level permission it needs — `admin:read`
37
+ * for a read and a `readonly` action, `admin:write` for a write, `admin:destroy` for a delete or a
38
+ * destructive action. The connection's gate, checked before the actor's own policy.
39
+ */
40
+ readonly scope: AdminPermission;
35
41
  /** Destructive tools require the confirmation token; agents must read before they delete. */
36
42
  readonly destructive: boolean;
37
43
  readonly input: readonly AdminToolField[];
@@ -110,6 +116,7 @@ function toolFor(resource: AdminResource, op: AdminOperation): AdminMcpTool | nu
110
116
  entity: resource.name,
111
117
  action: null,
112
118
  permissions: permissionsForOperation(resource.permission, 'list'),
119
+ scope: adminPermissionFor('list'),
113
120
  destructive: false,
114
121
  input: [
115
122
  CURSOR_FIELD,
@@ -126,6 +133,7 @@ function toolFor(resource: AdminResource, op: AdminOperation): AdminMcpTool | nu
126
133
  entity: resource.name,
127
134
  action: null,
128
135
  permissions: permissionsForOperation(resource.permission, 'detail'),
136
+ scope: adminPermissionFor('detail'),
129
137
  destructive: false,
130
138
  input: [ID_FIELD],
131
139
  };
@@ -137,6 +145,7 @@ function toolFor(resource: AdminResource, op: AdminOperation): AdminMcpTool | nu
137
145
  entity: resource.name,
138
146
  action: null,
139
147
  permissions: permissionsForOperation(resource.permission, 'create'),
148
+ scope: adminPermissionFor('create'),
140
149
  destructive: false,
141
150
  input: formFields(resource),
142
151
  };
@@ -148,6 +157,7 @@ function toolFor(resource: AdminResource, op: AdminOperation): AdminMcpTool | nu
148
157
  entity: resource.name,
149
158
  action: null,
150
159
  permissions: permissionsForOperation(resource.permission, 'update'),
160
+ scope: adminPermissionFor('update'),
151
161
  destructive: false,
152
162
  input: [ID_FIELD, ...formFields(resource)],
153
163
  };
@@ -159,6 +169,7 @@ function toolFor(resource: AdminResource, op: AdminOperation): AdminMcpTool | nu
159
169
  entity: resource.name,
160
170
  action: null,
161
171
  permissions: permissionsForOperation(resource.permission, 'delete'),
172
+ scope: adminPermissionFor('delete'),
162
173
  destructive: true,
163
174
  input: [ID_FIELD, { name: 'confirmation', type: 'text', required: true }],
164
175
  };
@@ -202,6 +213,7 @@ function toolGates(app: AdminApp): readonly ToolGate[] {
202
213
  entity: action.entity ?? null,
203
214
  action: action.name,
204
215
  permissions: permissionsForAction(action),
216
+ scope: adminPermissionForAction(action),
205
217
  destructive: action.destructive === true,
206
218
  input: actionEnvelope(action),
207
219
  },
@@ -220,6 +232,7 @@ function toolGates(app: AdminApp): readonly ToolGate[] {
220
232
  entity: null,
221
233
  action: null,
222
234
  permissions: permissionsForOperation('admin', 'search'),
235
+ scope: adminPermissionFor('search'),
223
236
  destructive: false,
224
237
  input: [{ name: 'term', type: 'text', required: true }],
225
238
  },
@@ -237,6 +250,7 @@ function toolGates(app: AdminApp): readonly ToolGate[] {
237
250
  entity: null,
238
251
  action: action.name,
239
252
  permissions: permissionsForAction(action),
253
+ scope: adminPermissionForAction(action),
240
254
  destructive: action.destructive === true,
241
255
  input: actionEnvelope(action),
242
256
  },