@ultimat3/admin 30.9.0 → 31.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CLAUDE.md CHANGED
@@ -46,7 +46,7 @@ Two products, one package, **two entry points**: `@ultimat3/admin/dev` (`src/dev
46
46
  - **An edit is optimistic** (`row-version.ts`): the form posts `_version` = `rowVersion(row)` (keyed), `adminUpdate(…, { version })` answers `stale` — 409, `admin.error.row-changed`, the form re-drawn over the row as it is now — when the row moved on; a post with no version is stale. An instant posted back as it was drawn (`datetimeInputValue`) is LEFT OUT of the patch (`decodeForm(…, before)`): `datetime-local` holds minutes. A create or update is decided again on the validated values as `subject.input` (update: beside `row`).
47
47
  - **The audit trail is the actor's tenant's**: `/admin/audit` and the history card pass `orgId` to `entries()` when the actor has one (`audit-tenancy.test.ts`).
48
48
  - **A table read by a runtime key is `Object.hasOwn` / a `Map`, never a bare index** — `impliedBy`, the `/_x` counters, `list-filters.ts`' operator table, `renderers.tsx` (`bun run proto-index`).
49
- - **The `/_x` SQL runs in `readOnlyQuery`** (`dev/sql-runner.ts`, `readOnlySql(client)` — what a host wires as `runSql`): read-only transaction, timeout, `DEV_SQL_MAX_ROWS`.
49
+ - **The `/_x` SQL runs in `readOnlyQuery`** (`dev/sql-runner.ts`, `readOnlySql(client)`, a host's `runSql`): read-only txn, timeout, `DEV_SQL_MAX_ROWS`, SELECT-only role.
50
50
  - **One read-only SQL guard, and it is `@ultimat3/mcp`'s** — no local keyword scan; the panel keeps only the emptiness test and the way out (`psql "$DATABASE_URL"`, the developer's own client — `x db` has no `psql`).
51
51
  - **Every admin operation is audited, reads included**, not `/admin`'s counts (a count reads no row). `ListResult` carries its `AuditEntry` on both branches (`entityId: null`); `AdminSearchResult.audit` one entry per resource decided, and a skipped resource (no text field, no repo, a repo that threw) is in `skipped`, never a 500.
52
52
  - **A write's RESULT satisfies the actor's row scope** (`row-scope-write.ts`): a create, and an update over the fields it touches, outside `rows(actor)` is refused before the repo — `admin.error.row-out-of-scope`, audited as a denial. The one predicate evaluator in this package; an ordering over text is refused, never guessed. `row-scope-write.test.ts`.
@@ -109,6 +109,6 @@ From the repo root — the seal-key preload is the root's, so `bun test --filter
109
109
  directory fails on the first sealed column:
110
110
 
111
111
  ```bash
112
- bun test packages/admin
112
+ bun test ./packages/admin/
113
113
  bunx tsc --noEmit -p packages/admin/tsconfig.json
114
114
  ```
package/README.md CHANGED
@@ -32,7 +32,7 @@ One panel per file. Each kills one question, and each is available as `--json`
32
32
  | `timeline` | where did the time go? — a server-rendered waterfall of SQL, cache, action, policy spans (one row per span, bar placed by the payload's own `offset`/`width`), time per kind, and the N+1 detector's verdicts with their fix lines; the payload folded under it |
33
33
  | `live` | what does each subscriber receive, and **why** — the matcher's decision trace |
34
34
  | `jobs` | a ring of the recent runs by state (`DonutChart`), then the admin's own jobs overview (`/admin/jobs`'s component) over this process's queue; the payload — queue depth, step traces, retry-from-step target, dead letter — folded under it |
35
- | `db` | psql in a tab (read-only twice: `assertReadOnly` refuses DML, and `readOnlySql(client)` — what a host wires as `runSql` — runs it in a `BEGIN READ ONLY` transaction with a timeout and `DEV_SQL_MAX_ROWS`), schema + drift (`null` unless a host wires the check) |
35
+ | `db` | psql in a tab (read-only twice: `assertReadOnly` refuses DML, and `readOnlySql(client)` — what a host wires as `runSql` — runs it in a `BEGIN READ ONLY` transaction with a timeout, `DEV_SQL_MAX_ROWS` and the SELECT-only role `ensureReadOnlyRole` grants), schema + drift (`null` unless a host wires the check) |
36
36
  | `mail` | caught mail, rendered, per locale, with the locale gaps listed; beside it every declared mail's `preview` rendered per locale (`mailTemplates`, the host's source: `x dev` wires it) |
37
37
  | `cache` | the tag graph — what invalidated what, and which tags are orphans |
38
38
  | `policy` | the permission matrix per actor, every cell carrying its trace |
@@ -330,9 +330,9 @@ import { AdminList, listHref, Widget } from '@ultimat3/admin';
330
330
  <Widget field={field} value={row.total} ctx={ctx} mode="read" />;
