khotan-data 0.1.1 → 0.2.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.
@@ -5,6 +5,7 @@ import { Badge } from "@/components/ui/badge";
5
5
  import { Button } from "@/components/ui/button";
6
6
  import { Input } from "@/components/ui/input";
7
7
  import { Label } from "@/components/ui/label";
8
+ import { khotanFetch, isKhotanApiError, ApiErrorState } from "./api-state";
8
9
 
9
10
  // ============================================================================
10
11
  // Plug Debugger — Lightweight Postman for your plugs
@@ -331,6 +332,7 @@ export function PlugDebugger({
331
332
  }: PlugDebuggerProps) {
332
333
  const [meta, setMeta] = useState<PlugMeta | null>(null);
333
334
  const [loading, setLoading] = useState(true);
335
+ const [metaError, setMetaError] = useState<unknown>(null);
334
336
 
335
337
  const [method, setMethod] = useState<string>("GET");
336
338
  const [path, setPath] = useState("");
@@ -353,15 +355,17 @@ export function PlugDebugger({
353
355
  const containerRef = useRef<HTMLDivElement>(null);
354
356
 
355
357
  const fetchMeta = useCallback(async () => {
358
+ setLoading(true);
359
+ setMetaError(null);
356
360
  try {
357
- const res = await fetch(`${basePath}/debug/${plugName}`);
358
- if (!res.ok) {
359
- setMeta(null);
360
- return;
361
- }
362
- setMeta(await res.json());
363
- } catch {
361
+ setMeta(await khotanFetch<PlugMeta>(`${basePath}/debug/${plugName}`));
362
+ } catch (err) {
364
363
  setMeta(null);
364
+ // A 404 means debug is off or the plug isn't registered — handled by the
365
+ // dedicated "not available" message below. Surface everything else.
366
+ if (!(isKhotanApiError(err) && err.status === 404)) {
367
+ setMetaError(err);
368
+ }
365
369
  } finally {
366
370
  setLoading(false);
367
371
  }
@@ -401,6 +405,10 @@ export function PlugDebugger({
401
405
  );
402
406
  }
403
407
 
408
+ if (metaError) {
409
+ return <ApiErrorState error={metaError} onRetry={() => void fetchMeta()} />;
410
+ }
411
+
404
412
  if (!meta) {
405
413
  return (
406
414
  <div className="rounded-lg border border-border p-6 text-center">
@@ -2,6 +2,7 @@
2
2
 
3
3
  import { Fragment, useCallback, useEffect, useState } from "react";
4
4
  import { RefreshCw } from "lucide-react";
5
+ import { khotanFetch, ApiErrorState } from "./api-state";
5
6
  import { Badge } from "@/components/ui/badge";
6
7
  import { Button } from "@/components/ui/button";
7
8
  import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
@@ -359,7 +360,7 @@ export function KhotanRunsTable({ pageSize = 10 }: { pageSize?: number } = {}) {
359
360
  const [data, setData] = useState<PageResponse<RunLogItem> | null>(null);
360
361
  const [offset, setOffset] = useState(0);
361
362
  const [loading, setLoading] = useState(true);
362
- const [error, setError] = useState<string | null>(null);
363
+ const [error, setError] = useState<unknown>(null);
363
364
  const [refreshKey, setRefreshKey] = useState(0);
364
365
  const [expandedRunId, setExpandedRunId] = useState<string | null>(null);
365
366
  const [streamingEnabled, setStreamingEnabled] = useState(false);
@@ -378,20 +379,16 @@ export function KhotanRunsTable({ pageSize = 10 }: { pageSize?: number } = {}) {
378
379
  setLoading(true);
379
380
  setError(null);
380
381
  try {
381
- const res = await fetch(
382
+ const json = await khotanFetch<PageResponse<RunLogItem>>(
382
383
  `/api/khotan/runs?limit=${String(pageSize)}&offset=${String(offset)}`,
383
384
  );
384
- if (!res.ok) {
385
- throw new Error("Failed to load runs");
386
- }
387
- const json = (await res.json()) as PageResponse<RunLogItem>;
388
385
  if (!cancelled) {
389
386
  setData(json);
390
387
  setLastUpdatedAt(new Date().toISOString());
391
388
  }
392
389
  } catch (err) {
393
390
  if (!cancelled) {
394
- setError(err instanceof Error ? err.message : "Unknown error");
391
+ setError(err);
395
392
  }
396
393
  } finally {
397
394
  if (!cancelled) {
@@ -454,138 +451,144 @@ export function KhotanRunsTable({ pageSize = 10 }: { pageSize?: number } = {}) {
454
451
  </CardHeader>
455
452
  <CardContent className="space-y-4">
456
453
  {error ? (
457
- <div className="rounded-md border border-destructive/30 bg-destructive/5 p-3 text-sm text-destructive">
458
- {error}
459
- </div>
454
+ <ApiErrorState
455
+ error={error}
456
+ onRetry={() => setRefreshKey((v) => v + 1)}
457
+ compact
458
+ />
460
459
  ) : null}
461
460
 
462
- <Table>
463
- <TableHeader>
464
- <TableRow>
465
- <TableHead>Started</TableHead>
466
- <TableHead>Status</TableHead>
467
- <TableHead>Source</TableHead>
468
- <TableHead>Plug</TableHead>
469
- <TableHead>Run Type</TableHead>
470
- <TableHead>Counts</TableHead>
471
- <TableHead>Workflow</TableHead>
472
- <TableHead />
473
- </TableRow>
474
- </TableHeader>
475
- <TableBody>
476
- {loading ? (
461
+ {error ? null : (
462
+ <Table>
463
+ <TableHeader>
477
464
  <TableRow>
478
- <TableCell
479
- colSpan={8}
480
- className="text-sm text-muted-foreground"
481
- >
482
- Loading runs...
483
- </TableCell>
465
+ <TableHead>Started</TableHead>
466
+ <TableHead>Status</TableHead>
467
+ <TableHead>Source</TableHead>
468
+ <TableHead>Plug</TableHead>
469
+ <TableHead>Run Type</TableHead>
470
+ <TableHead>Counts</TableHead>
471
+ <TableHead>Workflow</TableHead>
472
+ <TableHead />
484
473
  </TableRow>
485
- ) : data?.items.length ? (
486
- data.items.map((item) => (
487
- <Fragment key={item.id}>
488
- <TableRow>
489
- <TableCell className="text-sm text-muted-foreground">
490
- <div>{formatDateTime(item.startedAt)}</div>
491
- <div className="text-xs">
492
- {item.completedAt
493
- ? `completed ${formatDateTime(item.completedAt)}`
494
- : "in progress"}
495
- </div>
496
- </TableCell>
497
- <TableCell>
498
- <Badge variant={statusVariant[item.status]}>
499
- {statusLabel[item.status]}
500
- </Badge>
501
- {item.error ? (
502
- <div
503
- className="mt-1 max-w-56 truncate text-xs text-destructive"
504
- title={item.error}
505
- >
506
- {item.error}
507
- </div>
508
- ) : null}
509
- </TableCell>
510
- <TableCell className="font-medium">
511
- {formatSource(item)}
512
- </TableCell>
513
- <TableCell className="text-muted-foreground">
514
- {item.plugName ?? "-"}
515
- </TableCell>
516
- <TableCell className="font-mono text-xs">
517
- {item.runType}
518
- </TableCell>
519
- <TableCell className="max-w-64 text-xs text-muted-foreground">
520
- {formatCounts(item)}
521
- </TableCell>
522
- <TableCell className="font-mono text-xs text-muted-foreground">
523
- {item.workflowRunId ?? "-"}
524
- </TableCell>
525
- <TableCell className="text-right">
526
- <Button
527
- variant="outline"
528
- size="sm"
529
- onClick={() =>
530
- setExpandedRunId((current) =>
531
- current === item.id ? null : item.id,
532
- )
533
- }
534
- >
535
- {expandedRunId === item.id ? "Hide" : "Details"}
536
- </Button>
537
- </TableCell>
538
- </TableRow>
539
- {expandedRunId === item.id ? (
474
+ </TableHeader>
475
+ <TableBody>
476
+ {loading ? (
477
+ <TableRow>
478
+ <TableCell
479
+ colSpan={8}
480
+ className="text-sm text-muted-foreground"
481
+ >
482
+ Loading runs...
483
+ </TableCell>
484
+ </TableRow>
485
+ ) : data?.items.length ? (
486
+ data.items.map((item) => (
487
+ <Fragment key={item.id}>
540
488
  <TableRow>
541
- <TableCell colSpan={8}>
542
- <RunDetails
543
- run={item}
544
- streamingEnabled={streamingEnabled}
545
- onChanged={() => setRefreshKey((v) => v + 1)}
546
- onStreamInbound={pulseLiveIndicator}
547
- />
489
+ <TableCell className="text-sm text-muted-foreground">
490
+ <div>{formatDateTime(item.startedAt)}</div>
491
+ <div className="text-xs">
492
+ {item.completedAt
493
+ ? `completed ${formatDateTime(item.completedAt)}`
494
+ : "in progress"}
495
+ </div>
496
+ </TableCell>
497
+ <TableCell>
498
+ <Badge variant={statusVariant[item.status]}>
499
+ {statusLabel[item.status]}
500
+ </Badge>
501
+ {item.error ? (
502
+ <div
503
+ className="mt-1 max-w-56 truncate text-xs text-destructive"
504
+ title={item.error}
505
+ >
506
+ {item.error}
507
+ </div>
508
+ ) : null}
509
+ </TableCell>
510
+ <TableCell className="font-medium">
511
+ {formatSource(item)}
512
+ </TableCell>
513
+ <TableCell className="text-muted-foreground">
514
+ {item.plugName ?? "-"}
515
+ </TableCell>
516
+ <TableCell className="font-mono text-xs">
517
+ {item.runType}
518
+ </TableCell>
519
+ <TableCell className="max-w-64 text-xs text-muted-foreground">
520
+ {formatCounts(item)}
521
+ </TableCell>
522
+ <TableCell className="font-mono text-xs text-muted-foreground">
523
+ {item.workflowRunId ?? "-"}
524
+ </TableCell>
525
+ <TableCell className="text-right">
526
+ <Button
527
+ variant="outline"
528
+ size="sm"
529
+ onClick={() =>
530
+ setExpandedRunId((current) =>
531
+ current === item.id ? null : item.id,
532
+ )
533
+ }
534
+ >
535
+ {expandedRunId === item.id ? "Hide" : "Details"}
536
+ </Button>
548
537
  </TableCell>
549
538
  </TableRow>
550
- ) : null}
551
- </Fragment>
552
- ))
553
- ) : (
554
- <TableRow>
555
- <TableCell
556
- colSpan={8}
557
- className="text-sm text-muted-foreground"
558
- >
559
- No runs recorded yet.
560
- </TableCell>
561
- </TableRow>
562
- )}
563
- </TableBody>
564
- </Table>
539
+ {expandedRunId === item.id ? (
540
+ <TableRow>
541
+ <TableCell colSpan={8}>
542
+ <RunDetails
543
+ run={item}
544
+ streamingEnabled={streamingEnabled}
545
+ onChanged={() => setRefreshKey((v) => v + 1)}
546
+ onStreamInbound={pulseLiveIndicator}
547
+ />
548
+ </TableCell>
549
+ </TableRow>
550
+ ) : null}
551
+ </Fragment>
552
+ ))
553
+ ) : (
554
+ <TableRow>
555
+ <TableCell
556
+ colSpan={8}
557
+ className="text-sm text-muted-foreground"
558
+ >
559
+ No runs recorded yet.
560
+ </TableCell>
561
+ </TableRow>
562
+ )}
563
+ </TableBody>
564
+ </Table>
565
+ )}
565
566
 
566
- <div className="flex items-center justify-between gap-3">
567
- <p className="text-sm text-muted-foreground">
568
- Page {Math.floor(offset / pageSize) + 1}
569
- </p>
570
- <div className="flex items-center gap-2">
571
- <Button
572
- variant="outline"
573
- size="sm"
574
- disabled={offset === 0 || loading}
575
- onClick={() => setOffset(Math.max(offset - pageSize, 0))}
576
- >
577
- Previous
578
- </Button>
579
- <Button
580
- variant="outline"
581
- size="sm"
582
- disabled={!data?.page.hasMore || loading}
583
- onClick={() => setOffset(offset + pageSize)}
584
- >
585
- Next
586
- </Button>
567
+ {error ? null : (
568
+ <div className="flex items-center justify-between gap-3">
569
+ <p className="text-sm text-muted-foreground">
570
+ Page {Math.floor(offset / pageSize) + 1}
571
+ </p>
572
+ <div className="flex items-center gap-2">
573
+ <Button
574
+ variant="outline"
575
+ size="sm"
576
+ disabled={offset === 0 || loading}
577
+ onClick={() => setOffset(Math.max(offset - pageSize, 0))}
578
+ >
579
+ Previous
580
+ </Button>
581
+ <Button
582
+ variant="outline"
583
+ size="sm"
584
+ disabled={!data?.page.hasMore || loading}
585
+ onClick={() => setOffset(offset + pageSize)}
586
+ >
587
+ Next
588
+ </Button>
589
+ </div>
587
590
  </div>
588
- </div>
591
+ )}
589
592
  </CardContent>
590
593
  </Card>
591
594
  );
@@ -90,9 +90,44 @@ The schema command auto-detects your Drizzle schema directory, updates `drizzle.
90
90
  |----------|----------|---------|
91
91
  | `DATABASE_URL` | Yes | Postgres connection (used by Drizzle) |
92
92
  | `KHOTAN_SECRET` | For variables | AES-256-GCM key for encrypting plug vars |
93
- | `KHOTAN_DEBUG` | For debugging | Enables `/debug/*` routes and the `plug` CLI (`probe` alias) |
93
+ | `KHOTAN_DEBUG` | For debugging | Enables `/debug/*` routes and the `plug` CLI (`probe` alias). Automatically disabled when `NODE_ENV=production` |
94
94
  | `KHOTAN_WEBHOOK_URL` | For webhooks | Public URL for wire callbacks |
95
- | `CRON_SECRET` | For production cron | Protects the built-in `/api/khotan/cron` dispatcher route |
95
+ | `CRON_SECRET` | For production cron | Protects the built-in `/api/khotan/cron` dispatcher route. The route fails closed in production when this is unset |
96
+
97
+ ## Securing the Management API
98
+
99
+ The management API (`/api/khotan/*`) and the Hub dashboard expose plug
100
+ credentials and operational controls. **They are public unless you gate them.**
101
+
102
+ Pass an `authorize` hook to `khotan({ ... })`. It receives the raw `Request` and
103
+ returns `true` to allow the request or `false` to reject it with `401`. It
104
+ composes directly with session libraries like better-auth:
105
+
106
+ ```typescript
107
+ import { khotan, drizzleAdapter } from "khotan-data/factory";
108
+ import { auth } from "@/lib/auth";
109
+ import { db } from "@/db";
110
+
111
+ const khotanData = khotan({
112
+ adapter: drizzleAdapter(db),
113
+ authorize: async (request) => {
114
+ const session = await auth.api.getSession({ headers: request.headers });
115
+ return Boolean(session?.user); // or: session?.user?.role === "admin"
116
+ },
117
+ plugs: [/* ... */],
118
+ });
119
+ ```
120
+
121
+ Notes:
122
+ - `authorize` is **not** a replacement for `KHOTAN_SECRET` — that key only
123
+ encrypts credentials at rest, it does not authenticate requests.
124
+ - Inbound webhooks (`POST /webhook/:plug`, verified per-plug via `onVerify`),
125
+ the cron dispatcher (`CRON_SECRET`), and debug routes (`KHOTAN_DEBUG`,
126
+ non-production only) are exempt from `authorize` automatically.
127
+ - Also protect the Hub dashboard page (e.g. `/config`) with your app's
128
+ middleware — `authorize` only guards the API, not your React pages.
129
+ - Without `authorize`, khotan logs a startup warning. Always configure it
130
+ before deploying.
96
131
 
97
132
  ## Next.js Config
98
133
 
@@ -18,6 +18,7 @@ import {
18
18
  type NodeProps,
19
19
  } from "@xyflow/react";
20
20
  import "@xyflow/react/dist/style.css";
21
+ import { khotanFetch, ApiErrorState } from "./api-state";
21
22
  import { Badge } from "@/components/ui/badge";
22
23
  import {
23
24
  Card,
@@ -1056,8 +1057,9 @@ function TopologyCanvasInner() {
1056
1057
  const [nodes, setNodes, onNodesChange] = useNodesState<TopologyNodeData>([]);
1057
1058
  const [edges, setEdges, onEdgesChange] = useEdgesState([]);
1058
1059
  const [loading, setLoading] = useState(true);
1059
- const [error, setError] = useState<string | null>(null);
1060
+ const [error, setError] = useState<unknown>(null);
1060
1061
  const [lastUpdatedAt, setLastUpdatedAt] = useState<string | null>(null);
1062
+ const [refreshKey, setRefreshKey] = useState(0);
1061
1063
 
1062
1064
  useEffect(() => {
1063
1065
  let cancelled = false;
@@ -1068,30 +1070,22 @@ function TopologyCanvasInner() {
1068
1070
  setError(null);
1069
1071
  }
1070
1072
 
1071
- const [plugsRes, flowsRes, runsRes] = await Promise.all([
1072
- fetch("/api/khotan/plugs"),
1073
- fetch("/api/khotan/flows"),
1074
- fetch("/api/khotan/runs?limit=100"),
1073
+ const [plugs, flows, runsRaw] = await Promise.all([
1074
+ khotanFetch<PlugRecord[]>("/api/khotan/plugs"),
1075
+ khotanFetch<FlowRecord[]>("/api/khotan/flows"),
1076
+ khotanFetch<unknown>("/api/khotan/runs?limit=100"),
1075
1077
  ]);
1076
1078
 
1077
- if (!plugsRes.ok || !flowsRes.ok || !runsRes.ok) {
1078
- throw new Error("Failed to load topology data from /api/khotan");
1079
- }
1080
-
1081
- const plugs = (await plugsRes.json()) as PlugRecord[];
1082
- const flows = (await flowsRes.json()) as FlowRecord[];
1083
- const runs = normalizeRuns(await runsRes.json());
1079
+ const runs = normalizeRuns(runsRaw);
1084
1080
 
1085
1081
  const webhookGroups = await Promise.all(
1086
1082
  plugs.map(async (plug) => {
1087
1083
  try {
1088
- const res = await fetch(
1084
+ const handlers = await khotanFetch<
1085
+ Array<Omit<WebhookHandlerRecord, "plugName">>
1086
+ >(
1089
1087
  `/api/khotan/webhook-handlers/${encodeURIComponent(plug.name)}`,
1090
1088
  );
1091
- if (!res.ok) return [] as WebhookHandlerRecord[];
1092
- const handlers = (await res.json()) as Array<
1093
- Omit<WebhookHandlerRecord, "plugName">
1094
- >;
1095
1089
  return handlers.map((handler) => ({
1096
1090
  ...handler,
1097
1091
  plugName: plug.name,
@@ -1113,11 +1107,7 @@ function TopologyCanvasInner() {
1113
1107
  setLastUpdatedAt(new Date().toISOString());
1114
1108
  } catch (loadError) {
1115
1109
  if (!cancelled) {
1116
- setError(
1117
- loadError instanceof Error
1118
- ? loadError.message
1119
- : "Unknown topology load failure",
1120
- );
1110
+ setError(loadError);
1121
1111
  }
1122
1112
  } finally {
1123
1113
  if (!cancelled) {
@@ -1135,7 +1125,7 @@ function TopologyCanvasInner() {
1135
1125
  cancelled = true;
1136
1126
  window.clearInterval(interval);
1137
1127
  };
1138
- }, []);
1128
+ }, [refreshKey]);
1139
1129
 
1140
1130
  const model = useMemo(() => {
1141
1131
  return snapshot ? buildTopologyModel(snapshot) : null;
@@ -1180,19 +1170,18 @@ function TopologyCanvasInner() {
1180
1170
 
1181
1171
  if (error) {
1182
1172
  return (
1183
- <Card className="border-red-200 bg-white/80 shadow-xl backdrop-blur">
1173
+ <Card className="border-white/70 bg-white/80 shadow-xl backdrop-blur">
1184
1174
  <CardHeader>
1185
1175
  <CardTitle>Topology Canvas</CardTitle>
1186
1176
  <CardDescription>
1187
1177
  Could not load the graph from the local Khotan API.
1188
1178
  </CardDescription>
1189
1179
  </CardHeader>
1190
- <CardContent className="space-y-3 text-sm">
1191
- <p className="text-red-600">{error}</p>
1192
- <p className="text-slate-500">
1193
- Make sure the catch-all Khotan route is mounted and your dev server
1194
- is running.
1195
- </p>
1180
+ <CardContent>
1181
+ <ApiErrorState
1182
+ error={error}
1183
+ onRetry={() => setRefreshKey((v) => v + 1)}
1184
+ />
1196
1185
  </CardContent>
1197
1186
  </Card>
1198
1187
  );
@@ -6,6 +6,7 @@ import { Badge } from "@/components/ui/badge";
6
6
  import { Button } from "@/components/ui/button";
7
7
  import { Input } from "@/components/ui/input";
8
8
  import { Label } from "@/components/ui/label";
9
+ import { khotanFetch, ApiErrorState } from "./api-state";
9
10
 
10
11
  // ============================================================================
11
12
  // Var Panel — UI for managing plug variables
@@ -44,6 +45,7 @@ export function VarPanel({
44
45
  const [configured, setConfigured] = useState(false);
45
46
  const [loading, setLoading] = useState(true);
46
47
  const [saving, setSaving] = useState(false);
48
+ const [loadError, setLoadError] = useState<unknown>(null);
47
49
  const [error, setError] = useState<string | null>(null);
48
50
  const [success, setSuccess] = useState<string | null>(null);
49
51
  const [editing, setEditing] = useState(false);
@@ -52,21 +54,23 @@ export function VarPanel({
52
54
  );
53
55
 
54
56
  const fetchVariables = useCallback(async () => {
57
+ setLoading(true);
58
+ setLoadError(null);
55
59
  try {
56
- const res = await fetch(`${basePath}/variables/${plugName}`);
57
- if (!res.ok) {
58
- setFields([]);
59
- setConfigured(false);
60
- return;
61
- }
62
- const data = await res.json();
60
+ const data = await khotanFetch<{
61
+ fields?: VarField[];
62
+ values?: Record<string, string>;
63
+ configured?: boolean;
64
+ }>(`${basePath}/variables/${plugName}`);
63
65
  setFields((data.fields ?? []).filter((f: VarField) => !f.hidden));
64
66
  setValues(data.values ?? {});
65
67
  setConfigured(data.configured ?? false);
66
- setFormValues(data.configured ? data.values : {});
68
+ setFormValues(data.configured ? (data.values ?? {}) : {});
67
69
  setError(null);
68
- } catch {
69
- setError("Failed to load variables");
70
+ } catch (err) {
71
+ setFields([]);
72
+ setConfigured(false);
73
+ setLoadError(err);
70
74
  } finally {
71
75
  setLoading(false);
72
76
  }
@@ -144,6 +148,25 @@ export function VarPanel({
144
148
  );
145
149
  }
146
150
 
151
+ if (loadError) {
152
+ return (
153
+ <Card>
154
+ <CardHeader className="pb-2">
155
+ <CardTitle className="text-sm font-medium capitalize">
156
+ {displayName} Variables
157
+ </CardTitle>
158
+ </CardHeader>
159
+ <CardContent>
160
+ <ApiErrorState
161
+ error={loadError}
162
+ onRetry={() => void fetchVariables()}
163
+ compact
164
+ />
165
+ </CardContent>
166
+ </Card>
167
+ );
168
+ }
169
+
147
170
  if (fields.length === 0) return null;
148
171
 
149
172
  return (