@ultimat3/admin 1.2.0 → 3.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 +21 -10
- package/src/errors.ts +45 -2
- package/src/fields.ts +42 -3
- 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/resource.ts +1 -1
- package/src/routes.ts +64 -6
- package/src/search.ts +29 -18
- package/src/widget-value.ts +19 -3
- package/src/widgets.tsx +21 -12
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
|
@@ -3,7 +3,9 @@
|
|
|
3
3
|
// request time: an app that boots with /_x mounted in prod has already lost.
|
|
4
4
|
|
|
5
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 `
|
|
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';
|
|
7
9
|
import type { ColorRole } from '@ultimat3/ui';
|
|
8
10
|
import { DevDashboardInProdError } from '../errors';
|
|
9
11
|
import { defaultDevSources } from './data';
|
|
@@ -36,7 +38,7 @@ export const DEV_BASE_PATH = '/_x';
|
|
|
36
38
|
export interface DevDashboardOptions {
|
|
37
39
|
/** `ROLE` for this process. Defaults to `process.env.ROLE`. */
|
|
38
40
|
readonly role?: string;
|
|
39
|
-
/**
|
|
41
|
+
/** An explicit environment name. Defaults to `ULTIMATE_ENV`, else `NODE_ENV`, else development. */
|
|
40
42
|
readonly env?: string;
|
|
41
43
|
readonly basePath?: string;
|
|
42
44
|
readonly sources?: DevSources;
|
|
@@ -49,15 +51,20 @@ const envOf = (name: string): string | undefined => {
|
|
|
49
51
|
};
|
|
50
52
|
|
|
51
53
|
/**
|
|
52
|
-
* One rule: anything that says "production" refuses.
|
|
53
|
-
* `
|
|
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`.
|
|
54
61
|
*/
|
|
55
62
|
export function assertDevOnly(input: {
|
|
56
63
|
role?: string | undefined;
|
|
57
64
|
env?: string | undefined;
|
|
58
65
|
}): void {
|
|
59
66
|
const role = input.role ?? envOf('ROLE') ?? 'dev';
|
|
60
|
-
const env = input.env ??
|
|
67
|
+
const env = input.env ?? tryResolveEnvironment() ?? DEFAULT_ENVIRONMENT;
|
|
61
68
|
if (env === 'production' || env === 'prod' || role === 'production' || role === 'prod') {
|
|
62
69
|
throw new DevDashboardInProdError({ role, env });
|
|
63
70
|
}
|
|
@@ -113,8 +120,12 @@ let stylePromise: Promise<string> | undefined;
|
|
|
113
120
|
* here went stale through a WCAG retune and shipped `line` on `surface-raised` at 1.16:1.
|
|
114
121
|
* Reached by dynamic `import()` for the same reason `data.ts` is: /_x stays out of the
|
|
115
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.
|
|
116
127
|
*/
|
|
117
|
-
async function
|
|
128
|
+
export async function devShellStyle(): Promise<string> {
|
|
118
129
|
stylePromise ??= import('@ultimat3/ui').then(({ colorTokens }) => {
|
|
119
130
|
const block = (theme: 'light' | 'dark'): string =>
|
|
120
131
|
SHELL_ROLES.map((role) => `--x-color-${role}: ${colorTokens[theme][role]};`).join(' ');
|
|
@@ -138,7 +149,7 @@ function shell(
|
|
|
138
149
|
const tabs = panels
|
|
139
150
|
.map(
|
|
140
151
|
(panel) =>
|
|
141
|
-
`<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>`,
|
|
142
153
|
)
|
|
143
154
|
.join(' ');
|
|
144
155
|
|
|
@@ -149,7 +160,7 @@ function shell(
|
|
|
149
160
|
<title>_x · ${escapeHtml(active.key)}</title><style>${style}</style></head>
|
|
150
161
|
<body><header><h1>_x</h1><nav>${tabs}</nav>
|
|
151
162
|
<a href="${basePath}/${active.key}?json=1">--json</a></header>
|
|
152
|
-
<main><p class="question">${escapeHtml(active.
|
|
163
|
+
<main><p class="question">${escapeHtml(t(active.questionKey))}</p>
|
|
153
164
|
<pre>${escapeHtml(JSON.stringify(payload, null, 2))}</pre></main></body></html>`;
|
|
154
165
|
}
|
|
155
166
|
|
|
@@ -171,7 +182,7 @@ export function devDashboard(opts: DevDashboardOptions = {}): DevDashboard {
|
|
|
171
182
|
error: {
|
|
172
183
|
code: 'X_ADMIN_ENTITY_UNKNOWN',
|
|
173
184
|
cause: `no /_x panel named "${key}" (have: ${[...byKey.keys()].join(', ')})`,
|
|
174
|
-
fix: `x dev
|
|
185
|
+
fix: `x dev # then the ${[...byKey.keys()][0] ?? 'routes'} panel at /_x`,
|
|
175
186
|
},
|
|
176
187
|
};
|
|
177
188
|
}
|
|
@@ -198,7 +209,7 @@ export function devDashboard(opts: DevDashboardOptions = {}): DevDashboard {
|
|
|
198
209
|
|
|
199
210
|
return wantsJson
|
|
200
211
|
? jsonResponse(payload, payload.ok ? 200 : 500)
|
|
201
|
-
: new Response(shell(await
|
|
212
|
+
: new Response(shell(await devShellStyle(), basePath, panels, panel, payload), {
|
|
202
213
|
status: 200,
|
|
203
214
|
headers: { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-store' },
|
|
204
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/fields.ts
CHANGED
|
@@ -84,6 +84,22 @@ export function widgetFor(type: AdminFieldType): AdminWidget {
|
|
|
84
84
|
* Every kind `@ultimat3/entity` can put on a column, and nothing else: a name that no column
|
|
85
85
|
* builder emits would be a widget nobody can reach. `text` is the entry the length rule below
|
|
86
86
|
* then refines.
|
|
87
|
+
*
|
|
88
|
+
* The kinds an EXISTING schema brings (`bigint()`, `decimal()`, `date()`, `arrayOf()`) each map to
|
|
89
|
+
* the widget that survives a round trip of the value the column itself produces, which is not
|
|
90
|
+
* always the widget the Postgres type suggests:
|
|
91
|
+
*
|
|
92
|
+
* | kind | field type | why not the obvious one |
|
|
93
|
+
* |---|---|---|
|
|
94
|
+
* | `numeric` | `text` | the row value is the exact decimal STRING, and `number-input` renders anything that is not a JS number as null — a blanked field that saves the blank back |
|
|
95
|
+
* | `bigint` | `text` | same reason, and it shipped as `number` before `bigint()` existed: the row value is decimal digits, because a JS `bigint` is what `JSON.stringify` throws on and a `number` loses everything past 2^53 — the range a legacy `int8` key lives in |
|
|
96
|
+
* | `date` | `date` | a calendar date has no zone, so it takes the `precision: 'date'` branch (`<input type="date">`) rather than the instant one |
|
|
97
|
+
* | `array` | `json` | `String(['a,b'])` and `String(['a','b'])` are the same string, and what a text input posts back is not an array at all |
|
|
98
|
+
*
|
|
99
|
+
* `bytea` is deliberately ABSENT. The `file` widget's value is a storage reference (`{ url, name }`
|
|
100
|
+
* — see `uploadValue`), and the `Uint8Array` a `bytes()` column puts on the row is not one, so
|
|
101
|
+
* mapping it would move the same refusal from derive time, where the fix names the edit, to every
|
|
102
|
+
* render of every row. There is no `vector` row for the same reason there is no vector column.
|
|
87
103
|
*/
|
|
88
104
|
const FIELD_TYPE_BY_COLUMN_KIND: Readonly<Record<string, AdminFieldType>> = {
|
|
89
105
|
uuid: 'text',
|
|
@@ -91,9 +107,12 @@ const FIELD_TYPE_BY_COLUMN_KIND: Readonly<Record<string, AdminFieldType>> = {
|
|
|
91
107
|
char: 'text',
|
|
92
108
|
boolean: 'boolean',
|
|
93
109
|
integer: 'number',
|
|
94
|
-
bigint: '
|
|
110
|
+
bigint: 'text',
|
|
111
|
+
numeric: 'text',
|
|
95
112
|
timestamptz: 'timestamptz',
|
|
113
|
+
date: 'date',
|
|
96
114
|
jsonb: 'json',
|
|
115
|
+
array: 'json',
|
|
97
116
|
money: 'money',
|
|
98
117
|
};
|
|
99
118
|
|
|
@@ -146,6 +165,26 @@ export function sortable(type: AdminFieldType, column: AdminColumnFacts): boolea
|
|
|
146
165
|
return column.index || column.unique || column.primaryKey;
|
|
147
166
|
}
|
|
148
167
|
|
|
149
|
-
|
|
150
|
-
|
|
168
|
+
/**
|
|
169
|
+
* The column kinds Postgres will accept a `LIKE` against. MEASURED on Postgres 17 (PGlite), one
|
|
170
|
+
* statement per type: `text` and `char` answer rows; `uuid`, `numeric`, `bigint`, `integer`,
|
|
171
|
+
* `date`, `timestamptz`, `jsonb`, `boolean` and `text[]` all answer
|
|
172
|
+
* `operator does not exist: <type> ~~ unknown`, and `bytea` answers `Invalid input for bytea type`.
|
|
173
|
+
*
|
|
174
|
+
* It has to be the KIND and not the field type, because several kinds render in a text box and
|
|
175
|
+
* only two of them can be searched: `adminSearch` issues one `contains` filter per searchable
|
|
176
|
+
* field and the driver compiles `contains` to `<column> like $1` with NO cast
|
|
177
|
+
* (`packages/entity/src/pg-sql.ts`), so a searchable column of any other kind makes the admin's
|
|
178
|
+
* search box answer a database error rather than an empty result.
|
|
179
|
+
*/
|
|
180
|
+
const LIKE_ABLE_COLUMN_KINDS: ReadonlySet<string> = new Set(['text', 'char']);
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* A text box over a column a `LIKE` can run against — both halves required. The type keeps an
|
|
184
|
+
* enum or a relation out of the search index the way it always has; the kind keeps out the ones
|
|
185
|
+
* that would throw.
|
|
186
|
+
*/
|
|
187
|
+
export function searchable(type: AdminFieldType, column: AdminColumnFacts): boolean {
|
|
188
|
+
if (type !== 'text' && type !== 'textarea') return false;
|
|
189
|
+
return LIKE_ABLE_COLUMN_KINDS.has(column.kind);
|
|
151
190
|
}
|
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,
|