cartethyia 2.5.7 → 2.5.9
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/dist/dashboard/assets/{BannedPage-DE2607Tb.js → BannedPage-Brvuj_Eb.js} +1 -1
- package/dist/dashboard/assets/BannedPage-Brvuj_Eb.js.gz +0 -0
- package/dist/dashboard/assets/{CliToolDetailPage-Duv6mfrl.js → CliToolDetailPage-CqfXtI3y.js} +1 -1
- package/dist/dashboard/assets/CliToolDetailPage-CqfXtI3y.js.gz +0 -0
- package/dist/dashboard/assets/{CliToolsPage-C2PNYWzk.js → CliToolsPage-GQeS91bg.js} +1 -1
- package/dist/dashboard/assets/CliToolsPage-GQeS91bg.js.gz +0 -0
- package/dist/dashboard/assets/{CombosPage-TXP9OfAM.js → CombosPage-BDRsTXBB.js} +1 -1
- package/dist/dashboard/assets/CombosPage-BDRsTXBB.js.gz +0 -0
- package/dist/dashboard/assets/{ConsoleLogPage-B-9xnAQN.js → ConsoleLogPage-zP8AFHX0.js} +1 -1
- package/dist/dashboard/assets/ConsoleLogPage-zP8AFHX0.js.gz +0 -0
- package/dist/dashboard/assets/{CustomizationPage-xeax9nJt.js → CustomizationPage-BmfrNZ14.js} +1 -1
- package/dist/dashboard/assets/CustomizationPage-BmfrNZ14.js.gz +0 -0
- package/dist/dashboard/assets/{HealthEventsModal-Df4jjaHJ.js → HealthEventsModal-Dn3M6WZc.js} +1 -1
- package/dist/dashboard/assets/HealthEventsModal-Dn3M6WZc.js.gz +0 -0
- package/dist/dashboard/assets/{LoginPage-Dyojsm84.js → LoginPage-DoreIgm8.js} +1 -1
- package/dist/dashboard/assets/LoginPage-DoreIgm8.js.gz +0 -0
- package/dist/dashboard/assets/{OverviewPage-BJfg4ow6.js → OverviewPage-CvvD1Ugk.js} +1 -1
- package/dist/dashboard/assets/OverviewPage-CvvD1Ugk.js.gz +0 -0
- package/dist/dashboard/assets/{ProviderDetailPage-ChCX1gmT.js → ProviderDetailPage-Clbn1gsW.js} +1 -1
- package/dist/dashboard/assets/ProviderDetailPage-Clbn1gsW.js.gz +0 -0
- package/dist/dashboard/assets/{ProvidersPage-DeWSOEUO.js → ProvidersPage-BnoPgA7w.js} +1 -1
- package/dist/dashboard/assets/ProvidersPage-BnoPgA7w.js.gz +0 -0
- package/dist/dashboard/assets/{ProxyPage-CkpWln2S.js → ProxyPage-Bq_TGgdb.js} +1 -1
- package/dist/dashboard/assets/ProxyPage-Bq_TGgdb.js.gz +0 -0
- package/dist/dashboard/assets/{QuotaPage-B3VOgQmt.js → QuotaPage-D9DHXvom.js} +1 -1
- package/dist/dashboard/assets/QuotaPage-D9DHXvom.js.gz +0 -0
- package/dist/dashboard/assets/{SettingsPage-C_klIZom.js → SettingsPage-7xV8WkWL.js} +1 -1
- package/dist/dashboard/assets/SettingsPage-7xV8WkWL.js.gz +0 -0
- package/dist/dashboard/assets/{SetupPage-BYph-InK.js → SetupPage-Day3mCOn.js} +1 -1
- package/dist/dashboard/assets/SetupPage-Day3mCOn.js.gz +0 -0
- package/dist/dashboard/assets/{SortableList-CvyQCYWJ.js → SortableList-DQxllnB5.js} +1 -1
- package/dist/dashboard/assets/SortableList-DQxllnB5.js.gz +0 -0
- package/dist/dashboard/assets/{StudioPage-Cm1JYilI.js → StudioPage-uMExoWu1.js} +1 -1
- package/dist/dashboard/assets/StudioPage-uMExoWu1.js.gz +0 -0
- package/dist/dashboard/assets/{UsagePage-DlFnc3_S.js → UsagePage-DXa1oR-4.js} +1 -1
- package/dist/dashboard/assets/UsagePage-DXa1oR-4.js.gz +0 -0
- package/dist/dashboard/assets/{entry-Bog0p925.js → entry-ByMGkR3C.js} +3 -3
- package/dist/dashboard/assets/entry-ByMGkR3C.js.gz +0 -0
- package/dist/dashboard/assets/{index-CZQIrdoX.js → index-z3zDOSMM.js} +2 -2
- package/dist/dashboard/assets/index-z3zDOSMM.js.gz +0 -0
- package/dist/dashboard/assets/{select-BPpSsBgL.js → select-C6MMiaio.js} +1 -1
- package/dist/dashboard/assets/select-C6MMiaio.js.gz +0 -0
- package/dist/dashboard/index.html +1 -1
- package/dist/dashboard/index.html.gz +0 -0
- package/dist/dashboard/precompressed-assets.json +21 -21
- package/package.json +3 -1
- package/scripts/build/aot.ts +143 -0
- package/scripts/build/build.ts +24 -0
- package/scripts/build/compile.ts +88 -0
- package/scripts/build/restart.ts +120 -0
- package/scripts/build/start.ts +25 -0
- package/scripts/health.ts +186 -0
- package/src/console/backup/nine-router.ts +43 -9
- package/src/providers/discovery/openrouter-free-roster.ts +0 -1
- package/src/providers/integrations/buddy/buddy-catalog-shared.ts +3 -2
- package/src/providers/integrations/buddy/codebuddy/cn-provider.ts +2 -1
- package/src/providers/integrations/opencode/provider.ts +0 -3
- package/src/providers/operations/provider-credential-service.ts +29 -3
- package/dist/dashboard/assets/BannedPage-DE2607Tb.js.gz +0 -0
- package/dist/dashboard/assets/CliToolDetailPage-Duv6mfrl.js.gz +0 -0
- package/dist/dashboard/assets/CliToolsPage-C2PNYWzk.js.gz +0 -0
- package/dist/dashboard/assets/CombosPage-TXP9OfAM.js.gz +0 -0
- package/dist/dashboard/assets/ConsoleLogPage-B-9xnAQN.js.gz +0 -0
- package/dist/dashboard/assets/CustomizationPage-xeax9nJt.js.gz +0 -0
- package/dist/dashboard/assets/HealthEventsModal-Df4jjaHJ.js.gz +0 -0
- package/dist/dashboard/assets/LoginPage-Dyojsm84.js.gz +0 -0
- package/dist/dashboard/assets/OverviewPage-BJfg4ow6.js.gz +0 -0
- package/dist/dashboard/assets/ProviderDetailPage-ChCX1gmT.js.gz +0 -0
- package/dist/dashboard/assets/ProvidersPage-DeWSOEUO.js.gz +0 -0
- package/dist/dashboard/assets/ProxyPage-CkpWln2S.js.gz +0 -0
- package/dist/dashboard/assets/QuotaPage-B3VOgQmt.js.gz +0 -0
- package/dist/dashboard/assets/SettingsPage-C_klIZom.js.gz +0 -0
- package/dist/dashboard/assets/SetupPage-BYph-InK.js.gz +0 -0
- package/dist/dashboard/assets/SortableList-CvyQCYWJ.js.gz +0 -0
- package/dist/dashboard/assets/StudioPage-Cm1JYilI.js.gz +0 -0
- package/dist/dashboard/assets/UsagePage-DlFnc3_S.js.gz +0 -0
- package/dist/dashboard/assets/entry-Bog0p925.js.gz +0 -0
- package/dist/dashboard/assets/index-CZQIrdoX.js.gz +0 -0
- package/dist/dashboard/assets/select-BPpSsBgL.js.gz +0 -0
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Compiles the standalone gateway binary from the AOT build output.
|
|
3
|
+
*
|
|
4
|
+
* Two things here are load-bearing and neither is obvious:
|
|
5
|
+
*
|
|
6
|
+
* 1. **The entry is `dist/main.js`, not `src/main.ts`.** `aot.ts` runs
|
|
7
|
+
* Elysia's AOT plugin, which rewrites TypeBox imports into statically wired
|
|
8
|
+
* mirrors in its output. Bundling the raw source instead discards that work,
|
|
9
|
+
* and Elysia's CommonJS build reaches TypeBox through a lazy
|
|
10
|
+
* `require("typebox/type")` that the bundler cannot follow — the compiled
|
|
11
|
+
* binary then dies at startup with `Cannot find module 'typebox/type'`,
|
|
12
|
+
* because a standalone executable has no node_modules to resolve against.
|
|
13
|
+
*
|
|
14
|
+
* 2. **`NODE_ENV` is set to `production` here, at build time, because Bun bakes
|
|
15
|
+
* it into the binary.** `bun build --compile` replaces `process.env.NODE_ENV`
|
|
16
|
+
* with a literal, so the value present when this runs is the value the
|
|
17
|
+
* binary reports forever after — a runtime `NODE_ENV=production` cannot
|
|
18
|
+
* change it. What depends on it is the logger: the `pino-pretty` transport
|
|
19
|
+
* is attached only in development, and that transport spawns a worker thread
|
|
20
|
+
* loading `real-require`, which a standalone executable does not carry. A
|
|
21
|
+
* binary built without this line therefore bundles a logger that crashes on
|
|
22
|
+
* first use. The migrations folder is not part of this decision —
|
|
23
|
+
* `resolveMigrationsFolder()` resolves `<cwd>/migrations` unconditionally.
|
|
24
|
+
*
|
|
25
|
+
* Usage: `bun run scripts/build/compile.ts [--outfile dist/cartethyia]`
|
|
26
|
+
*/
|
|
27
|
+
const DEFAULT_OUTFILE = "dist/cartethyia";
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* The artifact path `bun build --compile` actually writes for `outfile`.
|
|
31
|
+
*
|
|
32
|
+
* Bun appends `.exe` when it compiles for Windows, so the extensionless
|
|
33
|
+
* `dist/cartethyia` handed to it never exists on that platform. This is the one
|
|
34
|
+
* place that knows where the compiled binary lands; `start-production.ts`
|
|
35
|
+
* resolves its target from here instead of re-deriving the name, because a
|
|
36
|
+
* launcher that probed the requested path reported "run bun run build first"
|
|
37
|
+
* immediately after a successful build.
|
|
38
|
+
*/
|
|
39
|
+
export function compiledBinaryPath(outfile: string = DEFAULT_OUTFILE): string {
|
|
40
|
+
return process.platform === "win32" && !outfile.toLowerCase().endsWith(".exe")
|
|
41
|
+
? `${outfile}.exe`
|
|
42
|
+
: outfile;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Reads `--outfile <path>`, falling back to a positional path or the default. */
|
|
46
|
+
export function resolveOutfile(argv: readonly string[]): string {
|
|
47
|
+
const flag = argv.indexOf("--outfile");
|
|
48
|
+
if (flag !== -1) {
|
|
49
|
+
const value = argv[flag + 1];
|
|
50
|
+
if (value === undefined || value.length === 0) {
|
|
51
|
+
throw new Error("--outfile requires a path");
|
|
52
|
+
}
|
|
53
|
+
return value;
|
|
54
|
+
}
|
|
55
|
+
return argv.find((arg) => arg.length > 0 && !arg.startsWith("-")) ?? DEFAULT_OUTFILE;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export async function buildBinary(
|
|
59
|
+
outfile: string = DEFAULT_OUTFILE,
|
|
60
|
+
buildFn: typeof Bun.build = Bun.build,
|
|
61
|
+
): Promise<void> {
|
|
62
|
+
// Baked into the output; see the note above before changing it. Substituted
|
|
63
|
+
// through `define` rather than by assigning `process.env`, because this module
|
|
64
|
+
// is imported by tests in the same process — mutating the environment would
|
|
65
|
+
// flip `NODE_ENV` for every later suite in that process.
|
|
66
|
+
const result = await buildFn({
|
|
67
|
+
entrypoints: ["dist/main.js"],
|
|
68
|
+
minify: true,
|
|
69
|
+
target: "bun",
|
|
70
|
+
define: { "process.env.NODE_ENV": JSON.stringify("production") },
|
|
71
|
+
compile: {
|
|
72
|
+
outfile,
|
|
73
|
+
},
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
if (result.logs.length > 0) {
|
|
77
|
+
for (const log of result.logs) console.error(`[binary] ${log.message}`);
|
|
78
|
+
throw new Error(`binary build emitted ${result.logs.length} diagnostic(s)`);
|
|
79
|
+
}
|
|
80
|
+
console.log(`[binary] compiled ${compiledBinaryPath(outfile)}`);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
if (import.meta.main) {
|
|
84
|
+
await buildBinary(resolveOutfile(process.argv.slice(2))).catch((error: unknown) => {
|
|
85
|
+
console.error("[binary] build failed:", error);
|
|
86
|
+
process.exit(1);
|
|
87
|
+
});
|
|
88
|
+
}
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
/**
|
|
3
|
+
* restart.ts — rebuild and restart the compiled gateway gracefully.
|
|
4
|
+
*
|
|
5
|
+
* Why this exists: a hard kill (`taskkill /F`, `kill -9`) truncates every
|
|
6
|
+
* in-flight response. On Linux the process handles SIGTERM/SIGINT and drains,
|
|
7
|
+
* but Windows has no deliverable catchable signal — `process.kill(pid,
|
|
8
|
+
* "SIGTERM")` of a console-less Bun process runs no JS handler (measured). So
|
|
9
|
+
* this script stops the running instance through the drain endpoint when
|
|
10
|
+
* `CARTETHYIA_DRAIN_TOKEN` is set (loopback + `x-drain-token`), and only falls
|
|
11
|
+
* back to a hard kill when no token is configured.
|
|
12
|
+
*
|
|
13
|
+
* Env:
|
|
14
|
+
* CARTETHYIA_DRAIN_TOKEN token for POST /admin/drain (required for graceful stop)
|
|
15
|
+
* PORT gateway port (default 12800)
|
|
16
|
+
* DRAIN_WAIT_MS how long to wait for the drain to finish (default 30000)
|
|
17
|
+
*/
|
|
18
|
+
import { compiledBinaryPath } from "./compile";
|
|
19
|
+
|
|
20
|
+
function intEnv(name: string, fallback: number): number {
|
|
21
|
+
const raw = process.env[name];
|
|
22
|
+
if (raw === undefined || raw.trim() === "") return fallback;
|
|
23
|
+
const value = Number(raw);
|
|
24
|
+
return Number.isFinite(value) && value > 0 ? Math.trunc(value) : fallback;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const PORT = intEnv("PORT", 12800);
|
|
28
|
+
const DRAIN_WAIT_MS = intEnv("DRAIN_WAIT_MS", 30_000);
|
|
29
|
+
|
|
30
|
+
/** PIDs currently listening on `port` (best-effort, platform-specific). */
|
|
31
|
+
async function listeningPids(port: number): Promise<number[]> {
|
|
32
|
+
try {
|
|
33
|
+
if (process.platform === "win32") {
|
|
34
|
+
const proc = Bun.spawnSync(["netstat", "-ano", "-p", "TCP"]);
|
|
35
|
+
const out = new TextDecoder().decode(proc.stdout);
|
|
36
|
+
const pids = new Set<number>();
|
|
37
|
+
for (const line of out.split(/\r?\n/)) {
|
|
38
|
+
if (!line.includes(`:${port} `) || !line.includes("LISTENING")) continue;
|
|
39
|
+
const pid = Number(line.trim().split(/\s+/).pop());
|
|
40
|
+
if (Number.isInteger(pid) && pid > 0) pids.add(pid);
|
|
41
|
+
}
|
|
42
|
+
return [...pids];
|
|
43
|
+
}
|
|
44
|
+
const proc = Bun.spawnSync(["lsof", "-ti", `tcp:${port}`, "-sTCP:LISTEN"]);
|
|
45
|
+
return new TextDecoder()
|
|
46
|
+
.decode(proc.stdout)
|
|
47
|
+
.split(/\s+/)
|
|
48
|
+
.map(Number)
|
|
49
|
+
.filter((n) => Number.isInteger(n) && n > 0);
|
|
50
|
+
} catch {
|
|
51
|
+
return [];
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
async function waitForPortFree(port: number, timeoutMs: number): Promise<boolean> {
|
|
56
|
+
const deadline = Date.now() + timeoutMs;
|
|
57
|
+
while (Date.now() < deadline) {
|
|
58
|
+
if ((await listeningPids(port)).length === 0) return true;
|
|
59
|
+
await new Promise((r) => setTimeout(r, 250));
|
|
60
|
+
}
|
|
61
|
+
return (await listeningPids(port)).length === 0;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
async function stopGracefully(): Promise<boolean> {
|
|
65
|
+
const token = process.env.CARTETHYIA_DRAIN_TOKEN?.trim();
|
|
66
|
+
if (token === undefined || token.length === 0) {
|
|
67
|
+
console.warn("[restart] CARTETHYIA_DRAIN_TOKEN unset — cannot drain gracefully");
|
|
68
|
+
return false;
|
|
69
|
+
}
|
|
70
|
+
try {
|
|
71
|
+
const response = await fetch(`http://127.0.0.1:${PORT}/admin/drain`, {
|
|
72
|
+
method: "POST",
|
|
73
|
+
headers: { "x-drain-token": token },
|
|
74
|
+
});
|
|
75
|
+
if (!response.ok) {
|
|
76
|
+
console.warn(`[restart] drain request returned HTTP ${response.status}`);
|
|
77
|
+
return false;
|
|
78
|
+
}
|
|
79
|
+
console.log("[restart] drain acknowledged; waiting for the process to exit");
|
|
80
|
+
return await waitForPortFree(PORT, DRAIN_WAIT_MS);
|
|
81
|
+
} catch (error) {
|
|
82
|
+
console.warn(`[restart] drain request failed: ${(error as Error).message}`);
|
|
83
|
+
return false;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
async function hardKill(): Promise<void> {
|
|
88
|
+
const pids = await listeningPids(PORT);
|
|
89
|
+
if (pids.length === 0) return;
|
|
90
|
+
console.warn(`[restart] force-killing pid(s) ${pids.join(", ")} on :${PORT}`);
|
|
91
|
+
for (const pid of pids) {
|
|
92
|
+
try {
|
|
93
|
+
process.kill(pid, "SIGKILL");
|
|
94
|
+
} catch {
|
|
95
|
+
// Already gone.
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
await waitForPortFree(PORT, 5_000);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
async function main(): Promise<number> {
|
|
102
|
+
const graceful = await stopGracefully();
|
|
103
|
+
if (!graceful) await hardKill();
|
|
104
|
+
|
|
105
|
+
console.log("[restart] building");
|
|
106
|
+
const build = Bun.spawnSync(["bun", "run", "build"], { stdout: "inherit", stderr: "inherit" });
|
|
107
|
+
if (build.exitCode !== 0) {
|
|
108
|
+
console.error("[restart] build failed");
|
|
109
|
+
return build.exitCode ?? 1;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const binary = compiledBinaryPath();
|
|
113
|
+
console.log(`[restart] starting ${binary}`);
|
|
114
|
+
const child = Bun.spawn([binary], { stdin: "ignore", stdout: "inherit", stderr: "inherit" });
|
|
115
|
+
return await child.exited;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
if (import.meta.main) {
|
|
119
|
+
process.exit(await main());
|
|
120
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/** Runs the compiled binary from the repository root. */
|
|
2
|
+
import { compiledBinaryPath } from "./compile";
|
|
3
|
+
|
|
4
|
+
/** Runs the compiled binary with inherited stdio; resolves to its exit code. */
|
|
5
|
+
export async function startBinary(): Promise<number> {
|
|
6
|
+
const binary = compiledBinaryPath();
|
|
7
|
+
if (!(await Bun.file(binary).exists())) {
|
|
8
|
+
throw new Error(`${binary} is missing; run bun run build first`);
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
process.env.NODE_ENV ??= "production";
|
|
12
|
+
process.env.CARTETHYIA_SERVER_MAX_BODY_BYTES ??= String(8 * 1024 * 1024 * 1024);
|
|
13
|
+
|
|
14
|
+
const child = Bun.spawn([binary], {
|
|
15
|
+
env: process.env,
|
|
16
|
+
stdin: "inherit",
|
|
17
|
+
stdout: "inherit",
|
|
18
|
+
stderr: "inherit",
|
|
19
|
+
});
|
|
20
|
+
return await child.exited;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
if (import.meta.main) {
|
|
24
|
+
process.exit(await startBinary());
|
|
25
|
+
}
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
import { access } from "node:fs/promises";
|
|
2
|
+
import { constants } from "node:fs";
|
|
3
|
+
import { join, resolve } from "node:path";
|
|
4
|
+
import { Client } from "pg";
|
|
5
|
+
import Redis from "ioredis";
|
|
6
|
+
import { hasPlaceholderSecrets, readEnvFile } from "./cli/env";
|
|
7
|
+
import { parseServiceUrl, probeTcpService } from "./cli/services";
|
|
8
|
+
import { resolveDataDir, resolveDbMode } from "../src/persistence/db-mode";
|
|
9
|
+
import { bunVersionAtLeast, readRequiredBunVersion } from "./cli/version";
|
|
10
|
+
|
|
11
|
+
export type CheckPhase = "preflight" | "configuration" | "runtime";
|
|
12
|
+
export type CheckStatus = "pass" | "warn" | "fail" | "skip";
|
|
13
|
+
export interface CheckResult {
|
|
14
|
+
id: string;
|
|
15
|
+
phase: CheckPhase;
|
|
16
|
+
status: CheckStatus;
|
|
17
|
+
required: boolean;
|
|
18
|
+
summary: string;
|
|
19
|
+
remediation?: string;
|
|
20
|
+
}
|
|
21
|
+
export interface HealthContext {
|
|
22
|
+
target: "local" | "vps" | "docker";
|
|
23
|
+
env: Record<string, string>;
|
|
24
|
+
envPath: string;
|
|
25
|
+
port: number;
|
|
26
|
+
phases: readonly CheckPhase[];
|
|
27
|
+
}
|
|
28
|
+
export interface HealthDependencies {
|
|
29
|
+
dockerCompose: () => Promise<boolean>;
|
|
30
|
+
postgres: (url: string) => Promise<void>;
|
|
31
|
+
redis: (url: string) => Promise<void>;
|
|
32
|
+
tcp: typeof probeTcpService;
|
|
33
|
+
ready: (port: number) => Promise<string | null>;
|
|
34
|
+
writable: (path: string) => Promise<boolean>;
|
|
35
|
+
exists: (path: string) => Promise<boolean>;
|
|
36
|
+
}
|
|
37
|
+
const defaultDependencies: HealthDependencies = {
|
|
38
|
+
async dockerCompose() {
|
|
39
|
+
try {
|
|
40
|
+
return Bun.spawnSync(["docker", "compose", "version"], { stdout: "ignore", stderr: "ignore" }).exitCode === 0;
|
|
41
|
+
} catch {
|
|
42
|
+
return false;
|
|
43
|
+
}
|
|
44
|
+
},
|
|
45
|
+
async postgres(url) {
|
|
46
|
+
const client = new Client({ connectionString: url, connectionTimeoutMillis: 3_000 });
|
|
47
|
+
try {
|
|
48
|
+
await client.connect();
|
|
49
|
+
await client.query("SELECT 1");
|
|
50
|
+
} finally {
|
|
51
|
+
await client.end().catch(() => undefined);
|
|
52
|
+
}
|
|
53
|
+
},
|
|
54
|
+
async redis(url) {
|
|
55
|
+
const client = new Redis(url, { lazyConnect: true, connectTimeout: 3_000, maxRetriesPerRequest: 0 });
|
|
56
|
+
try {
|
|
57
|
+
await client.connect();
|
|
58
|
+
const result = await client.ping();
|
|
59
|
+
if (result !== "PONG") throw new Error(`Unexpected Redis response: ${result}`);
|
|
60
|
+
} finally {
|
|
61
|
+
client.disconnect();
|
|
62
|
+
}
|
|
63
|
+
},
|
|
64
|
+
tcp: probeTcpService,
|
|
65
|
+
async ready(port) {
|
|
66
|
+
try {
|
|
67
|
+
const response = await fetch(`http://127.0.0.1:${port}/health/ready`, { signal: AbortSignal.timeout(3_000) });
|
|
68
|
+
return response.ok ? null : `HTTP ${response.status}`;
|
|
69
|
+
} catch (error) {
|
|
70
|
+
return error instanceof Error ? error.message : "connection failed";
|
|
71
|
+
}
|
|
72
|
+
},
|
|
73
|
+
async writable(path) {
|
|
74
|
+
try {
|
|
75
|
+
await access(path, constants.W_OK);
|
|
76
|
+
return true;
|
|
77
|
+
} catch {
|
|
78
|
+
return false;
|
|
79
|
+
}
|
|
80
|
+
},
|
|
81
|
+
async exists(path) {
|
|
82
|
+
try {
|
|
83
|
+
await access(path, constants.F_OK);
|
|
84
|
+
return true;
|
|
85
|
+
} catch {
|
|
86
|
+
return false;
|
|
87
|
+
}
|
|
88
|
+
},
|
|
89
|
+
};
|
|
90
|
+
function result(id: string, phase: CheckPhase, status: CheckStatus, summary: string, required = true, remediation?: string): CheckResult {
|
|
91
|
+
return { id, phase, status, required, summary, ...(remediation ? { remediation } : {}) };
|
|
92
|
+
}
|
|
93
|
+
function errorMessage(error: unknown): string {
|
|
94
|
+
return error instanceof Error ? error.message : String(error);
|
|
95
|
+
}
|
|
96
|
+
export async function runHealthChecks(
|
|
97
|
+
context: HealthContext,
|
|
98
|
+
dependencies: HealthDependencies = defaultDependencies,
|
|
99
|
+
): Promise<CheckResult[]> {
|
|
100
|
+
const results: CheckResult[] = [];
|
|
101
|
+
const phases = new Set(context.phases);
|
|
102
|
+
const env = { ...(await readEnvFile(context.envPath)), ...context.env };
|
|
103
|
+
if (phases.has("preflight")) {
|
|
104
|
+
const minimum = await readRequiredBunVersion();
|
|
105
|
+
const bunOkay = bunVersionAtLeast(Bun.version, minimum);
|
|
106
|
+
results.push(result("runtime-bun", "preflight", bunOkay ? "pass" : "fail", bunOkay ? `Bun ${Bun.version} satisfies ${minimum}+` : `Bun ${Bun.version} is older than required ${minimum}`, true, bunOkay ? undefined : "Install the required Bun version"));
|
|
107
|
+
if (context.target === "docker") {
|
|
108
|
+
const available = await dependencies.dockerCompose();
|
|
109
|
+
results.push(result("docker-compose", "preflight", available ? "pass" : "fail", available ? "Docker Compose is available" : "Docker Compose is unavailable", true, "Install Docker Engine with the Compose plugin"));
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
if (phases.has("configuration")) {
|
|
113
|
+
if (!(await dependencies.exists(context.envPath))) {
|
|
114
|
+
results.push(result("env-file", "configuration", "fail", `.env not found at ${context.envPath}`, true, "Run setup or the installer"));
|
|
115
|
+
} else {
|
|
116
|
+
results.push(result("env-file", "configuration", "pass", `.env exists at ${context.envPath}`));
|
|
117
|
+
}
|
|
118
|
+
let mode: "lite" | "full" | null = null;
|
|
119
|
+
try {
|
|
120
|
+
mode = resolveDbMode(env);
|
|
121
|
+
results.push(result("db-mode", "configuration", "pass", `Database mode: ${mode}`));
|
|
122
|
+
} catch (error) {
|
|
123
|
+
results.push(result("db-mode", "configuration", "fail", errorMessage(error), true, "Set CARTETHYIA_DB_MODE=lite or full"));
|
|
124
|
+
}
|
|
125
|
+
const key = env.CARTETHYIA_ENCRYPTION_KEY?.trim() ?? "";
|
|
126
|
+
results.push(!key
|
|
127
|
+
? result("encryption-key", "configuration", "fail", "CARTETHYIA_ENCRYPTION_KEY is missing")
|
|
128
|
+
: hasPlaceholderSecrets(key)
|
|
129
|
+
? result("encryption-key", "configuration", "fail", "CARTETHYIA_ENCRYPTION_KEY is still a placeholder")
|
|
130
|
+
: result("encryption-key", "configuration", "pass", "Encryption key is configured"));
|
|
131
|
+
if (mode === "lite") {
|
|
132
|
+
const data = resolveDataDir(env);
|
|
133
|
+
const paths = [data, env.CARTETHYIA_TELEMETRY_PAYLOAD_DIR?.trim() || join(data, "telemetry-payloads"), env.CARTETHYIA_INSTALL_ID_DIR?.trim() || join(data, ".cartethyia")];
|
|
134
|
+
for (const path of paths) {
|
|
135
|
+
if (context.target === "docker" && path.startsWith("/app/")) {
|
|
136
|
+
results.push(result(`container-path-${path}`, "configuration", "pass", `Container state path is declared: ${path}; runtime checks verify its writability`));
|
|
137
|
+
continue;
|
|
138
|
+
}
|
|
139
|
+
const writable = await dependencies.writable(resolve(path));
|
|
140
|
+
results.push(result(`writable-${path}`, "configuration", writable ? "pass" : "fail", `${writable ? "Writable" : "Not writable"}: ${path}`));
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
if (mode === "full") {
|
|
144
|
+
const url = env.DATABASE_URL?.trim() ?? "";
|
|
145
|
+
if (!url) results.push(result("postgres", "configuration", "fail", "DATABASE_URL is required in Full mode"));
|
|
146
|
+
else if (!parseServiceUrl(url)) results.push(result("postgres", "configuration", "fail", "DATABASE_URL must include a host and port"));
|
|
147
|
+
else if (context.target === "docker") results.push(result("postgres", "configuration", "pass", "External DATABASE_URL is configured; container readiness verifies connectivity"));
|
|
148
|
+
else {
|
|
149
|
+
try {
|
|
150
|
+
await dependencies.postgres(url);
|
|
151
|
+
results.push(result("postgres", "configuration", "pass", "PostgreSQL authenticated SELECT 1 succeeded"));
|
|
152
|
+
} catch (error) {
|
|
153
|
+
const parsed = parseServiceUrl(url)!;
|
|
154
|
+
const tcp = await dependencies.tcp(parsed.host, parsed.port, 3_000);
|
|
155
|
+
results.push(result("postgres", "configuration", tcp.success ? "warn" : "fail", tcp.success ? "PostgreSQL port is reachable, but authenticated query failed" : "PostgreSQL is unreachable or authentication failed", true, "Verify DATABASE_URL credentials and database availability"));
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
const redis = env.REDIS_URL?.trim() ?? "";
|
|
160
|
+
if (!redis) results.push(result("redis", "configuration", "pass", "Redis is not configured; in-memory coordination applies"));
|
|
161
|
+
else if (context.target === "docker") results.push(result("redis", "configuration", "pass", "Container Redis coordination is configured; container readiness verifies connectivity"));
|
|
162
|
+
else {
|
|
163
|
+
try {
|
|
164
|
+
await dependencies.redis(redis);
|
|
165
|
+
results.push(result("redis", "configuration", "pass", "Redis PING succeeded"));
|
|
166
|
+
} catch (error) {
|
|
167
|
+
results.push(result("redis", "configuration", "fail", "Configured Redis is unavailable or authentication failed", true, "Verify REDIS_URL and Redis availability"));
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
if (phases.has("runtime")) {
|
|
172
|
+
if (!Number.isInteger(context.port) || context.port < 1 || context.port > 65535) {
|
|
173
|
+
results.push(result("app-ready", "runtime", "fail", `Invalid application port: ${context.port}`));
|
|
174
|
+
} else {
|
|
175
|
+
const failure = await dependencies.ready(context.port);
|
|
176
|
+
results.push(result("app-ready", "runtime", failure ? "fail" : "pass", failure ? `Application is not ready: ${failure}` : `Application is ready on port ${context.port}`));
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
return results;
|
|
180
|
+
}
|
|
181
|
+
export function hasRequiredFailure(results: readonly CheckResult[]): boolean {
|
|
182
|
+
return results.some((item) => item.required && item.status === "fail");
|
|
183
|
+
}
|
|
184
|
+
export function skippedRuntimeCheck(): CheckResult {
|
|
185
|
+
return result("app-ready", "runtime", "skip", "Runtime readiness was not requested", false);
|
|
186
|
+
}
|
|
@@ -77,12 +77,47 @@ function isoDate(value: unknown, fallback: string): string {
|
|
|
77
77
|
}
|
|
78
78
|
|
|
79
79
|
/**
|
|
80
|
-
* The credential
|
|
81
|
-
*
|
|
82
|
-
*
|
|
80
|
+
* The credential and the kind to store it under.
|
|
81
|
+
*
|
|
82
|
+
* A router connection often carries both an `apiKey` and an OAuth access
|
|
83
|
+
* token for the same account. The two are not interchangeable: the api key
|
|
84
|
+
* is a bare bearer, while the access token has to be sent through the
|
|
85
|
+
* provider's OAuth-shaped envelope — Cline's chat path, for example,
|
|
86
|
+
* prepends `workos:` to anything it treats as OAuth. If we picked `apiKey`
|
|
87
|
+
* (the natural first choice because it is the longer-lived, bearer-ready
|
|
88
|
+
* secret) while persisting the kind as `oauth`, dispatch would wrap it as
|
|
89
|
+
* `Bearer workos:sk_...` and the upstream would answer "Unauthorized,
|
|
90
|
+
* re-authenticate".
|
|
91
|
+
*
|
|
92
|
+
* Resolution: when a connection carries an api key AND nothing explicitly
|
|
93
|
+
* says "treat this as OAuth", the api key is the credential and the kind
|
|
94
|
+
* is `api_key`. The caller still writes any refresh/client secret into
|
|
95
|
+
* `oauth_states` — the account can be rotated to OAuth later without
|
|
96
|
+
* losing it — but today's dispatch sends the key as a bare bearer and
|
|
97
|
+
* works. Only when `authType === "oauth"` is explicit, or when there is
|
|
98
|
+
* no api key at all, does the credential come from the OAuth bundle.
|
|
83
99
|
*/
|
|
84
|
-
|
|
85
|
-
|
|
100
|
+
interface ResolvedConnectionCredential {
|
|
101
|
+
readonly value: string;
|
|
102
|
+
readonly kind: "api_key" | "oauth";
|
|
103
|
+
}
|
|
104
|
+
function credentialOf(entry: Row, provider: string): ResolvedConnectionCredential | null {
|
|
105
|
+
const apiKey = text(entry.apiKey);
|
|
106
|
+
const accessToken = text(entry.accessToken);
|
|
107
|
+
const fallback = text(entry.credential) ?? text(entry.token);
|
|
108
|
+
const authType = text(entry.authType);
|
|
109
|
+
const oauthExplicit = authType === "oauth";
|
|
110
|
+
const apiKeyExplicit = authType === "apikey" || authType === "api_key";
|
|
111
|
+
if (apiKey !== null && !oauthExplicit) {
|
|
112
|
+
return { value: apiKey, kind: "api_key" };
|
|
113
|
+
}
|
|
114
|
+
if (accessToken !== null) {
|
|
115
|
+
return { value: accessToken, kind: apiKeyExplicit ? "api_key" : OAUTH_PROVIDERS.has(provider) ? "oauth" : "api_key" };
|
|
116
|
+
}
|
|
117
|
+
if (fallback !== null) {
|
|
118
|
+
return { value: fallback, kind: oauthExplicit ? "oauth" : apiKeyExplicit ? "api_key" : OAUTH_PROVIDERS.has(provider) ? "oauth" : "api_key" };
|
|
119
|
+
}
|
|
120
|
+
return null;
|
|
86
121
|
}
|
|
87
122
|
|
|
88
123
|
/** OAuth-backed providers store a refresh token rather than a static key. */
|
|
@@ -232,14 +267,13 @@ export function convert9RouterBackup(input: unknown, tenantId: string): Conversi
|
|
|
232
267
|
);
|
|
233
268
|
return;
|
|
234
269
|
}
|
|
235
|
-
const credential = credentialOf(entry);
|
|
270
|
+
const credential = credentialOf(entry, provider);
|
|
236
271
|
if (credential === null) {
|
|
237
272
|
skipped.push(`${label}: no credential on the row`);
|
|
238
273
|
return;
|
|
239
274
|
}
|
|
240
275
|
const accountId = crypto.randomUUID();
|
|
241
|
-
const
|
|
242
|
-
const kind = authType === "oauth" || OAUTH_PROVIDERS.has(provider) ? "oauth" : "api_key";
|
|
276
|
+
const kind = credential.kind;
|
|
243
277
|
accounts.push({
|
|
244
278
|
id: accountId,
|
|
245
279
|
provider_id: provider,
|
|
@@ -249,7 +283,7 @@ export function convert9RouterBackup(input: unknown, tenantId: string): Conversi
|
|
|
249
283
|
// `SEALED_COLUMNS`. The store re-seals it under this instance's key on the
|
|
250
284
|
// way in; sealing it here would put ciphertext in a payload whose only
|
|
251
285
|
// portable form is the plaintext, and the restore would drop it.
|
|
252
|
-
access_token: credential,
|
|
286
|
+
access_token: credential.value,
|
|
253
287
|
credential_kind: kind,
|
|
254
288
|
status: bool(entry.isActive, true) ? "active" : "disabled",
|
|
255
289
|
consecutive_failures: 0,
|
|
@@ -54,7 +54,6 @@ interface FreeRow {
|
|
|
54
54
|
const FREE_ROWS: readonly FreeRow[] = [
|
|
55
55
|
{ id: "cohere/north-mini-code:free", ctx: 256_000, out: 64_000 },
|
|
56
56
|
{ id: "dots-studio/dots-3-note-preview:free", ctx: 512_000, out: 460_800, vision: true },
|
|
57
|
-
{ id: "minimax/minimax-m3.1-flash:free", ctx: 1_000_000, out: 524_288, vision: true },
|
|
58
57
|
{
|
|
59
58
|
id: "nvidia/nemotron-3-nano-omni-30b-a3b-reasoning:free",
|
|
60
59
|
ctx: 256_000,
|
|
@@ -123,6 +123,7 @@ export const BUDDY_PRO_ONLY_MODEL_IDS: ReadonlySet<string> = new Set([
|
|
|
123
123
|
"gemini-3.8-flash",
|
|
124
124
|
"grok-4.6",
|
|
125
125
|
"grok-4.7",
|
|
126
|
+
"kimi-k3.1-pro-highspeed",
|
|
126
127
|
]);
|
|
127
128
|
|
|
128
129
|
/**
|
|
@@ -259,7 +260,6 @@ export const BUDDY_SHARED_RAW: readonly BuddyRawEntry[] = [
|
|
|
259
260
|
["claude-sonnet-4.6", "Claude Sonnet 4.6", true, true, 200_000, 32_000],
|
|
260
261
|
["deepseek-v4.1-flash", "DeepSeek V4.1 Flash", true, true, 1_000_000, 384_000],
|
|
261
262
|
["deepseek-v4.1-flash-sg", "DeepSeek V4.1 Flash SG", true, true, 1_000_000, 128_000],
|
|
262
|
-
["deepseek-v4.1-pro", "DeepSeek V4.1 Pro", true, false, 1_000_000, 384_000],
|
|
263
263
|
["gemini-2.5-flash-image", "Gemini 2.5 Flash Image", true, true, 1_000_000, 64_000],
|
|
264
264
|
["gemini-3.0-pro-image", "Gemini 3.0 Pro Image", true, true, 1_000_000, 64_000],
|
|
265
265
|
["gemini-3.1-flash-image", "Gemini 3.1 Flash Image", true, true, 1_000_000, 64_000],
|
|
@@ -278,7 +278,6 @@ export const BUDDY_SHARED_RAW: readonly BuddyRawEntry[] = [
|
|
|
278
278
|
["gpt-5.6-sol", "GPT-5.6 Sol", true, true, 1_050_000, 128_000],
|
|
279
279
|
["gpt-5.6-terra", "GPT-5.6 Terra", true, true, 1_050_000, 128_000],
|
|
280
280
|
["gpt-6-luna", "GPT-6 Luna", true, true, 1_050_000, 128_000],
|
|
281
|
-
["deep-model", "Deep Model", true, true, null, null],
|
|
282
281
|
["gpt-6-sol", "GPT-6 Sol", true, true, 1_000_000, 128_000, true],
|
|
283
282
|
["gpt-6.1-sol", "GPT-6.1 Sol", true, true, 1_050_000, 128_000],
|
|
284
283
|
["gpt-image-2", "GPT-Image-2", true, true, 400_000, 128_000],
|
|
@@ -293,6 +292,8 @@ export const BUDDY_SHARED_RAW: readonly BuddyRawEntry[] = [
|
|
|
293
292
|
["kimi-k2.7", "Kimi K2.7", true, false, 256_000, 65_536],
|
|
294
293
|
["kimi-k2.8-preview", "Kimi K2.8 Preview", true, false, 1_048_576, 65_536],
|
|
295
294
|
["kimi-k3", "Kimi K3", true, false, 1_048_576, 131_072],
|
|
295
|
+
["kimi-k3.1-pro", "Kimi K3.1 Pro", true, true, 1_000_000, 64_000],
|
|
296
|
+
["kimi-k3.1-pro-highspeed", "Kimi K3.1 Pro Highspeed", true, true, 1_000_000, 64_000],
|
|
296
297
|
["minimax-m3", "MiniMax-M3", true, false, 512_000, 512_000],
|
|
297
298
|
["step-5-preview", "Step-5-Preview", true, true, 1_000_000, 64_000, true],
|
|
298
299
|
];
|
|
@@ -114,7 +114,8 @@ export const CODEBUDDY_CN_RAW: readonly BuddyRawEntry[] = [
|
|
|
114
114
|
["kimi-k2.7", "Kimi K2.7", true, true, 256_000, 65_536],
|
|
115
115
|
["kimi-k2.6", "Kimi K2.6", true, true, 256_000, 262_144],
|
|
116
116
|
["kimi-k2.5", "Kimi K2.5", true, true, 164_000, 262_144],
|
|
117
|
-
["kimi-k3-
|
|
117
|
+
["kimi-k3.1-pro", "Kimi K3.1 Pro", true, true, 1_000_000, 64_000],
|
|
118
|
+
["kimi-k3.1-pro-highspeed", "Kimi K3.1 Pro Highspeed", true, true, 1_000_000, 64_000],
|
|
118
119
|
["hy3", "Hy3", true, true, 192_000, 64_000],
|
|
119
120
|
["hy4-preview", "Hy4 Preview", true, true, 1_000_000, 64_000],
|
|
120
121
|
["uto", "UTO", true, true, 256_000, 32_000],
|
|
@@ -230,7 +230,6 @@ const FREE_AGENT_TOOL_SCHEMA = {
|
|
|
230
230
|
required: [] as string[],
|
|
231
231
|
} as const;
|
|
232
232
|
|
|
233
|
-
|
|
234
233
|
function ensureFreeAgentRequest(request: CanonicalRequest): CanonicalRequest {
|
|
235
234
|
const present: Record<string, true> = {};
|
|
236
235
|
for (const tool of request.tools ?? []) present[tool.name] = true;
|
|
@@ -251,8 +250,6 @@ function ensureFreeAgentRequest(request: CanonicalRequest): CanonicalRequest {
|
|
|
251
250
|
};
|
|
252
251
|
}
|
|
253
252
|
|
|
254
|
-
|
|
255
|
-
|
|
256
253
|
function completeOpenCodeToolSchemas(payload: Record<string, unknown>): void {
|
|
257
254
|
if (!Array.isArray(payload.tools)) return;
|
|
258
255
|
payload.tools = payload.tools.map((tool) => {
|
|
@@ -152,14 +152,33 @@ export async function loadAccountWithFreshness(
|
|
|
152
152
|
const row = rows[0];
|
|
153
153
|
if (!row) return undefined;
|
|
154
154
|
const lead = skewMs ?? refreshLeadMs(row.providerId);
|
|
155
|
+
// A refresh-capable OAuth account with no stored expiry is unknown-liveness:
|
|
156
|
+
// we have no evidence the access token still works. The old rule left
|
|
157
|
+
// `dueAt` undefined for these, so `resolveCredentialForAccount` skipped the
|
|
158
|
+
// refresh branch and dispatch sent the pasted token as-is. For a token that
|
|
159
|
+
// had already expired on another installation (a 9router/Omniroute import
|
|
160
|
+
// carrying `accessToken` + `refreshToken` + a past `expiresAt` that was
|
|
161
|
+
// dropped by validation, a hand-pasted OAuth bundle with no expiry field,
|
|
162
|
+
// or an upstream that stopped minting expiries) that produced the exact
|
|
163
|
+
// "re-authenticate" refusal the refresh grant exists to prevent. Marking the
|
|
164
|
+
// token due immediately refreshes it on first use; the sweep handles the
|
|
165
|
+
// normal case where `expires_at` is populated.
|
|
166
|
+
const hasRefreshGrant = row.refreshCiphertext != null;
|
|
167
|
+
const kindOauth = row.credentialKind === "oauth";
|
|
168
|
+
const unknownExpiryButRefreshable =
|
|
169
|
+
kindOauth && !row.staticToken && hasRefreshGrant && row.expiresAt == null;
|
|
155
170
|
return {
|
|
156
171
|
row,
|
|
157
172
|
// A static token is never "due": it is used as issued and there is no
|
|
158
173
|
// refresh to run, so the dispatch path must not treat it as stale.
|
|
159
174
|
dueAt:
|
|
160
|
-
row.staticToken
|
|
175
|
+
row.staticToken
|
|
161
176
|
? undefined
|
|
162
|
-
: row.expiresAt
|
|
177
|
+
: row.expiresAt != null
|
|
178
|
+
? row.expiresAt.getTime() - lead
|
|
179
|
+
: unknownExpiryButRefreshable
|
|
180
|
+
? 0
|
|
181
|
+
: undefined,
|
|
163
182
|
};
|
|
164
183
|
}
|
|
165
184
|
|
|
@@ -223,7 +242,14 @@ export async function resolveCredentialForAccount(
|
|
|
223
242
|
if (row.credentialKind === "oauth" && oauth && dueAt !== undefined && dueAt <= Date.now()) {
|
|
224
243
|
const refresher = await oauth.resolveRefresher(row.providerId);
|
|
225
244
|
if (refresher) {
|
|
226
|
-
|
|
245
|
+
// Try to refresh. If it fails (refresh grant dead, upstream unreachable,
|
|
246
|
+
// etc.) keep the already-decrypted stale secret rather than wiping it:
|
|
247
|
+
// dispatch then sends the token we have, and the upstream's own 401 is
|
|
248
|
+
// the authoritative verdict. Wiping it here turned "token expired" into
|
|
249
|
+
// a credential-less GatewayError before the request ever left, which is
|
|
250
|
+
// worse than letting the server say so.
|
|
251
|
+
const refreshed = await oauth.refreshService.ensureFreshAccessToken(accountId, refresher);
|
|
252
|
+
if (refreshed) secret = refreshed;
|
|
227
253
|
}
|
|
228
254
|
}
|
|
229
255
|
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|