cartethyia 2.3.4 → 2.4.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.
Files changed (28) hide show
  1. package/dist/dashboard/assets/{BannedPage-Dc8V-nxc.js → BannedPage-CvnNj-tj.js} +1 -1
  2. package/dist/dashboard/assets/{CliToolDetailPage-ifAh7wbK.js → CliToolDetailPage-DdYuTZKs.js} +1 -1
  3. package/dist/dashboard/assets/{CliToolsPage-2A1c_dFb.js → CliToolsPage-Ba55GO-L.js} +1 -1
  4. package/dist/dashboard/assets/{ConsoleLogPage-FfxY8_yn.js → ConsoleLogPage-D-OQwsYE.js} +1 -1
  5. package/dist/dashboard/assets/{CustomizationPage-CZ5H40Ha.js → CustomizationPage-BMqN7mc4.js} +1 -1
  6. package/dist/dashboard/assets/{HealthEventsModal-CZKQak_s.js → HealthEventsModal-C5cygmiy.js} +1 -1
  7. package/dist/dashboard/assets/{LoginPage-sqFYY4uv.js → LoginPage-lI7CDb07.js} +1 -1
  8. package/dist/dashboard/assets/{OverviewPage-BOZJaauG.js → OverviewPage-HaMJcZaC.js} +1 -1
  9. package/dist/dashboard/assets/{ProviderDetailPage-CNUCsNFg.js → ProviderDetailPage-Pb-0zXVA.js} +1 -1
  10. package/dist/dashboard/assets/{ProvidersPage-CsXoemJ1.js → ProvidersPage-KifbSUkV.js} +1 -1
  11. package/dist/dashboard/assets/{ProxyPage-DvRt3aZP.js → ProxyPage-DKLnIcne.js} +1 -1
  12. package/dist/dashboard/assets/{QuotaPage-DNYJWw7s.js → QuotaPage-BzG3XTSg.js} +1 -1
  13. package/dist/dashboard/assets/{SetupPage-Cz1DWkcr.js → SetupPage-Cu42oonZ.js} +1 -1
  14. package/dist/dashboard/assets/{StudioPage-CsKD9sy-.js → StudioPage-DIglSNvf.js} +1 -1
  15. package/dist/dashboard/assets/{UsagePage-_Zu6460J.js → UsagePage-DQh2rUsY.js} +1 -1
  16. package/dist/dashboard/assets/{entry-COuKgXuV.js → entry-6PSJYbzI.js} +3 -3
  17. package/dist/dashboard/assets/{index-CF94-Gob.js → index-Bysq3XgY.js} +2 -2
  18. package/dist/dashboard/index.html +1 -1
  19. package/package.json +2 -1
  20. package/scripts/cli/cli-launcher.cjs +12 -1
  21. package/scripts/cli/cli.ts +923 -657
  22. package/scripts/cli/menu.ts +107 -53
  23. package/scripts/cli/runtime-state.ts +48 -32
  24. package/scripts/cli/setup.ts +448 -443
  25. package/scripts/cli/wordmark.ts +58 -0
  26. package/src/boot/app.ts +39 -0
  27. package/src/console/auth/account-reset.ts +67 -0
  28. package/src/console/backup/export-endpoint.ts +111 -0
