@ultimat3/admin 1.1.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.
@@ -19,8 +19,8 @@ export interface CachePanelData {
19
19
 
20
20
  export const cachePanel: DevPanel<CachePanelData> = {
21
21
  key: 'cache',
22
- titleKey: 'dev.panel.cache',
23
- question: 'what invalidated what — and why is this still stale?',
22
+ titleKey: 'dev.panel.cache.title',
23
+ questionKey: 'dev.panel.cache.question',
24
24
  async data(sources): Promise<CachePanelData> {
25
25
  const graph = await sources.cacheGraph();
26
26
  // The invalidation log needs a running process that has served a write; the graph alone
@@ -2,6 +2,8 @@
2
2
  // Kills: "what is actually in the table, and does it match the migrations?" — psql in a tab,
3
3
  // read-only by default, plus the schema diff against the migration history.
4
4
 
5
+ import { isUltimateError } from '@ultimat3/core';
6
+ import { assertReadOnlyQuery } from '@ultimat3/mcp';
5
7
  import type { DriftFact, SqlResult, TableFact } from './facts';
6
8
  import type { DevPanel } from './panel';
7
9
 
@@ -15,30 +17,83 @@ export interface DbPanelData {
15
17
  readonly readOnly: boolean;
16
18
  }
17
19
 
18
- const WRITE_STATEMENT =
19
- /\b(insert|update|delete|drop|alter|truncate|create|grant|revoke|copy|vacuum)\b/i;
20
+ /**
21
+ * Every span Postgres reads as opaque text rather than as SQL, in ONE alternation: a `'…'`
22
+ * string (`''` escapes included), a `"…"` quoted identifier (`""` included), a `$tag$…$tag$`
23
+ * dollar-quoted body, a `--` line comment and a slash-star block comment.
24
+ *
25
+ * One pass, not five. Sequential passes cannot be ordered correctly, because each form can
26
+ * contain the opener of any other: blanking comments first eats the `--` inside `'…'`, and
27
+ * blanking strings first eats the `'` inside a comment. A single left-to-right scan resolves
28
+ * that the way a lexer does — whichever token *starts* first consumes the rest.
29
+ *
30
+ * This is NO LONGER a security scan and must not be read as one. It once fed a write-keyword
31
+ * check in this file; that check is gone and `assertReadOnlyQuery` owns the question. All this
32
+ * decides now is "did the developer type a statement, or only a comment".
33
+ */
34
+ const OPAQUE_SPAN =
35
+ /'(?:[^']|'')*'|"(?:[^"]|"")*"|\$([A-Za-z_]\w*)?\$[\s\S]*?\$\1\$|--[^\n]*|\/\*[\s\S]*?\*\//g;
36
+
37
+ /**
38
+ * Blanks every comment and literal so `-- still typing` and `/* … *​/` read as an empty box
39
+ * rather than as a refusal. Blanked to spaces, never deleted, so `whe`+`re` never fuses into a
40
+ * new word.
41
+ *
42
+ * Says NOTHING about whether the statement is safe, and in particular nothing about an
43
+ * unterminated delimiter: this regex simply fails to match one, which used to be described here
44
+ * as failing closed. It was — for a keyword scan that no longer exists. `assertReadOnlyQuery` is
45
+ * what refuses an unterminated delimiter now, in all five forms.
46
+ */
47
+ function sanitize(sql: string): string {
48
+ return sql.replace(OPAQUE_SPAN, (span) => ' '.repeat(span.length));
49
+ }
20
50
 
21
51
  /**
22
52
  * Read-only is enforced here, not just in the UI: /_x runs with the developer's own DB
23
53
  * credentials, so "the textarea only sends SELECTs" would be the whole safety story.
24
54
  * `x db psql --write` is the deliberate way out.
55
+ *
56
+ * ONE implementation of "is this a read", and it is `@ultimat3/mcp`'s. That guard refuses four
57
+ * things this file's own keyword scan never did — a batch of statements, a call into the
58
+ * `pg_read_*`/`pg_advisory_*`/`pg_sleep`/`set_config` families (a prefix, so a member Postgres adds
59
+ * next release is refused on the day it ships), `FOR UPDATE`, and a delimiter that never closes in
60
+ * any of its five forms (`'`, `E'`, `"`, `$tag$`, slash-star) — and a second, weaker copy of a
61
+ * security rule is how the two drift apart. Downward, not sideways: `admin` is tier 5, `mcp` is
62
+ * tier 4, and `@ultimat3/mcp` is already a dependency of this package (`src/mcp.ts`).
63
+ *
64
+ * NOTHING about the verdict stays local, deliberately. A local unterminated-delimiter refusal
65
+ * lived here for one revision and was deleted: it tested for a surviving `'` or `"`, so it caught
66
+ * three of the five forms and let `select $tag$ ; delete from members` fall through to the shared
67
+ * guard — one failure mode with two different explanations, the local one calling a dollar-quoted
68
+ * body "a quote". A second detector for a property the guard below already owns and tests is the
69
+ * drift this delegation exists to remove.
70
+ *
71
+ * What stays local is the emptiness test and the WAY OUT: `@ultimat3/mcp` tells its caller to
72
+ * expose an action, which is not advice a developer standing at `/_x` can act on. Only this file
73
+ * knows which reader it is talking to.
25
74
  */
26
75
  export function assertReadOnly(sql: string): string | null {
27
- const stripped = sql.replace(/--[^\n]*/g, ' ').trim();
28
- if (stripped === '') return null;
29
- if (WRITE_STATEMENT.test(stripped)) {
30
- return `refused: the /_x DB panel is read-only. Run it with: x db psql --write`;
31
- }
32
- if (!/^(select|with|explain|show|table)\b/i.test(stripped)) {
33
- return 'refused: only SELECT / WITH / EXPLAIN / SHOW / TABLE statements run here';
76
+ // Nothing to run — a blank box or a comment the developer is still writing, not a refusal.
77
+ if (sanitize(sql).trim() === '') return null;
78
+ try {
79
+ assertReadOnlyQuery(sql);
80
+ return null;
81
+ } catch (error) {
82
+ // Structurally, never `String(error)`: the guard throws an `UltimateError` whose `cause` is
83
+ // the sentence a developer needs, and anything else here is a bug in the guard, not a verdict.
84
+ if (!isUltimateError(error)) throw error;
85
+ // Two classes arrive through one code, and the panel cannot tell them apart without reading
86
+ // another package's prose — so the way out is phrased to be true for both. It used to assert
87
+ // `Run it with: x db psql --write`, which is the wrong instruction for a missing quote: that
88
+ // flag grants writes, it does not close a delimiter.
89
+ return `refused: ${error.cause}. Fix the statement, or — if it is meant to write — run it with: x db psql --write`;
34
90
  }
35
- return null;
36
91
  }
37
92
 
38
93
  export const dbPanel: DevPanel<DbPanelData> = {
39
94
  key: 'db',
40
- titleKey: 'dev.panel.db',
41
- question: 'what is in the table, and does the schema match the migrations?',
95
+ titleKey: 'dev.panel.db.title',
96
+ questionKey: 'dev.panel.db.question',
42
97
  async data(sources, params): Promise<DbPanelData> {
43
98
  const [tables, drift] = await Promise.all([sources.tables(), sources.drift()]);
44
99
  const sql = params.get('sql');
@@ -2,7 +2,7 @@
2
2
  // Kills: "is the queue moving, and which step failed?" — queue depth, per-run step traces,
3
3
  // the retry-from-step target, and the dead letter.
4
4
 
5
- import type { JobDefFact, JobRunFact, QueueFact, TaskFact } from './facts';
5
+ import type { BackfillFact, JobDefFact, JobRunFact, QueueFact, TaskFact } from './facts';
6
6
  import type { DevPanel } from './panel';
7
7
 
8
8
  export interface RetryTarget {
@@ -22,6 +22,14 @@ export interface JobsPanelData {
22
22
  readonly deadLetter: readonly JobRunFact[];
23
23
  readonly retryTargets: readonly RetryTarget[];
24
24
  readonly totalDepth: number;
25
+ /** Every pass the `x_backfills` ledger holds, newest first — see `backfillsInFlight`. */
26
+ readonly backfills: readonly BackfillFact[];
27
+ /**
28
+ * The sweeps still going, split out rather than left for the reader to filter: a `?queue=`
29
+ * filter scopes the runs on this panel and a backfill is not a queue, so the one number that
30
+ * would otherwise be missed is how many passes are moving right now.
31
+ */
32
+ readonly backfillsInFlight: number;
25
33
  }
26
34
 
27
35
  const firstFailedStep = (run: JobRunFact): RetryTarget | null => {
@@ -38,14 +46,15 @@ const firstFailedStep = (run: JobRunFact): RetryTarget | null => {
38
46
 
39
47
  export const jobsPanel: DevPanel<JobsPanelData> = {
40
48
  key: 'jobs',
41
- titleKey: 'dev.panel.jobs',
42
- question: 'is the queue moving, and which step failed?',
49
+ titleKey: 'dev.panel.jobs.title',
50
+ questionKey: 'dev.panel.jobs.question',
43
51
  async data(sources, params): Promise<JobsPanelData> {
44
- const [queues, jobs, tasks, runs] = await Promise.all([
52
+ const [queues, jobs, tasks, runs, backfills] = await Promise.all([
45
53
  sources.queues(),
46
54
  sources.jobDefs(),
47
55
  sources.tasks(),
48
56
  sources.jobRuns(),
57
+ sources.backfills(),
49
58
  ]);
50
59
  const queueFilter = params.get('queue');
51
60
  const scoped = queueFilter === null ? runs : runs.filter((run) => run.queue === queueFilter);
@@ -61,6 +70,8 @@ export const jobsPanel: DevPanel<JobsPanelData> = {
61
70
  .map(firstFailedStep)
62
71
  .filter((target): target is RetryTarget => target !== null),
63
72
  totalDepth: queues.reduce((sum, queue) => sum + queue.depth, 0),
73
+ backfills,
74
+ backfillsInFlight: backfills.filter((pass) => pass.status === 'running').length,
64
75
  };
65
76
  },
66
77
  };
@@ -2,6 +2,7 @@
2
2
  // Kills: "why did (or didn't) this subscriber get that row?" — every subscriber, what it
3
3
  // received, and the matcher's decision trace beside it.
4
4
 
5
+ import { DevSourceUnavailableError } from '../errors';
5
6
  import type { LiveQueryFact, LiveSubscriberFact } from './facts';
6
7
  import type { DevPanel } from './panel';
7
8
 
@@ -21,13 +22,27 @@ export interface LivePanelData {
21
22
 
22
23
  export const livePanel: DevPanel<LivePanelData> = {
23
24
  key: 'live',
24
- titleKey: 'dev.panel.live',
25
- question: 'what does each subscriber receive, and why?',
25
+ titleKey: 'dev.panel.live.title',
26
+ questionKey: 'dev.panel.live.question',
26
27
  async data(sources, params): Promise<LivePanelData> {
27
28
  const queries = (await sources.liveQueries()).filter((query) => query.live);
28
29
  // The subscriber list needs a running sync node; without one the panel still shows the
29
- // registered live queries rather than an empty tab.
30
- const subscribers = await sources.subscribers().catch((): readonly LiveSubscriberFact[] => []);
30
+ // registered live queries rather than an empty tab. `wired` is kept apart from the list
31
+ // itself: `[]` is what a *running* node with nobody attached answers too, and folding the
32
+ // two into one empty array made a genuinely idle live tier print "no sync node" — the same
33
+ // "an empty list is not the same answer as no detector" argument `panel-timeline.ts` makes.
34
+ let subscribers: readonly LiveSubscriberFact[] = [];
35
+ let wired = true;
36
+ try {
37
+ subscribers = await sources.subscribers();
38
+ } catch (error) {
39
+ // Only the one error that MEANS unwired. A bare `catch` read an authz refusal, a dropped
40
+ // NATS connection and a bug in the recorder as "no sync node" — the note the panel prints
41
+ // for a tier that was never installed — and threw the diagnostic away. Anything else
42
+ // reaches `panelPayload`, which renders its code and its fix line.
43
+ if (!(error instanceof DevSourceUnavailableError)) throw error;
44
+ wired = false;
45
+ }
31
46
  const wanted = params.get('query');
32
47
  const scoped =
33
48
  wanted === null ? subscribers : subscribers.filter((sub) => sub.query === wanted);
@@ -40,7 +55,7 @@ export const livePanel: DevPanel<LivePanelData> = {
40
55
  rejected: scoped
41
56
  .filter((sub) => !sub.matched)
42
57
  .map((sub) => ({ id: sub.id, query: sub.query, trace: sub.trace })),
43
- note: subscribers.length === 0 ? 'dev.live.no-sync-node' : null,
58
+ note: wired ? null : 'dev.live.no-sync-node',
44
59
  };
45
60
  },
46
61
  };
@@ -18,8 +18,8 @@ export interface MailPanelData {
18
18
 
19
19
  export const mailPanel: DevPanel<MailPanelData> = {
20
20
  key: 'mail',
21
- titleKey: 'dev.panel.mail',
22
- question: 'what did that email look like, in that locale?',
21
+ titleKey: 'dev.panel.mail.title',
22
+ questionKey: 'dev.panel.mail.question',
23
23
  async data(sources, params): Promise<MailPanelData> {
24
24
  const all = await sources.mail();
25
25
  const locale = params.get('locale');
@@ -17,8 +17,8 @@ export interface ManifestPanelData {
17
17
 
18
18
  export const manifestPanel: DevPanel<ManifestPanelData> = {
19
19
  key: 'manifest',
20
- titleKey: 'dev.panel.manifest',
21
- question: 'is the committed x.manifest.json current?',
20
+ titleKey: 'dev.panel.manifest.title',
21
+ questionKey: 'dev.panel.manifest.question',
22
22
  async data(sources): Promise<ManifestPanelData> {
23
23
  const manifest = await sources.manifest();
24
24
  return {
@@ -24,8 +24,8 @@ export interface PolicyPanelData {
24
24
 
25
25
  export const policyPanel: DevPanel<PolicyPanelData> = {
26
26
  key: 'policy',
27
- titleKey: 'dev.panel.policy',
28
- question: 'can this actor do that, and why?',
27
+ titleKey: 'dev.panel.policy.title',
28
+ questionKey: 'dev.panel.policy.question',
29
29
  async data(sources, params): Promise<PolicyPanelData> {
30
30
  const facts = await sources.policyMatrix();
31
31
  const actors = [...new Set(facts.map((fact) => fact.actorId))].sort();
@@ -23,8 +23,8 @@ const kb = (budget: string | undefined): number => {
23
23
 
24
24
  export const routesPanel: DevPanel<RoutesPanelData> = {
25
25
  key: 'routes',
26
- titleKey: 'dev.panel.routes',
27
- question: 'which handler serves this?',
26
+ titleKey: 'dev.panel.routes.title',
27
+ questionKey: 'dev.panel.routes.question',
28
28
  async data(sources): Promise<RoutesPanelData> {
29
29
  const routes = await sources.routes();
30
30
  const byRenderMode: Record<string, number> = {};
@@ -1,8 +1,9 @@
1
1
  // Panel: Request timeline.
2
2
  // Kills: "where did the 800ms go?" — a flamegraph of one request: SQL, cache hits, action
3
- // calls, and policy decisions on one axis, with the N+1 already counted for you.
3
+ // calls, and policy decisions on one axis, with the N+1 already counted for you — counted by
4
+ // `x dev`'s statement ledger and read here through `statementLoops()`, never re-derived.
4
5
 
5
- import type { RequestTrace, SpanKind, TimelineSpan } from './facts';
6
+ import type { RequestTrace, SpanKind, StatementLoopFact, TimelineSpan } from './facts';
6
7
  import type { DevPanel } from './panel';
7
8
 
8
9
  export interface FlameRow {
@@ -22,8 +23,18 @@ export interface TimelinePanelData {
22
23
  readonly selected: RequestTrace | null;
23
24
  readonly flame: readonly FlameRow[];
24
25
  readonly totalsByKind: Readonly<Record<string, number>>;
25
- /** Same SQL text more than once in one request. The N+1 detector. */
26
+ /** Same SQL text more than once in one request. A measurement over this trace, not a verdict. */
26
27
  readonly repeatedSql: readonly { readonly sql: string; readonly count: number }[];
28
+ /**
29
+ * The detector's verdicts for the selected request, or `null` when no detector is installed.
30
+ *
31
+ * Two fields and not one, deliberately: `repeatedSql` above is a **measurement** over the trace
32
+ * this panel recorded — every SQL text that appeared twice, whatever it was — while this is the
33
+ * **verdict**, counted per request by `x dev`'s statement ledger with attribution applied and
34
+ * `expectedQueryLoop` honoured. A measurement that started warning would be a second detector,
35
+ * disagreeing with the one whose `fix:` an author actually pastes.
36
+ */
37
+ readonly nPlusOne: readonly StatementLoopFact[] | null;
27
38
  }
28
39
 
29
40
  function flatten(trace: RequestTrace): readonly FlameRow[] {
@@ -48,13 +59,17 @@ function flatten(trace: RequestTrace): readonly FlameRow[] {
48
59
 
49
60
  export const timelinePanel: DevPanel<TimelinePanelData> = {
50
61
  key: 'timeline',
51
- titleKey: 'dev.panel.timeline',
52
- question: 'where did the time go in this request?',
62
+ titleKey: 'dev.panel.timeline.title',
63
+ questionKey: 'dev.panel.timeline.question',
53
64
  async data(sources, params): Promise<TimelinePanelData> {
54
65
  const traces = await sources.traces();
55
66
  const wanted = params.get('requestId');
56
67
  const selected =
57
68
  (wanted === null ? traces[0] : traces.find((trace) => trace.requestId === wanted)) ?? null;
69
+ // Degrade rather than reject, as `panel-live.ts` does for `subscribers`: a host with traces
70
+ // but no detector installed must still get its flamegraph. `null` carries that difference —
71
+ // "nobody counted" is not "counted, and this request was clean".
72
+ const loops = await sources.statementLoops().catch((): null => null);
58
73
 
59
74
  const totalsByKind: Record<string, number> = {};
60
75
  const sqlCounts = new Map<string, number>();
@@ -77,6 +92,12 @@ export const timelinePanel: DevPanel<TimelinePanelData> = {
77
92
  .filter(([, count]) => count > 1)
78
93
  .map(([sql, count]) => ({ sql, count }))
79
94
  .sort((a, b) => b.count - a.count),
95
+ // Scoped to the shown request and left in the ledger's own order — it already orders
96
+ // newest first, and a second sort here would be this panel deciding what matters most.
97
+ // With nothing selected the match is against `undefined`, so the answer is `[]`: no
98
+ // request is on screen to have looped.
99
+ nPlusOne:
100
+ loops === null ? null : loops.filter((loop) => loop.requestId === selected?.requestId),
80
101
  };
81
102
  },
82
103
  };
package/src/dev/panel.ts CHANGED
@@ -5,11 +5,15 @@
5
5
  import type { DevSources } from './facts';
6
6
 
7
7
  export interface DevPanel<Data = unknown> {
8
- /** URL segment and `--json` selector: `x dev --panel routes --json`. */
8
+ /** URL segment under `/_x`, and the key `devDashboard().json(key)` takes. */
9
9
  readonly key: string;
10
10
  readonly titleKey: string;
11
- /** The question this panel exists to kill. Rendered as the tab's subtitle. */
12
- readonly question: string;
11
+ /**
12
+ * i18n key for the question this panel exists to kill, rendered as the tab's subtitle —
13
+ * `t(questionKey)`, never a literal sitting beside `titleKey`. The two are siblings under the
14
+ * panel's own namespace: `dev.panel.jobs.title` and `dev.panel.jobs.question`.
15
+ */
16
+ readonly questionKey: string;
13
17
  data(sources: DevSources, params: URLSearchParams): Promise<Data>;
14
18
  }
15
19
 
package/src/dev/server.ts CHANGED
@@ -2,6 +2,11 @@
2
2
  // SQL, policy traces, and caught mail. The refusal is a throw at construction, not a 404 at
3
3
  // request time: an app that boots with /_x mounted in prod has already lost.
4
4
 
5
+ // Type-only, so it is erased and the 46-component barrel stays out of the mount graph — the
6
+ // values arrive through the dynamic `import()` in `devShellStyle()`, same reason as `data.ts`.
7
+ import { DEFAULT_ENVIRONMENT, tryResolveEnvironment } from '@ultimat3/core';
8
+ import { t } from '@ultimat3/i18n';
9
+ import type { ColorRole } from '@ultimat3/ui';
5
10
  import { DevDashboardInProdError } from '../errors';
6
11
  import { defaultDevSources } from './data';
7
12
  import type { DevSources } from './facts';
@@ -33,7 +38,7 @@ export const DEV_BASE_PATH = '/_x';
33
38
  export interface DevDashboardOptions {
34
39
  /** `ROLE` for this process. Defaults to `process.env.ROLE`. */
35
40
  readonly role?: string;
36
- /** `NODE_ENV` (or `X_ENV`). Defaults to the environment. */
41
+ /** An explicit environment name. Defaults to `ULTIMATE_ENV`, else `NODE_ENV`, else development. */
37
42
  readonly env?: string;
38
43
  readonly basePath?: string;
39
44
  readonly sources?: DevSources;
@@ -46,15 +51,20 @@ const envOf = (name: string): string | undefined => {
46
51
  };
47
52
 
48
53
  /**
49
- * One rule: anything that says "production" refuses. Checked against both the framework's
50
- * `ROLE`-adjacent env and `NODE_ENV`, since a container may set only one of them.
54
+ * One rule: anything that says "production" refuses. The environment has ONE reader —
55
+ * `tryResolveEnvironment()`, which is `ULTIMATE_ENV` else `NODE_ENV` — because this guard used to
56
+ * read `X_ENV`/`NODE_ENV` and never the framework's own key, so an app declaring production the
57
+ * documented way mounted /_x on the internet. Non-throwing: a malformed `ULTIMATE_ENV` is its own
58
+ * error with its own fix and must not be raised for the first time by a mount guard.
59
+ * The `role` half survives only for an explicit `devDashboard({ role })` — no shipped `ROLE`
60
+ * value is `production`.
51
61
  */
52
62
  export function assertDevOnly(input: {
53
63
  role?: string | undefined;
54
64
  env?: string | undefined;
55
65
  }): void {
56
66
  const role = input.role ?? envOf('ROLE') ?? 'dev';
57
- const env = input.env ?? envOf('X_ENV') ?? envOf('NODE_ENV') ?? 'development';
67
+ const env = input.env ?? tryResolveEnvironment() ?? DEFAULT_ENVIRONMENT;
58
68
  if (env === 'production' || env === 'prod' || role === 'production' || role === 'prod') {
59
69
  throw new DevDashboardInProdError({ role, env });
60
70
  }
@@ -78,26 +88,17 @@ const jsonResponse = (body: unknown, status = 200): Response =>
78
88
  const escapeHtml = (value: string): string =>
79
89
  value.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
80
90
 
81
- /** Tokens are defined inline: /_x is a standalone page and still owes both themes. */
82
- const SHELL_STYLE = `
83
- :root {
84
- --x-color-bg: 253 246 240; --x-color-surface: 255 255 255; --x-color-fg: 38 34 31;
85
- --x-color-fg-muted: 110 102 94; --x-color-line: 224 216 208; --x-color-accent: 34 122 197;
86
- }
87
- @media (prefers-color-scheme: dark) {
88
- :root {
89
- --x-color-bg: 18 18 20; --x-color-surface: 34 34 39; --x-color-fg: 228 226 222;
90
- --x-color-fg-muted: 150 146 140; --x-color-line: 54 54 60; --x-color-accent: 96 170 240;
91
- }
92
- }
93
- html[data-theme="light"] {
94
- --x-color-bg: 253 246 240; --x-color-surface: 255 255 255; --x-color-fg: 38 34 31;
95
- --x-color-fg-muted: 110 102 94; --x-color-line: 224 216 208; --x-color-accent: 34 122 197;
96
- }
97
- html[data-theme="dark"] {
98
- --x-color-bg: 18 18 20; --x-color-surface: 34 34 39; --x-color-fg: 228 226 222;
99
- --x-color-fg-muted: 150 146 140; --x-color-line: 54 54 60; --x-color-accent: 96 170 240;
100
- }
91
+ /** The six roles /_x paints with. `--x-*` is the admin's namespace; the values are ui's. */
92
+ const SHELL_ROLES = [
93
+ 'bg',
94
+ 'surface-raised',
95
+ 'fg',
96
+ 'fg-muted',
97
+ 'line',
98
+ 'accent',
99
+ ] as const satisfies readonly ColorRole[];
100
+
101
+ const SHELL_LAYOUT = `
101
102
  body { margin: 0; background: rgb(var(--x-color-bg)); color: rgb(var(--x-color-fg));
102
103
  font: 14px/1.5 ui-monospace, monospace; }
103
104
  header { display: flex; gap: 1rem; padding: .75rem 1rem;
@@ -106,12 +107,40 @@ a { color: rgb(var(--x-color-accent)); }
106
107
  main { padding: 1rem; }
107
108
  h1 { font-size: 1rem; margin: 0 1rem 0 0; }
108
109
  p.question { color: rgb(var(--x-color-fg-muted)); margin: 0 0 1rem; }
109
- pre { background: rgb(var(--x-color-surface)); border: 1px solid rgb(var(--x-color-line));
110
+ pre { background: rgb(var(--x-color-surface-raised)); border: 1px solid rgb(var(--x-color-line));
110
111
  padding: 1rem; overflow: auto; }
111
112
  :focus-visible { outline: 2px solid rgb(var(--x-color-accent)); outline-offset: 2px; }
112
113
  `;
113
114
 
115
+ let stylePromise: Promise<string> | undefined;
116
+
117
+ /**
118
+ * Tokens are inlined because /_x is a standalone page with no stylesheet pipeline — but the
119
+ * VALUES are read from `@ultimat3/ui` rather than copied, because the copy that used to live
120
+ * here went stale through a WCAG retune and shipped `line` on `surface-raised` at 1.16:1.
121
+ * Reached by dynamic `import()` for the same reason `data.ts` is: /_x stays out of the
122
+ * production graph, and the 46-component barrel loads only once a panel is actually drawn.
123
+ *
124
+ * Exported because the host that mounts /_x is the one that configures the CSP the panels are
125
+ * served under, and the `style-src` hash it needs is of THIS text — a host that hashed its own
126
+ * copy would send a policy that blocks the document this function actually writes.
127
+ */
128
+ export async function devShellStyle(): Promise<string> {
129
+ stylePromise ??= import('@ultimat3/ui').then(({ colorTokens }) => {
130
+ const block = (theme: 'light' | 'dark'): string =>
131
+ SHELL_ROLES.map((role) => `--x-color-${role}: ${colorTokens[theme][role]};`).join(' ');
132
+ return `
133
+ :root { ${block('light')} }
134
+ @media (prefers-color-scheme: dark) { :root { ${block('dark')} } }
135
+ html[data-theme="light"] { ${block('light')} }
136
+ html[data-theme="dark"] { ${block('dark')} }
137
+ ${SHELL_LAYOUT}`;
138
+ });
139
+ return stylePromise;
140
+ }
141
+
114
142
  function shell(
143
+ style: string,
115
144
  basePath: string,
116
145
  panels: readonly DevPanel[],
117
146
  active: DevPanel,
@@ -120,7 +149,7 @@ function shell(
120
149
  const tabs = panels
121
150
  .map(
122
151
  (panel) =>
123
- `<a href="${basePath}/${panel.key}"${panel.key === active.key ? ' aria-current="page"' : ''}>${panel.key}</a>`,
152
+ `<a href="${basePath}/${panel.key}"${panel.key === active.key ? ' aria-current="page"' : ''}>${escapeHtml(t(panel.titleKey))}</a>`,
124
153
  )
125
154
  .join(' ');
126
155
 
@@ -128,10 +157,10 @@ function shell(
128
157
  <html lang="en"><head><meta charset="utf-8">
129
158
  <meta name="viewport" content="width=device-width, initial-scale=1">
130
159
  <meta name="robots" content="noindex, nofollow">
131
- <title>_x · ${escapeHtml(active.key)}</title><style>${SHELL_STYLE}</style></head>
160
+ <title>_x · ${escapeHtml(active.key)}</title><style>${style}</style></head>
132
161
  <body><header><h1>_x</h1><nav>${tabs}</nav>
133
162
  <a href="${basePath}/${active.key}?json=1">--json</a></header>
134
- <main><p class="question">${escapeHtml(active.question)}</p>
163
+ <main><p class="question">${escapeHtml(t(active.questionKey))}</p>
135
164
  <pre>${escapeHtml(JSON.stringify(payload, null, 2))}</pre></main></body></html>`;
136
165
  }
137
166
 
@@ -153,7 +182,7 @@ export function devDashboard(opts: DevDashboardOptions = {}): DevDashboard {
153
182
  error: {
154
183
  code: 'X_ADMIN_ENTITY_UNKNOWN',
155
184
  cause: `no /_x panel named "${key}" (have: ${[...byKey.keys()].join(', ')})`,
156
- fix: `x dev --panel ${[...byKey.keys()][0] ?? 'routes'}`,
185
+ fix: `x dev # then the ${[...byKey.keys()][0] ?? 'routes'} panel at /_x`,
157
186
  },
158
187
  };
159
188
  }
@@ -180,7 +209,7 @@ export function devDashboard(opts: DevDashboardOptions = {}): DevDashboard {
180
209
 
181
210
  return wantsJson
182
211
  ? jsonResponse(payload, payload.ok ? 200 : 500)
183
- : new Response(shell(basePath, panels, panel, payload), {
212
+ : new Response(shell(await devShellStyle(), basePath, panels, panel, payload), {
184
213
  status: 200,
185
214
  headers: { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-store' },
186
215
  });
package/src/errors.ts CHANGED
@@ -16,6 +16,11 @@ export const ADMIN_OWNED_ERROR_CODES = [
16
16
  'X_ADMIN_DENIED',
17
17
  'X_ADMIN_TOOL_FORBIDDEN',
18
18
  'X_ADMIN_INVALID',
19
+ // The two ways a `pages:` entry can be wrong. Both are thrown by `defineAdmin` at declaration,
20
+ // not on the first request: an admin page that is public, or one that shadows a generated
21
+ // screen, is a defect the author must never be able to deploy.
22
+ 'X_ADMIN_PAGE_UNGUARDED',
23
+ 'X_ADMIN_PAGE_PATH_INVALID',
19
24
  ] as const;
20
25
 
21
26
  /**
@@ -41,6 +46,8 @@ export const ADMIN_ERROR_TITLES: Readonly<Record<AdminOwnedErrorCode, string>> =
41
46
  X_ADMIN_DENIED: 'the actor may not use this admin surface',
42
47
  X_ADMIN_TOOL_FORBIDDEN: 'an admin MCP tool was called without permission',
43
48
  X_ADMIN_INVALID: "an admin tool's arguments failed the resource schema",
49
+ X_ADMIN_PAGE_UNGUARDED: 'a custom admin page declared no permissions',
50
+ X_ADMIN_PAGE_PATH_INVALID: 'a custom admin page has an unusable or already-taken path',
44
51
  };
45
52
 
46
53
  // One unconditional call, so a second package claiming one of admin's codes throws
@@ -83,18 +90,54 @@ export class AdminFieldUnsupportedError extends UltimateError {
83
90
  }
84
91
  }
85
92
 
86
- /** An action reached the admin without a policy — the button would be an open door. */
93
+ /**
94
+ * An action reached the admin without a policy — the button would be an open door.
95
+ *
96
+ * The subject's NAME goes in the cause and the permission's SHAPE goes in the fix: `can()` takes
97
+ * `resource:verb` (`Permission` is `` `${string}:${string}` ``), and `subject` is an export name,
98
+ * so the `can('<the action name>')` this used to emit was a paste that could not compile. No
99
+ * `allow()` branch, unlike action's and query's twin: an unguarded admin operation is the one
100
+ * thing this code exists to refuse.
101
+ */
87
102
  export class AdminPolicyMissingError extends UltimateError {
88
103
  constructor(input: { subject: string; kind: 'action' | 'resource' }) {
89
104
  super({
90
105
  code: 'X_ADMIN_POLICY_MISSING',
91
106
  cause: `${input.kind} "${input.subject}" is exposed in the admin with no policy`,
92
- fix: `add policy: can('${input.subject}') to the ${input.kind} definition`,
107
+ fix: `add \`policy: can('<resource>:<verb>')\` to the ${input.kind} "${input.subject}" — a permission your definePermissions() call declares, never the ${input.kind}'s own name`,
93
108
  docs: docsFor('X_ADMIN_POLICY_MISSING'),
94
109
  });
95
110
  }
96
111
  }
97
112
 
113
+ /**
114
+ * A `pages:` entry with an empty permission list. Refused where it is written, because the
115
+ * alternative is an unauthenticated admin screen that `x verify` is perfectly happy with — the
116
+ * route table would carry `permissions: []` and the emitted `defineRoute` would have no policy.
117
+ */
118
+ export class AdminPageUnguardedError extends UltimateError {
119
+ constructor(input: { path: string }) {
120
+ super({
121
+ code: 'X_ADMIN_PAGE_UNGUARDED',
122
+ cause: `the admin page "${input.path}" declares no permissions, so nothing gates it`,
123
+ fix: `add permissions: ['${input.path.replace(/^\//, '').split('/')[0] ?? 'ops'}:read'] to the pages entry for "${input.path}"`,
124
+ docs: docsFor('X_ADMIN_PAGE_UNGUARDED'),
125
+ });
126
+ }
127
+ }
128
+
129
+ /** A page path that cannot be mounted: not rooted, malformed, or already served. */
130
+ export class AdminPagePathInvalidError extends UltimateError {
131
+ constructor(input: { path: string; cause: string; fix: string }) {
132
+ super({
133
+ code: 'X_ADMIN_PAGE_PATH_INVALID',
134
+ cause: `the admin page path "${input.path}" ${input.cause}`,
135
+ fix: input.fix,
136
+ docs: docsFor('X_ADMIN_PAGE_PATH_INVALID'),
137
+ });
138
+ }
139
+ }
140
+
98
141
  /**
99
142
  * A `/_x` panel needs a fact the framework cannot introspect on its own — request traces,
100
143
  * caught mail, the read-only SQL tool, the committed manifest. Thrown instead of drawing an
package/src/index.ts CHANGED
@@ -91,6 +91,8 @@ export {
91
91
  type AdminErrorCode,
92
92
  type AdminErrorParts,
93
93
  AdminFieldUnsupportedError,
94
+ AdminPagePathInvalidError,
95
+ AdminPageUnguardedError,
94
96
  AdminPolicyMissingError,
95
97
  adminErrorFrom,
96
98
  DevDashboardInProdError,
@@ -127,6 +129,15 @@ export {
127
129
  adminToolDecisions,
128
130
  } from './mcp-tools';
129
131
  export { adminNav, type NavGroup, type NavItem, type NavOptions, visibleNav } from './nav';
132
+ export { AdminPageDenied, guardedPage } from './page-guard';
133
+ export {
134
+ type AdminCustomPage,
135
+ type AdminPageComponent,
136
+ type AdminPageProps,
137
+ pageNavItems,
138
+ pagePermissions,
139
+ pageRoutes,
140
+ } from './pages';
130
141
  export {
131
142
  type AdminCursor,
132
143
  type AdminPage,
@@ -186,7 +197,12 @@ export {
186
197
  repoOf,
187
198
  resourceFor,
188
199
  } from './resource';
189
- export { type AdminRouteConfig, adminRouteConfig, adminRoutes } from './routes';
200
+ export {
201
+ type AdminRouteConfig,
202
+ adminRouteConfig,
203
+ adminRouteFor,
204
+ adminRoutes,
205
+ } from './routes';
190
206
  export {
191
207
  type AdminSearchHit,
192
208
  type AdminSearchInput,