@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.
- package/CLAUDE.md +52 -0
- package/README.md +117 -3
- package/package.json +17 -14
- package/src/admin.ts +35 -6
- package/src/crud.ts +33 -4
- package/src/detail.tsx +4 -1
- package/src/dev/data.ts +69 -7
- package/src/dev/facts.ts +48 -1
- package/src/dev/index.ts +3 -0
- package/src/dev/panel-cache.ts +2 -2
- package/src/dev/panel-db.ts +67 -12
- package/src/dev/panel-jobs.ts +15 -4
- package/src/dev/panel-live.ts +20 -5
- package/src/dev/panel-mail.ts +2 -2
- package/src/dev/panel-manifest.ts +2 -2
- package/src/dev/panel-policy.ts +2 -2
- package/src/dev/panel-routes.ts +2 -2
- package/src/dev/panel-timeline.ts +26 -5
- package/src/dev/panel.ts +7 -3
- package/src/dev/server.ts +59 -30
- package/src/errors.ts +45 -2
- package/src/index.ts +17 -1
- package/src/mcp-tools.ts +25 -2
- package/src/mcp.ts +16 -11
- package/src/nav.ts +10 -0
- package/src/page-guard.tsx +58 -0
- package/src/pages.ts +117 -0
- package/src/pagination.ts +29 -5
- package/src/registry.ts +6 -0
- package/src/routes.ts +64 -6
- package/src/search.ts +29 -18
- package/src/widget-value.ts +19 -3
- package/src/widgets.tsx +11 -9
package/src/dev/panel-cache.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
package/src/dev/panel-db.ts
CHANGED
|
@@ -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
|
-
|
|
19
|
-
|
|
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
|
-
|
|
28
|
-
if (
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
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
|
-
|
|
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');
|
package/src/dev/panel-jobs.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
};
|
package/src/dev/panel-live.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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:
|
|
58
|
+
note: wired ? null : 'dev.live.no-sync-node',
|
|
44
59
|
};
|
|
45
60
|
},
|
|
46
61
|
};
|
package/src/dev/panel-mail.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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 {
|
package/src/dev/panel-policy.ts
CHANGED
|
@@ -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
|
-
|
|
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();
|
package/src/dev/panel-routes.ts
CHANGED
|
@@ -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
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
|
8
|
+
/** URL segment under `/_x`, and the key `devDashboard().json(key)` takes. */
|
|
9
9
|
readonly key: string;
|
|
10
10
|
readonly titleKey: string;
|
|
11
|
-
/**
|
|
12
|
-
|
|
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
|
-
/**
|
|
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.
|
|
50
|
-
* `
|
|
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 ??
|
|
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, '&').replace(/</g, '<').replace(/>/g, '>');
|
|
80
90
|
|
|
81
|
-
/**
|
|
82
|
-
const
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
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.
|
|
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>${
|
|
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.
|
|
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
|
|
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
|
-
/**
|
|
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}
|
|
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 {
|
|
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,
|