@@ -0,0 +1,58 @@
1
+ /**
2
+ * The console wordmark.
3
+ *
4
+ * Rendered with half-block characters so each letter is two pixel rows tall in
5
+ * one character cell, and coloured with a horizontal gradient across the
6
+ * Cartethyia blue-grey range. The caller decides whether to print it: a
7
+ * non-colour terminal, or one narrower than the wordmark, should skip it.
8
+ */
9
+
10
+ const WORDMARK: readonly string[] = [
11
+ " ██████╗ █████╗ ██████╗ ████████╗███████╗████████╗██╗ ██╗██╗ ██╗██╗ █████╗ ",
12
+ "██╔════╝██╔══██╗██╔══██╗╚══██╔══╝██╔════╝╚══██╔══╝██║ ██║╚██╗ ██╔╝██║██╔══██╗",
13
+ "██║ ███████║██████╔╝ ██║ █████╗ ██║ ███████║ ╚████╔╝ ██║███████║",
14
+ "██║ ██╔══██║██╔══██╗ ██║ ██╔══╝ ██║ ██╔══██║ ╚██╔╝ ██║██╔══██║",
15
+ "╚██████╗██║ ██║██║ ██║ ██║ ███████╗ ██║ ██║ ██║ ██║ ██║██║ ██║",
16
+ " ╚═════╝╚═╝ ╚═╝╚═╝ ╚═╝ ╚═╝ ╚══════╝ ╚═╝ ╚═╝ ╚═╝ ╚═╝ ╚═╝╚═╝ ╚═╝",
17
+ ];
18
+
19
+ /** Blue-grey gradient: dim at the edges, brighter through the middle. */
20
+ const GRADIENT: readonly [number, number, number][] = [
21
+ [90, 120, 150],
22
+ [120, 160, 200],
23
+ [150, 200, 240],
24
+ [120, 160, 200],
25
+ [90, 120, 150],
26
+ ];
27
+
28
+ const RESET = "\u001b[0m";
29
+
30
+ /**
31
+ * Returns the wordmark lines with per-column colour, or an empty array when the
32
+ * terminal is too narrow or colour is disabled.
33
+ */
34
+ export function renderWordmark(columns = process.stdout.columns ?? 80): string[] {
35
+ if (process.env.NO_COLOR !== undefined) return [];
36
+ const width = WORDMARK[0]!.length;
37
+ if (columns < width) return [];
38
+
39
+ const coloured: string[] = [];
40
+ for (const row of WORDMARK) {
41
+ let line = "";
42
+ let current = -1;
43
+ for (let column = 0; column < row.length; column += 1) {
44
+ const band = Math.min(
45
+ GRADIENT.length - 1,
46
+ Math.floor((column / width) * GRADIENT.length),
47
+ );
48
+ const [r, g, b] = GRADIENT[band]!;
49
+ if (band !== current) {
50
+ line += `\u001b[38;2;${r};${g};${b}m`;
51
+ current = band;
52
+ }
53
+ line += row[column];
54
+ }
55
+ coloured.push(`${line}${RESET}`);
56
+ }
57
+ return coloured;
58
+ }
package/src/boot/app.ts CHANGED
@@ -39,6 +39,9 @@ import { createTokenCountHandler } from "../transport/dispatch/token-count";
39
39
 
40
40
  import { createTransportPipeline } from "../transport/guards/pipeline";
41
41
  import { createDrainHandler, isLoopbackPeer, tokenMatches } from "../transport/drain-endpoint";
42
+ import { createConsoleAccountResetHandler } from "../console/auth/account-reset";
43
+ import { createBackupExportHandler } from "../console/backup/export-endpoint";
44
+ import { consoleUsers, tenants } from "../persistence/schema";
42
45
  import { PublicModelCatalogStore, type AllowedModelEntry } from "../console/providers/catalog/public-model-store";
43
46
  import type { IpAbuseProtectionService } from "../security/abuse";
44
47
  import type { ReadinessCheckResult } from "../persistence/readiness";
@@ -549,6 +552,42 @@ export function createGatewayApp(deps: GatewayAppDeps) {
549
552
  app.post("/admin/drain", drainHandler);
550
553
  }
551
554
 
555
+ // Operator backup export. Same double gate as drain and reset, and no
556
+ // console password: the CLI already runs as the OS user who owns the
557
+ // database, and a forgotten password must not block the backup that is
558
+ // meant to rescue the install.
559
+ if (deps.drainToken) {
560
+ app.post(
561
+ "/admin/export-backup",
562
+ createBackupExportHandler({
563
+ db: deps.db,
564
+ token: deps.drainToken,
565
+ resolvePeerAddress: (request) => peerAddresses.get(request) ?? null,
566
+ resolveTenantId: async () => {
567
+ const [row] = await deps.db.select({ id: tenants.id }).from(tenants).limit(1);
568
+ return row?.id ?? null;
569
+ },
570
+ }),
571
+ );
572
+ }
573
+
574
+ // Console account reset. Same double gate as drain — loopback plus the
575
+ // token — because it deletes every operator credential on the install.
576
+ // Only authentication state goes; providers, keys, and routing stay.
577
+ if (deps.drainToken) {
578
+ app.post(
579
+ "/admin/reset-console-account",
580
+ createConsoleAccountResetHandler({
581
+ token: deps.drainToken,
582
+ resolvePeerAddress: (request) => peerAddresses.get(request) ?? null,
583
+ resetConsoleAccount: async () => {
584
+ const users = await deps.db.delete(consoleUsers);
585
+ return users.rowCount ?? 0;
586
+ },
587
+ }),
588
+ );
589
+ }
590
+
552
591
  // Manual task trigger. Same gate as drain: loopback plus the drain token,