331
331
  ```
332
332
 
333
- `Widget` takes the field and the raw row value, not pre-derived props: `widgetProps()` is the
334
- guard (money is minor units, a timestamp has an IANA zone) and the component calls it, so there is
335
- no way to render a cell that skipped it.
333
+ `Widget` takes the field and the raw row value, not pre-derived props: the internal
334
+ `widgetProps()` is the guard (money is minor units, a timestamp has an IANA zone) and the component
335
+ calls it, so there is no way to render a cell that skipped it.
336
336
 
337
337
  ### Mounting it yourself
338
338
 
@@ -522,6 +522,8 @@ Opt-in AI panes, each declaring the scope it needs (`aiPanes({ enable: ['anomaly
522
522
  | `backlog-forecast` | `jobs:read`, `metrics:read` |
523
523
 
524
524
  Panes are off until enabled, and `runAiPane` refuses (never no-ops) without a runner.
525
+ `GatewayAdapter` — the type a host narrows `@ultimat3/ai`'s `Gateway` to an `AiRunner` with — is
526
+ `@ultimat3/admin/adapter`'s, off the barrel.
525
527
 
526
528
  ## Errors
527
529
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ultimat3/admin",
3
- "version": "30.9.0",
3
+ "version": "31.1.0",
4
4
  "description": "Two dashboards: the /_x framework dev panels and the generated, AI-first app admin",
5
5
  "keywords": [
6
6
  "ultimate",
@@ -25,6 +25,7 @@
25
25
  },
26
26
  "exports": {
27
27
  ".": "./src/index.ts",
28
+ "./adapter": "./src/adapter.ts",
28
29
  "./dev": "./src/dev/index.ts",
29
30
  "./schema": "./src/audit-schema.ts",
30
31
  "./error-groups": "./src/error-groups.ts"
@@ -45,21 +46,21 @@
45
46
  "test": "bun test"
46
47
  },
47
48
  "dependencies": {
48
- "@ultimat3/action": "30.9.0",
49
- "@ultimat3/ai": "30.9.0",
50
- "@ultimat3/cache": "30.9.0",
51
- "@ultimat3/core": "30.9.0",
52
- "@ultimat3/db": "30.9.0",
53
- "@ultimat3/entity": "30.9.0",
54
- "@ultimat3/http": "30.9.0",
55
- "@ultimat3/i18n": "30.9.0",
56
- "@ultimat3/jobs": "30.9.0",
57
- "@ultimat3/mcp": "30.9.0",
58
- "@ultimat3/money": "30.9.0",
59
- "@ultimat3/policy": "30.9.0",
60
- "@ultimat3/query": "30.9.0",
61
- "@ultimat3/render": "30.9.0",
62
- "@ultimat3/schema": "30.9.0",
63
- "@ultimat3/ui": "30.9.0"
49
+ "@ultimat3/action": "31.1.0",
50
+ "@ultimat3/ai": "31.1.0",
51
+ "@ultimat3/cache": "31.1.0",
52
+ "@ultimat3/core": "31.1.0",
53
+ "@ultimat3/db": "31.1.0",
54
+ "@ultimat3/entity": "31.1.0",
55
+ "@ultimat3/http": "31.1.0",
56
+ "@ultimat3/i18n": "31.1.0",
57
+ "@ultimat3/jobs": "31.1.0",
58
+ "@ultimat3/mcp": "31.1.0",
59
+ "@ultimat3/money": "31.1.0",
60
+ "@ultimat3/policy": "31.1.0",
61
+ "@ultimat3/query": "31.1.0",
62
+ "@ultimat3/render": "31.1.0",
63
+ "@ultimat3/schema": "31.1.0",
64
+ "@ultimat3/ui": "31.1.0"
64
65
  }
65
66
  }
package/src/adapter.ts ADDED
@@ -0,0 +1,5 @@
1
+ // `@ultimat3/admin/adapter` — what a host wiring an AI gateway into the admin reads: the shape of
2
+ // the function that narrows `@ultimat3/ai`'s `Gateway` to the runner a pane may call. Never the
3
+ // `.` barrel: an app declares an admin; only the code that hands it a gateway needs this.
4
+
5
+ export type { GatewayAdapter } from './ai-panes';
package/src/admin.ts CHANGED
@@ -105,6 +105,11 @@ export interface DefineAdminInput {
105
105
 
106
106
  export interface AdminApp {
107
107
  readonly basePath: string;
108
+ /**
109
+ * Where "back to the app" goes: `/` as declared; the root a request was served under behind an
110
+ * HTTP base path or a routed locale (`/myapp/es/`) — `adminAppFor`, `localized-app.ts`.
111
+ */
112
+ readonly home?: string;
108
113
  readonly branding: AdminBranding;
109
114
  readonly theme: ThemeAttributes;
110
115
  readonly resources: readonly AdminResource[];
@@ -414,6 +419,7 @@ export function defineAdmin(input: DefineAdminInput): AdminApp {
414
419
 
415
420
  const app: AdminApp = {
416
421
  basePath,
422
+ home: '/',
417
423
  branding,
418
424
  theme: themeAttributes(branding),
419
425
  resources,
package/src/audit-pg.ts CHANGED
@@ -3,6 +3,7 @@
3
3
  // every restart, and a row's history that ends at the last deploy is not a history.
4
4
 
5
5
  import { isUltimateError, uuidV7 } from '@ultimat3/core';
6
+ import type { AuditSink } from '@ultimat3/core/adapter';
6
7
  import { type DbClient, db, withTransaction } from '@ultimat3/db';
7
8
  import {
8
9
  AUDIT_READ_OPERATIONS,
@@ -12,7 +13,6 @@ import {
12
13
  type AuditLog,
13
14
  type AuditOutcome,
14
15
  type AuditQuery,
15
- type AuditSink,
16
16
  auditEntry,
17
17
  auditLimit,
18
18
  auditWindow,
@@ -138,7 +138,7 @@ export interface PostgresAuditLogOptions {
138
138
  * read is an event and is always written.
139
139
  */
140
140
  readonly reads?: boolean;
141
- readonly sinks?: readonly AuditSink[];
141
+ readonly sinks?: readonly AuditSink<AuditEntry>[];
142
142
  readonly now?: () => Date;
143
143
  /** Must answer a uuid: the column is one. Omitted, a time-ordered v7. */
144
144
  readonly nextId?: () => string;
package/src/audit.ts CHANGED
@@ -3,6 +3,7 @@
3
3
  // there is deliberately no update or delete on this interface.
4
4
 
5
5
  import { canonicalJson, finiteCount, REDACTED } from '@ultimat3/core';
6
+ import type { AuditSink } from '@ultimat3/core/adapter';
6
7
  import type { AdminActor, AdminDecision } from './authz';
7
8
  import { AdminFilterInvalidError } from './errors';
8
9
  import { instantOf } from './list-filters';
@@ -55,11 +56,6 @@ export interface AuditDraft {
55
56
  readonly diff?: readonly AuditFieldDiff[];
56
57
  }
57
58
 
58
- /** Where a copy of every entry goes besides the log's own store: stdout as JSON lines, OTel. */
59
- export interface AuditSink {
60
- write(entry: AuditEntry): Promise<void> | void;
61
- }
62
-
63
59
  /** A position in the trail: the entry a page ended on. Entries are ordered `at` then `id`. */
64
60
  export interface AuditCursor {
65
61
  readonly at: string;
@@ -113,7 +109,11 @@ export interface AuditLog {
113
109
  }
114
110
 
115
111
  export interface AuditLogOptions {
116
- readonly sinks?: readonly AuditSink[];
112
+ /**
113
+ * Where a copy of every entry goes besides the log's own store: stdout as JSON lines, OTel.
114
+ * `@ultimat3/core`'s one `AuditSink`, over this package's entry — there is no second interface.
115
+ */
116
+ readonly sinks?: readonly AuditSink<AuditEntry>[];
117
117
  /** Injected so tests get deterministic timestamps and ids. */
118
118
  readonly now?: () => Date;
119
119
  readonly nextId?: () => string;
@@ -39,23 +39,39 @@ export interface TimelinePanelData {
39
39
  readonly nPlusOne: readonly StatementLoopFact[] | null;
40
40
  }
41
41
 
42
+ /**
43
+ * Depth-first by start time. A span whose parent is not in this recording — work handed off after
44
+ * its trace's early spans were evicted, or a host's own parenting — is drawn at depth 0, as is one
45
+ * caught in a parent cycle: `totalsByKind` counts every span, so the flame must draw every span,
46
+ * and a walk from `null` alone left those out of it without a word.
47
+ */
42
48
  function flatten(trace: RequestTrace): readonly FlameRow[] {
49
+ const ids = new Set(trace.spans.map((span) => span.id));
43
50
  const byParent = new Map<string | null, TimelineSpan[]>();
44
51
  for (const span of trace.spans) {
45
- const bucket = byParent.get(span.parentId) ?? [];
52
+ const parent =
53
+ span.parentId !== null && span.parentId !== span.id && ids.has(span.parentId)
54
+ ? span.parentId
55
+ : null;
56
+ const bucket = byParent.get(parent) ?? [];
46
57
  bucket.push(span);
47
- byParent.set(span.parentId, bucket);
58
+ byParent.set(parent, bucket);
48
59
  }
49
60
  const total = trace.totalMs === 0 ? 1 : trace.totalMs;
50
61
  const rows: FlameRow[] = [];
62
+ const drawn = new Set<TimelineSpan>();
63
+ const byStart = (spans: readonly TimelineSpan[]) =>
64
+ [...spans].sort((a, b) => a.startMs - b.startMs);
51
65
 
52
- const walk = (parentId: string | null, depth: number): void => {
53
- for (const span of (byParent.get(parentId) ?? []).sort((a, b) => a.startMs - b.startMs)) {
54
- rows.push({ span, depth, offset: span.startMs / total, width: span.durationMs / total });
55
- walk(span.id, depth + 1);
56
- }
66
+ const draw = (span: TimelineSpan, depth: number): void => {
67
+ if (drawn.has(span)) return;
68
+ drawn.add(span);
69
+ rows.push({ span, depth, offset: span.startMs / total, width: span.durationMs / total });
70
+ for (const child of byStart(byParent.get(span.id) ?? [])) draw(child, depth + 1);
57
71
  };
58
- walk(null, 0);
72
+ for (const span of byStart(byParent.get(null) ?? [])) draw(span, 0);
73
+ // Only a cycle of two or more spans is left: none of them is anyone's root.
74
+ for (const span of byStart(trace.spans)) draw(span, 0);
59
75
  return rows;
60
76
  }
61
77
 
@@ -1,8 +1,9 @@
1
1
  // The `/_x` DB panel's statement, RUN: inside `readOnlyQuery` — its own `BEGIN READ ONLY`
2
- // transaction, a statement timeout and a row ceiling — never through the app's plain `db.query`.
3
- // A host wires `runSql: readOnlySql(client)`; the guard upstream decides what reaches it.
2
+ // transaction, a statement timeout, a row ceiling and the SELECT-only role — never through the
3
+ // app's plain `db.query`. A host wires `runSql: readOnlySql(client)`; the guard upstream decides
4
+ // what reaches it.
4
5
 
5
- import { type DbClient, readOnlyQuery } from '@ultimat3/db';
6
+ import { type DbClient, ensureReadOnlyRole, readOnlyQuery } from '@ultimat3/db';
6
7
  import type { SqlResult } from './facts';
7
8
 
8
9
  /**
@@ -14,15 +15,30 @@ export const DEV_SQL_MAX_ROWS = 500;
14
15
  /**
15
16
  * The runner a `/_x` host hands the DB panel. `assertReadOnlyQuery` (`panel-db.ts`) is the parse
16
17
  * guard; this is the SERVER saying no: a statement the guard let through that writes, locks or
17
- * runs away still meets a read-only transaction that always rolls back, with the app's own
18
- * credentials and no way to commit. Columns are the first row's keys — the driver hands objects.
18
+ * runs away still meets a read-only transaction that always rolls back, run as the role
19
+ * `ensureReadOnlyRole` grants SELECT and nothing else — the layer MCP's `db.query` already had.
20
+ * The app's own credentials are often a dev superuser, so without it a parse-guard miss met one
21
+ * layer, not two. Columns are the first row's keys — the driver hands objects.
22
+ *
23
+ * The role's DDL runs until it succeeds, then never again for this runner. `ensureReadOnlyRole`
24
+ * never throws: a database that refuses `CREATE ROLE` answers `null`, the read runs without that
25
+ * layer, and the next statement asks again.
19
26
  */
20
27
  export function readOnlySql(client: DbClient): (sql: string) => Promise<SqlResult> {
28
+ let role: Promise<string | null> | undefined;
21
29
  return async (sql) => {
30
+ role ??= ensureReadOnlyRole(client);
31
+ const assumed = await role;
32
+ // `null` is not an answer to keep: a lost race (`x mcp serve` creating the role at the same
33
+ // moment) reads exactly like a refusal, and caching it ran every later statement without the
34
+ // layer. Asked again on the next statement; a role that exists is kept.
35
+ if (assumed === null) role = undefined;
36
+ // After the role: the panel times the statement, not the first call's one-off DDL.
22
37
  const started = performance.now();
23
38
  const { rows } = await readOnlyQuery<Readonly<Record<string, unknown>>>(sql, {
24
39
  client,
25
40
  maxRows: DEV_SQL_MAX_ROWS,
41
+ role: assumed,
26
42
  });
27
43
  const elapsedMs = Math.round(performance.now() - started);
28
44
  const columns = Object.keys(rows[0] ?? {});
@@ -7,6 +7,7 @@ import {
7
7
  errorFingerprint,
8
8
  finiteCount,
9
9
  isRedactedKey,
10
+ localErrorSink,
10
11
  logger,
11
12
  REDACTED,
12
13
  renderThrowable,
@@ -158,7 +159,9 @@ export function errorGroupsSink(options: ErrorGroupsSinkOptions): ErrorGroupsSin
158
159
  }
159
160
  };
160
161
 
161
- return {
162
+ // Local: the groups live in this app's own table, so a boot with `SENTRY_DSN` still adds its
163
+ // transport beside this sink rather than reading it as the app's monitor.
164
+ return localErrorSink({
162
165
  report(report: ErrorReport): void {
163
166
  try {
164
167
  if (options.ignore?.(report) === true) return;
@@ -177,5 +180,5 @@ export function errorGroupsSink(options: ErrorGroupsSinkOptions): ErrorGroupsSin
177
180
  async settled(): Promise<void> {
178
181
  while (pending.size > 0) await Promise.all([...pending]);
179
182
  },
180
- };
183
+ });
181
184
  }
@@ -113,6 +113,7 @@ export function memoryErrorGroupsStore(): ErrorGroupsStore {
113
113
  ...seen,
114
114
  message: occurrence.message,
115
115
  status: statusAfterOccurrence(seen.status),
116
+ // biome-ignore lint/complexity/useMathMinMax: ISO-8601 STRINGS, compared as text — Math.max would return NaN.
116
117
  lastSeen: at > seen.lastSeen ? at : seen.lastSeen,
117
118
  count: seen.count + occurrence.count,
118
119
  sample: occurrence.sample,
package/src/index.ts CHANGED
@@ -2,13 +2,10 @@
2
2
  // Explicit exports only — a barrel that re-exports everything is how internal helpers become
3
3
  // someone's dependency.
4
4
 
5
- export { AdminActionForm, type AdminActionFormProps } from './action-form';
6
5
  export {
7
- ACTION_NOT_APPLICABLE_REASON,
8
6
  type ActionGateInput,
9
7
  type AdminActionButton,
10
8
  actionButtons,
11
- actionDecisions,
12
9
  adminPermissionForAction,
13
10
  decideAction,
14
11
  type InvokeInput,
@@ -16,30 +13,9 @@ export {
16
13
  invokeAdminAction,
17
14
  permissionsForAction,
18
15
  } from './action-gate';
19
- export {
20
- ACTION_INPUT_PREFIX,
21
- type ActionInputControl,
22
- type ActionInputField,
23
- actionInputFields,
24
- decodeActionInput,
25
- } from './action-input';
26
16
  export { invokeRowAction, type RowActionInput } from './action-row';
27
- export {
28
- ACTION_OPERATION,
29
- ACTION_PARAM,
30
- AdminActions,
31
- type AdminActionsProps,
32
- actionFormHref,
33
- BATCH_OPERATION,
34
- DELETE_OPERATION,
35
- OPERATION_FIELD,
36
- } from './actions';
37
- export {
38
- type AdminRequestActor,
39
- ANONYMOUS_ADMIN_ACTOR,
40
- adminActorFrom,
41
- requestActor,
42
- } from './actor';
17
+ export { AdminActions, type AdminActionsProps } from './actions';
18
+ export { adminActorFrom } from './actor';
43
19
  export {
44
20
  type AdminApp,
45
21
  type AdminAuth,
@@ -58,12 +34,9 @@ export {
58
34
  type AiPanesOptions,
59
35
  type AiRunner,
60
36
  aiPanes,
61
- type GatewayAdapter,
62
37
  runAiPane,
63
38
  } from './ai-panes';
64
39
  export {
65
- AUDIT_PAGE_DEFAULT,
66
- AUDIT_PAGE_MAX,
67
40
  type AuditCursor,
68
41
  type AuditDraft,
69
42
  type AuditEntry,
@@ -72,25 +45,15 @@ export {
72
45
  type AuditLogOptions,
73
46
  type AuditOutcome,
74
47
  type AuditQuery,
75
- type AuditSink,
76
48
  auditCursorOf,
77
- auditEntry,
78
49
  auditEntryMatches,
79
50
  auditWindow,
80
51
  deniedDraft,
81
52
  diffRows,
82
53
  memoryAuditLog,
83
54
  } from './audit';
84
- export {
85
- type PostgresAuditLogOptions,
86
- postgresAuditLog,
87
- } from './audit-pg';
88
- export {
89
- type AuditRequest,
90
- auditHref,
91
- auditRequestOf,
92
- HISTORY_PARAM,
93
- } from './audit-request';
55
+ export { type PostgresAuditLogOptions, postgresAuditLog } from './audit-pg';
56
+ export { type AuditRequest, auditHref, auditRequestOf, HISTORY_PARAM } from './audit-request';
94
57
  // The audit table's DDL is `@ultimat3/admin/schema`'s alone — one import path for one statement.
95
58
  export {
96
59
  type AdminActor,
@@ -100,36 +63,23 @@ export {
100
63
  type AdminSubject,
101
64
  adminAllowed,
102
65
  adminDenied,
103
- anonymousAuthz,
104
66
  decideAll,
105
67
  expandPermissions,
106
- isAllowed,
107
68
  staticAuthz,
108
69
  } from './authz';
109
70
  export {
110
71
  type AdminBatchAnswer,
111
72
  type AdminBatchInput,
112
73
  type AdminBatchResult,
113
- BATCH_NOT_OFFERED_REASON,
114
74
  type BatchEnqueue,
115
75
  type BatchRow,
116
76
  type BatchRowOutcome,
117
77
  type BatchSelection,
118
- batchConfirmationToken,
119
78
  batchPlan,
120
79
  MAX_BATCH_QUEUED_ROWS,
121
80
  MAX_BATCH_ROWS,
122
81
  runAdminBatch,
123
82
  } from './batch';
124
- export {
125
- AdminBatchBar,
126
- type AdminBatchBarProps,
127
- } from './batch-bar';
128
- export {
129
- ADMIN_BATCH_JOB,
130
- batchEnqueue,
131
- } from './batch-job';
132
- export { BATCH_MATCHING_REASON, matchingConfirmationToken } from './batch-matching';
133
83
  export { BATCH_QUEUED_REASON } from './batch-queue';
134
84
  export {
135
85
  type AdminClaimData,
@@ -151,37 +101,26 @@ export {
151
101
  type ListResult,
152
102
  permissionsForOperation,
153
103
  } from './crud';
154
- export {
155
- type AdminActionDescription,
156
- type AdminDescription,
157
- type AdminResourceDescription,
158
- type AdminRouteDescription,
159
- type AdminScopeDescription,
160
- type AdminSectionDescription,
161
- describeAdmin,
104
+ export type {
105
+ AdminActionDescription,
106
+ AdminDescription,
107
+ AdminResourceDescription,
108
+ AdminRouteDescription,
109
+ AdminScopeDescription,
110
+ AdminSectionDescription,
162
111
  } from './describe';
163
- export {
164
- AdminDetail,
165
- type AdminDetailProps,
166
- } from './detail';
167
- export {
168
- type AdminColumnFacts,
169
- type AdminColumnReference,
170
- adminColumnsOf,
171
- adminSealedColumnsOf,
172
- } from './entity-columns';
112
+ export { AdminDetail, type AdminDetailProps } from './detail';
113
+ export type { AdminColumnFacts, AdminColumnReference } from './entity-columns';
173
114
  // The screens only. The sink and its stores are `@ultimat3/admin/error-groups`'s alone: every role
174
115
  // installs the sink, and this barrel would carry every admin screen into a worker's graph.
175
116
  export { ERRORS_READ, type ErrorGroupsPagesOptions, errorGroupsPages } from './error-groups-pages';
117
+ export type { AdminErrorCode } from './errors';
176
118
  // The /_x dashboard is NOT re-exported here — it has its own door, `@ultimat3/admin/dev`, so a
177
119
  // host that only mounts the dev panels never loads a production admin component.
178
120
  export {
179
- ADMIN_ERROR_CODES,
180
- ADMIN_ERROR_TITLES,
181
121
  AdminActionDuplicateError,
182
122
  AdminActionNotApplicableError,
183
123
  AdminEntityUnknownError,
184
- type AdminErrorCode,
185
124
  type AdminErrorParts,
186
125
  AdminFieldUnsupportedError,
187
126
  AdminFilterInvalidError,
@@ -190,7 +129,6 @@ export {
190
129
  AdminPageUnguardedError,
191
130
  AdminPolicyMissingError,
192
131
  AdminRepoUnboundError,
193
- adminErrorFrom,
194
132
  DevDashboardInProdError,
195
133
  DevSourceUnavailableError,
196
134
  } from './errors';
@@ -199,51 +137,19 @@ export {
199
137
  type AdminFieldType,
200
138
  type AdminFilterKind,
201
139
  type AdminWidget,
202
- fieldTypeFromColumn,
203
140
  filterable,
204
- filterKindFor,
205
141
  listable,
206
142
  searchable,
207
143
  sortable,
208
- WIDGET_BY_FIELD_TYPE,
209
- widgetFor,
210
144
  } from './fields';
211
- export { AdminForm, type AdminFormProps, issueText } from './form';
212
- export { currencyFieldOf, decodeForm, posted } from './form-decode';
145
+ export { AdminForm, type AdminFormProps } from './form';
146
+ export { posted } from './form-decode';
213
147
  export { JOB_MANAGE, JOB_READ } from './jobs/job-actions';
214
- export { JOB_ENTITY } from './jobs/job-entities';
215
- export { JOBS_PATH, jobRowScope } from './jobs/job-resources';
148
+ export { jobRowScope } from './jobs/job-resources';
216
149
  export { AdminLayout, type AdminLayoutProps, adminActorLabel } from './layout';
217
150
  export { AdminList, type AdminListProps } from './list';
218
- export { type ResourceColumnsInput, resourceColumns } from './list-columns';
219
- export { AdminFilterBar, type AdminFilterBarProps } from './list-filter-bar';
220
- export {
221
- type AskedFilter,
222
- checkedFilter,
223
- declaredFilters,
224
- FILTER_PREFIX,
225
- filterOpsOf,
226
- filterParam,
227
- knownFilterParams,
228
- } from './list-filters';
229
- export {
230
- CURSOR_PARAM,
231
- type ListLocation,
232
- listHref,
233
- pageRequestOf,
234
- SCOPE_PARAM,
235
- SORT_PARAM,
236
- } from './list-request';
237
- export {
238
- type AdminListRequest,
239
- findRow,
240
- type ListWhere,
241
- listWhere,
242
- rowWhere,
243
- scopeCounts,
244
- scopeNamed,
245
- } from './list-scope';
246
- export { AdminScopeTabs, type AdminScopeTabsProps } from './list-scope-tabs';
151
+ export { type ListLocation, listHref, pageRequestOf } from './list-request';
152
+ export { type AdminListRequest, findRow, type ListWhere, listWhere } from './list-scope';
247
153
  export { type AdminMcpActor, type AdminMcpOptions, adminMcp } from './mcp';
248
154
  export { type AdminToolResult, callAdminTool, type McpInput } from './mcp-dispatch';
249
155
  export { adminTokenScopes, adminToolScopes } from './mcp-scopes';
@@ -253,40 +159,21 @@ export {
253
159
  type AdminToolKind,
254
160
  adminMcpTools,
255
161
  adminToolCatalog,
256
- adminToolDecisions,
257
162
  } from './mcp-tools';
258
- export {
259
- ADMIN_MOUNTS,
260
- adminMountAt,
261
- adminMounts,
262
- clearAdminMounts,
263
- registerAdminMount,
264
- } from './mounts';
265
- export { adminNav, type NavGroup, type NavItem, type NavOptions, visibleNav } from './nav';
266
- export { AdminPageDenied, auditRefusal } from './page-guard';
163
+ export { ADMIN_MOUNTS, adminMounts, clearAdminMounts } from './mounts';
164
+ export { type NavGroup, type NavItem, type NavOptions, visibleNav } from './nav';
165
+ export { AdminPageDenied } from './page-guard';
267
166
  export {
268
167
  type AdminCustomPage,
269
168
  type AdminPageComponent,
270
169
  type AdminPageProps,
271
- pageNavItems,
272
170
  pagePermissions,
273
171
  pageRoutes,
274
172
  } from './pages';
275
- export {
276
- type AdminCursor,
277
- type AdminPage,
278
- decodeAdminCursor,
279
- encodeAdminCursor,
280
- fetchPage,
281
- listQuery,
282
- type PageRequest,
283
- pageFrom,
284
- } from './pagination';
173
+ export { type AdminCursor, type AdminPage, type PageRequest, pageFrom } from './pagination';
285
174
  export {
286
175
  ADMIN_DESTROY,
287
- ADMIN_IMPERSONATE,
288
176
  ADMIN_OPERATION_RULES,
289
- ADMIN_OPERATIONS,
290
177
  ADMIN_PERMISSION_SPEC,
291
178
  ADMIN_PERMISSIONS,
292
179
  ADMIN_READ,
@@ -294,8 +181,6 @@ export {
294
181
  type AdminOperation,
295
182
  type AdminPermission,
296
183
  type AdminPermissionRule,
297
- adminPermissionFor,
298
- CONFIRMATION_REQUIRED_REASON,
299
184
  confirmationToken,
300
185
  entityPermissionFor,
301
186
  isDestructive,
@@ -327,64 +212,38 @@ export {
327
212
  type AdminSort,
328
213
  type AdminTable,
329
214
  type AdminTableRead,
330
- computedRow,
331
- FILTER_OPS,
332
215
  type FilterOp,
333
216
  type KeysetBound,
334
- type RegisteredEntity,
335
- type RegisteredTable,
336
217
  readField,
337
218
  rowId,
338
219
  } from './registry';
220
+ export { type AdminRelated, type AdminRelatedList, relatedLists, relatedOf } from './related';
339
221
  export {
340
- type AdminRelated,
341
- type AdminRelatedList,
342
- RELATED_PAGE_SIZE,
343
- relatedLists,
344
- relatedOf,
345
- } from './related';
346
- export {
347
- type AdminLookupRequest,
348
- type AdminLookupResult,
349
222
  type AdminOption,
350
- adminLookup,
351
- LOOKUP_SELECT_MAX,
352
223
  labelOf,
353
224
  type RelationData,
354
225
  type RelationNeed,
355
226
  relationDataFor,
356
- relationNeeds,
357
227
  } from './relations';
358
- export { ComputedCell, TRUNCATE_AT } from './renderers';
359
- export { adminEntitiesOf, adminRepoFor, adminTablesOf } from './repo-entity';
360
- export {
361
- type AdminResource,
362
- type AdminResourceOptions,
363
- adminResource,
364
- repoOf,
365
- resourceFor,
366
- } from './resource';
228
+ export { adminEntitiesOf, adminRepoFor } from './repo-entity';
229
+ export { type AdminResource, type AdminResourceOptions, adminResource, repoOf } from './resource';
367
230
  export type { AdminFieldOverride } from './resource-fields';
368
- export {
369
- type AdminFormGroupOptions,
370
- type AdminSection,
371
- type AdminSectionOptions,
372
- DEFAULT_SECTION_KEY,
231
+ export type {
232
+ AdminFormGroupOptions,
233
+ AdminSection,
234
+ AdminSectionOptions,
373
235
  } from './resource-layout';
374
- export {
375
- ADMIN_RENDERERS,
376
- type AdminColumnComponent,
377
- type AdminColumnRenderProps,
378
- type AdminComputedColumn,
379
- type AdminComputedColumnOptions,
380
- type AdminRenderer,
381
- type AdminRowScope,
382
- type AdminScope,
383
- type AdminScopeOptions,
384
- type AdminScopeWhere,
385
- LOOKUP_SEGMENT,
236
+ export type {
237
+ AdminColumnComponent,
238
+ AdminColumnRenderProps,
239
+ AdminComputedColumn,
240
+ AdminComputedColumnOptions,
241
+ AdminRenderer,
242
+ AdminRowScope,
243
+ AdminScope,
244
+ AdminScopeOptions,
245
+ AdminScopeWhere,
386
246
  } from './resource-list';
387
- export { AdminViewWriteError, VIEW_OPERATIONS } from './resource-operations';
388
247
  export {
389
248
  type AdminRouteConfig,
390
249
  type AdminRouteMatch,
@@ -393,17 +252,13 @@ export {
393
252
  adminRouteMatch,
394
253
  adminRoutes,
395
254
  } from './routes';
396
- export { ROW_OUT_OF_SCOPE_REASON } from './row-scope-write';
397
255
  export { ROW_CHANGED_REASON, rowVersion, VERSION_FIELD } from './row-version';
398
256
  export {
399
257
  type AdminRouteRequest,
400
258
  type AdminRouteResponse,
401
259
  type AdminScreen,
402
260
  guardedScreen,
403
- lookupHref,
404
- widgetContextOf,
405
261
  } from './screen-frame';
406
- export { type OperationDecision, OperationMatrix, operationMatrix } from './screen-home';
407
262
  export {
408
263
  type AdminSearchHit,
409
264
  type AdminSearchInput,
@@ -411,23 +266,12 @@ export {
411
266
  adminSearch,
412
267
  } from './search';
413
268
  export { type AdminTestCtxInput, adminTestCtx } from './test-ctx';
414
- export {
415
- type AdminBranding,
416
- adminBranding,
417
- defaultBranding,
418
- type ThemeAttributes,
419
- type ThemeMode,
420
- type ThemeTokenRef,
421
- themeAttributes,
269
+ export type {
270
+ AdminBranding,
271
+ ThemeAttributes,
272
+ ThemeMode,
273
+ ThemeTokenRef,
422
274
  } from './theme';
423
275
  export { type ValidationIssue, type ValidationResult, validateInput } from './validate';
424
- export {
425
- assertMoney,
426
- assertZone,
427
- type GuardedField,
428
- type SelectOption,
429
- type WidgetContext,
430
- type WidgetProps,
431
- widgetProps,
432
- } from './widget-value';
276
+ export type { SelectOption, WidgetContext, WidgetProps } from './widget-value';
433
277
  export { optionLabel, Widget, type WidgetInput } from './widgets';
@@ -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(memoryJobDriver())',
24
+ fix: 'run the app through x dev or the container entry, which install the queue — or in a test: installJobDriver(memoryJobDriver()) from @ultimat3/jobs/adapter',
25
25
  });
26
26
  }
27
27
  if (driver.introspect === undefined) {
28
28
  throw new NotImplementedError({
29
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',
30
+ fix: 'export const runtime = { jobs: postgresJobDriver() } # apps/<app>/runtime.ts — or delete that line so the boot installs it; the pg and memory drivers implement introspect',
31
31
  });
32
32
  }
33
33
  return { driver, introspect: driver.introspect };
package/src/layout.tsx CHANGED
@@ -128,7 +128,7 @@ export function AdminLayout(props: AdminLayoutProps): JSX.Element {
128
128
 
129
129
  {/* The way out. The admin's route table never points back at the app, so without this an
130
130
  operator who opened the dashboard has no link home and edits the URL bar. */}
131
- <a class={styles['navLink']} href="/">
131
+ <a class={styles['navLink']} href={props.app.home ?? '/'}>
132
132
  {t('admin.backToApp')}
133
133
  </a>
134
134
  </aside>
@@ -0,0 +1,29 @@
1
+ // The admin as one request reached it: the same declaration, its links rooted where the request
2
+ // was SERVED — behind an HTTP base path, under a routed locale, or both (`/myapp/es/admin`). Every
3
+ // link, form action and redirect a screen writes starts from `app.basePath` or `app.home`, so this
4
+ // one substitution keeps every click where the operator is instead of dropping the prefix.
5
+
6
+ import type { AdminApp } from './admin';
7
+
8
+ /**
9
+ * `app` itself when it was reached at its declared base path; otherwise a copy whose `basePath` is
10
+ * the pathname up to and including the declared base (`/myapp/es/admin`) and whose `home` is the
11
+ * part before it with a trailing slash (`/myapp/es/`). The first match from the left is the mount:
12
+ * a row id or a page path that repeats the base comes after it. The route table (`routes`) is left
13
+ * as declared: the host matches on the stripped route path, and the audit names the route.
14
+ */
15
+ export function adminAppFor(app: AdminApp, url: string): AdminApp {
16
+ const segments = (URL.parse(url, 'http://localhost')?.pathname ?? '').split('/').slice(1);
17
+ const base = app.basePath.split('/').filter((segment) => segment !== '');
18
+ for (let at = 0; at + base.length <= segments.length; at += 1) {
19
+ if (!base.every((segment, i) => segments[at + i] === segment)) continue;
20
+ if (at === 0) return app;
21
+ const served = segments.slice(0, at);
22
+ return {
23
+ ...app,
24
+ basePath: `/${[...served, ...base].join('/')}`,
25
+ home: `/${served.join('/')}/`,
26
+ };
27
+ }
28
+ return app;
29
+ }
package/src/routes.ts CHANGED
@@ -5,6 +5,7 @@
5
5
  import { compilePattern, type RouteConfig, type RouteGuard } from '@ultimat3/render';
6
6
  import type { AdminApp, AdminRoute } from './admin';
7
7
  import { AdminPagePathInvalidError } from './errors';
8
+ import { adminAppFor } from './localized-app';
8
9
  import { adminRouteDefinition } from './route-config';
9
10
  import type { AdminRouteRequest, AdminRouteResponse } from './screen-frame';
10
11
  import { screenFor } from './screens';
@@ -32,12 +33,19 @@ export interface AdminRouteConfig {
32
33
  /** One route of the table, with its gate, its served config and its screen. */
33
34
  export function adminRouteConfig(app: AdminApp, route: AdminRoute): AdminRouteConfig {
34
35
  const { policy, config } = adminRouteDefinition(route);
36
+ // Built now, so a route with nothing behind it is refused at mount, not at its first request.
37
+ const screen = screenFor(app, route);
35
38
  return {
36
39
  path: route.path,
37
40
  view: route.view,
38
41
  entity: route.entity,
39
42
  permissions: route.permissions,
40
- respond: screenFor(app, route),
43
+ // Under a routed locale the screen is built over the admin as that locale spells it, so every
44
+ // link and redirect keeps the prefix (`localized-app.ts`).
45
+ respond: (request) => {
46
+ const under = adminAppFor(app, request.url);
47
+ return (under === app ? screen : screenFor(under, route))(request);
48
+ },
41
49
  policy,
42
50
  config,
43
51
  };
@@ -100,6 +100,9 @@ export function auditScreen(app: AdminApp, route: AdminRoute): AdminScreen {
100
100
  // Refused before anything is read: a parameter this screen does not answer is a 400 naming
101
101
  // what it does (`guardedScreen`), never the unfiltered trail.
102
102
  const asked = auditRequestOf(new URL(request.url));
103
+ // The trail's links and its filter form point at the page AS SERVED — under `/es/admin/audit`
104
+ // that keeps the locale, where the declared `route.path` dropped it on every click.
105
+ const here = new URL(request.url).pathname;
103
106
  // The actor's tenant's trail: `audit:read` held in org A is not a read of org B's operators,
104
107
  // their ids or what they touched. An actor with no org — the platform — reads every tenant's.
105
108
  // Never a parameter: the URL cannot name another tenant.
@@ -128,7 +131,7 @@ export function auditScreen(app: AdminApp, route: AdminRoute): AdminScreen {
128
131
  const { before: _page, ...filters } = asked;
129
132
  return (
130
133
  <Card>
131
- <AuditFilter action={route.path} asked={asked} />
134
+ <AuditFilter action={here} asked={asked} />
132
135
  {entries.length === 0 ? (
133
136
  <p class="x-admin-empty">{t('admin.audit.empty')}</p>
134
137
  ) : (
@@ -139,7 +142,7 @@ export function auditScreen(app: AdminApp, route: AdminRoute): AdminScreen {
139
142
  return (
140
143
  <li>
141
144
  <code>{entry.at}</code>{' '}
142
- <a href={auditHref(route.path, { ...filters, actorId: entry.actor.id })}>
145
+ <a href={auditHref(here, { ...filters, actorId: entry.actor.id })}>
143
146
  {entry.actor.id}
144
147
  </a>{' '}
145
148
  <span>{operationLabel(entry)}</span>{' '}
@@ -158,9 +161,7 @@ export function auditScreen(app: AdminApp, route: AdminRoute): AdminScreen {
158
161
  </ol>
159
162
  )}
160
163
  {last === null ? null : (
161
- <Link href={auditHref(route.path, { ...filters, before: last })}>
162
- {t('admin.audit.older')}
163
- </Link>
164
+ <Link href={auditHref(here, { ...filters, before: last })}>{t('admin.audit.older')}</Link>
164
165
  )}
165
166
  </Card>
166
167
  );