553
592
  // so a leaked token still cannot reach it from outside the host. 202 once
554
593
  // the run has started; 409 when that task is already mid-run, because the
@@ -0,0 +1,67 @@
1
+ /**
2
+ * Console account reset: `POST /admin/reset-console-account`.
3
+ *
4
+ * Why this exists. A forgotten console password had no recovery path at all:
5
+ * `/console/api/auth/change-password` requires the current password, and
6
+ * first-boot setup only runs when no console user exists. The only documented
7
+ * escape was deleting the database by hand. This endpoint removes every console
8
+ * user, which puts `requiresSetup()` back to true so the next visit to
9
+ * `/console` runs the create-an-account wizard again.
10
+ *
11
+ * What it does not touch: providers, provider accounts, API keys, routing,
12
+ * combos, aliases, usage, and telemetry. Those are not authentication state and
13
+ * an operator resetting a password has no reason to lose them.
14
+ *
15
+ * Gated exactly like the drain endpoint — loopback peer plus a constant-time
16
+ * token comparison — because it destroys every operator credential on the
17
+ * install. The two gates are independent, so a leaked token alone cannot reach
18
+ * it from another host.
19
+ *
20
+ * The caller must send the literal confirmation `RESET` in the body. That is a
21
+ * deliberate speed bump: this is destructive and irreversible, and the TUI asks
22
+ * the operator to type it rather than press Enter on a menu row.
23
+ */
24
+ import { GatewayError } from "../../protocol/gateway-error";
25
+ import { isLoopbackPeer, tokenMatches } from "../../transport/drain-endpoint";
26
+
27
+ export interface ConsoleAccountResetDeps {
28
+ /** Shared secret; the route is registered only when this is set. */
29
+ readonly token: string;
30
+ /** Removes every console user and session. Resolves to the number removed. */
31
+ readonly resetConsoleAccount: () => Promise<number>;
32
+ /** Peer address of the request, resolved by the composition root. */
33
+ readonly resolvePeerAddress: (request: Request) => string | null;
34
+ }
35
+
36
+ const CONFIRMATION = "RESET";
37
+
38
+ export function createConsoleAccountResetHandler(
39
+ deps: ConsoleAccountResetDeps,
40
+ ): (context: { request: Request; body: unknown }) => Promise<Response> {
41
+ return async ({ request, body }) => {
42
+ if (!isLoopbackPeer(deps.resolvePeerAddress(request)))
43
+ throw new GatewayError(
44
+ "invalid_request",
45
+ 403,
46
+ "console account reset is only available from loopback",
47
+ );
48
+ if (!tokenMatches(request.headers.get("x-drain-token"), deps.token))
49
+ throw new GatewayError("invalid_request", 401, "invalid reset token");
50
+ const confirm =
51
+ body !== null && typeof body === "object" && "confirm" in body
52
+ ? body.confirm
53
+ : undefined;
54
+ if (confirm !== CONFIRMATION)
55
+ throw new GatewayError(
56
+ "invalid_request",
57
+ 400,
58
+ `send {"confirm":"${CONFIRMATION}"} to confirm; this deletes every console account`,
59
+ );
60
+
61
+ const removed = await deps.resetConsoleAccount();
62
+ return new Response(
63
+ JSON.stringify({ status: "reset", removed }),
64
+ { status: 200, headers: { "content-type": "application/json", "cache-control": "no-store" } },
65
+ );
66
+ };
67
+ }
@@ -0,0 +1,111 @@
1
+ /**
2
+ * Operator backup export: `POST /admin/export-backup`.
3
+ *
4
+ * Why an endpoint instead of the console page. The dashboard export requires
5
+ * the operator's console password, because the browser session is the only
6
+ * proof of identity it has. The CLI is already running as the OS user who owns
7
+ * the config directory and the database, so requiring a password there only
8
+ * means a forgotten password blocks the backup that is supposed to rescue the
9
+ * install. This route is gated like the drain and reset routes — loopback peer
10
+ * plus the constant-time token — and needs no password.
11
+ *
12
+ * What it writes: a gzipped JSON backup of the `config` section only.
13
+ * `telemetry_events`, `telemetry_usage_totals`, and `telemetry_payloads` are
14
+ * deliberately excluded: they are per-request history, not configuration, they
15
+ * are the bulk of the file, and restoring them is not part of what an operator
16
+ * is trying to recover.
17
+ *
18
+ * Where it lands: the directory the caller names, defaulting to the OS download
19
+ * directory. The CLI passes that path; the route refuses to write outside it.
20
+ */
21
+ import { mkdirSync, writeFileSync } from "node:fs";
22
+ import { homedir } from "node:os";
23
+ import { join } from "node:path";
24
+ import { GatewayError } from "../../protocol/gateway-error";
25
+ import { isLoopbackPeer, tokenMatches } from "../../transport/drain-endpoint";
26
+ import { BACKUP_SECTIONS, tablesForSection } from "./contracts";
27
+ import { exportBackup } from "./store";
28
+ import type { CartethyiaDatabase } from "../../persistence/db-handle";
29
+
30
+ export interface BackupExportDeps {
31
+ readonly db: CartethyiaDatabase;
32
+ /** Shared secret; the route is registered only when this is set. */
33
+ readonly token: string;
34
+ /** The tenant whose rows are exported. */
35
+ readonly resolveTenantId: () => Promise<string | null>;
36
+ /** Peer address of the request, resolved by the composition root. */
37
+ readonly resolvePeerAddress: (request: Request) => string | null;
38
+ }
39
+
40
+ export interface BackupExportResult {
41
+ readonly path: string;
42
+ readonly bytes: number;
43
+ readonly counts: Record<string, number>;
44
+ }
45
+
46
+ export function createBackupExportHandler(
47
+ deps: BackupExportDeps,
48
+ ): (context: { request: Request; body: unknown }) => Promise<Response> {
49
+ return async ({ request, body }) => {
50
+ if (!isLoopbackPeer(deps.resolvePeerAddress(request)))
51
+ throw new GatewayError("invalid_request", 403, "backup export is only available from loopback");
52
+ if (!tokenMatches(request.headers.get("x-drain-token"), deps.token))
53
+ throw new GatewayError("invalid_request", 401, "invalid export token");
54
+
55
+ const directory =
56
+ body !== null && typeof body === "object" && "directory" in body
57
+ ? body.directory
58
+ : undefined;
59
+ if (directory !== undefined && typeof directory !== "string")
60
+ throw new GatewayError("invalid_request", 400, "directory must be a string");
61
+
62
+ const tenantId = await deps.resolveTenantId();
63
+ if (tenantId === null)
64
+ throw new GatewayError("invalid_request", 409, "no tenant exists; run setup first");
65
+
66
+ const { payload, counts } = await exportBackup(
67
+ deps.db,
68
+ tablesForSection("config"),
69
+ tenantId,
70
+ );
71
+
72
+ // gzipSync matches what the console writes, so a CLI backup and a dashboard
73
+ // backup are the same file format and either can be restored by the other.
74
+ const json = JSON.stringify(payload);
75
+ const gzipped = Bun.gzipSync(new TextEncoder().encode(json));
76
+ const target = directory ?? downloadDirectory();
77
+ const stamp = new Date().toISOString().replace(/[:.]/g, "-");
78
+ const path = join(target, `cartethyia-backup-${stamp}.json.gz`);
79
+
80
+ try {
81
+ mkdirSync(target, { recursive: true });
82
+ writeFileSync(path, gzipped);
83
+ } catch (error) {
84
+ throw new GatewayError(
85
+ "invalid_request",
86
+ 500,
87
+ `could not write ${path}: ${error instanceof Error ? error.message : "unknown error"}`,
88
+ );
89
+ }
90
+
91
+ const result: BackupExportResult = { path, bytes: gzipped.byteLength, counts };
92
+ return new Response(JSON.stringify(result), {
93
+ status: 200,
94
+ headers: { "content-type": "application/json", "cache-control": "no-store" },
95
+ });
96
+ };
97
+ }
98
+
99
+ /** The OS download directory, falling back to the home directory. */
100
+ function downloadDirectory(): string {
101
+ const home = homedir();
102
+ const candidate =
103
+ process.platform === "win32"
104
+ ? join(home, "Downloads")
105
+ : process.platform === "darwin"
106
+ ? join(home, "Downloads")
107
+ : join(process.env.XDG_DOWNLOAD_DIR ?? join(home, "Downloads"));
108
+ return candidate;
109
+ }
110
+
111
+ export { BACKUP_SECTIONS };