Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -55,3 +55,13 @@ AGENT_API_KEY=your-api-key
# Limits parallel queries to prevent resource exhaustion.
# Additional requests wait up to QUERY_TIMEOUT_MS for a slot.
# MAX_CONCURRENT_QUERIES=10

# Enable the custom (unsigned) Firebird connection type (default: disabled).
# When set to true, the `firebird` connection type becomes available and every
# DuckDB instance starts with allow_unsigned_extensions so the custom Firebird
# extension can load. This is the only switch that turns on unsigned-extension
# support; the federation console never installs unsigned extensions from
# arbitrary custom sources.
# SECURITY: the Firebird extension is unsigned and runs arbitrary native code.
# Only enable it when the Firebird extension source is trusted.
# DUCKDB_ENABLE_CUSTOM_FIREBIRD=false
3 changes: 2 additions & 1 deletion apps/api/src/app.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { Hono } from "hono";
import type { ContentfulStatusCode } from "hono/utils/http-status";
import { logger } from "hono/logger";
import { getEnv } from "@archmax/core/config/env";
import { customFirebirdEnabled, getEnv } from "@archmax/core/config/env";
import { runHealthChecks } from "@archmax/core/infra/health";
import { corsMiddleware } from "./middleware/cors";
import { csrfMiddleware } from "./middleware/csrf";
Expand Down Expand Up @@ -62,6 +62,7 @@ const app = new Hono()
const env = getEnv();
return c.json({
agentConfigured: !!env.AGENT_API_KEY,
firebirdEnabled: customFirebirdEnabled(),
});
})
.on(["POST", "GET"], "/api/auth/*", (c) => auth.handler(c.req.raw))
Expand Down
131 changes: 131 additions & 0 deletions apps/api/src/routes/connections-firebird.integration.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
import { describe, it, expect, vi, beforeEach } from "vitest";

const mocks = vi.hoisted(() => ({
connectDB: vi.fn(),
customFirebirdEnabled: vi.fn(() => false),
projectFindById: vi.fn(),
connectionFindOne: vi.fn(),
connectionCreate: vi.fn(),
connectionFindOneAndUpdate: vi.fn(),
testSingleConnection: vi.fn(),
}));

vi.mock("@archmax/core/infra/db", () => ({ connectDB: mocks.connectDB }));
vi.mock("@archmax/core/config/env", () => ({
getEnv: vi.fn(() => ({ ENCRYPTION_KEY: "" })),
customFirebirdEnabled: mocks.customFirebirdEnabled,
}));
vi.mock("@archmax/core/models/index", () => ({
Connection: {
findOne: mocks.connectionFindOne,
create: mocks.connectionCreate,
findOneAndUpdate: mocks.connectionFindOneAndUpdate,
},
Project: { findById: mocks.projectFindById },
CONNECTION_TYPES: ["postgres", "mysql", "mssql", "sqlite", "duckdb", "iceberg", "firebird"],
SLUG_PATTERN: /^[a-zA-Z_][a-zA-Z0-9_]*$/,
slugifyConnectionName: (s: string) => s.toLowerCase(),
}));
vi.mock("@archmax/core/services/duckdb", () => ({
deleteProjectDuckdbFile: vi.fn(),
disposeProjectInstance: vi.fn(),
getProjectInstance: vi.fn(),
testSingleConnection: mocks.testSingleConnection,
withQueryTimeout: vi.fn(async (_db: unknown, op: () => Promise<unknown>) => op()),
}));

import { createTestApp, jsonBody } from "../test-utils/api-client";
import connectionsRoute from "./connections";

const app = createTestApp("/api/projects/:projectId/connections", connectionsRoute);
const BASE = "/api/projects/proj1/connections";

function postFirebird(charset?: string) {
return app.request(BASE, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
name: "fb",
type: "firebird",
connectionConfig: { host: "h", database: "d", user: "u", password: "p", ...(charset ? { charset } : {}) },
}),
});
}

beforeEach(() => {
vi.clearAllMocks();
mocks.customFirebirdEnabled.mockReturnValue(false);
mocks.projectFindById.mockReturnValue({ lean: vi.fn().mockResolvedValue({ _id: "proj1" }) });
});

describe("connections route — firebird gate", () => {
it("rejects creating a firebird connection with 400 when disabled", async () => {
const res = await postFirebird();
expect(res.status).toBe(400);
const body = await jsonBody<{ error: string }>(res);
expect(body.error).toMatch(/not enabled/i);
expect(mocks.connectionCreate).not.toHaveBeenCalled();
});

it("accepts creating a firebird connection (incl. charset) when enabled", async () => {
mocks.customFirebirdEnabled.mockReturnValue(true);
mocks.connectionCreate.mockResolvedValue({
toObject: () => ({ _id: "c1", name: "fb", type: "firebird", connectionConfig: { charset: "WIN1252" } }),
});
const res = await postFirebird("WIN1252");
expect(res.status).toBe(201);
expect(mocks.connectionCreate).toHaveBeenCalledTimes(1);
const created = mocks.connectionCreate.mock.calls[0][0] as { connectionConfig: { charset?: string } };
expect(created.connectionConfig.charset).toBe("WIN1252");
});

it("rejects updating a connection to firebird with 400 when disabled", async () => {
mocks.connectionFindOne.mockReturnValue({
lean: vi.fn().mockResolvedValue({ _id: "c1", project: "proj1", connectionConfig: {} }),
});
const res = await app.request(`${BASE}/c1`, {
method: "PUT",
headers: { "content-type": "application/json" },
body: JSON.stringify({ type: "firebird" }),
});
expect(res.status).toBe(400);
expect(mocks.connectionFindOneAndUpdate).not.toHaveBeenCalled();
});

it("rejects a partial PUT (no type) on an existing firebird connection when disabled", async () => {
// `updateSchema` is partial, so a PUT can omit `type`. The gate must
// still fire based on the stored connection's type.
mocks.connectionFindOne.mockReturnValue({
lean: vi.fn().mockResolvedValue({ _id: "c1", project: "proj1", type: "firebird", connectionConfig: {} }),
});
const res = await app.request(`${BASE}/c1`, {
method: "PUT",
headers: { "content-type": "application/json" },
body: JSON.stringify({ description: "tweaked" }),
});
expect(res.status).toBe(400);
expect(mocks.connectionFindOneAndUpdate).not.toHaveBeenCalled();
});

it("rejects testing a stored firebird connection with 400 when disabled", async () => {
// `POST /:id/test` must not run the firebird install/attach branch (which
// loads the unsigned extension) while the capability is off.
mocks.connectionFindOne.mockResolvedValue({ _id: "c1", project: "proj1", type: "firebird" });
const res = await app.request(`${BASE}/c1/test`, { method: "POST" });
expect(res.status).toBe(400);
const body = await jsonBody<{ error: string }>(res);
expect(body.error).toMatch(/not enabled/i);
expect(mocks.testSingleConnection).not.toHaveBeenCalled();
});

it("tests a stored firebird connection when enabled", async () => {
mocks.customFirebirdEnabled.mockReturnValue(true);
mocks.connectionFindOne.mockResolvedValue({ _id: "c1", project: "proj1", type: "firebird" });
mocks.testSingleConnection.mockResolvedValue({
connect: vi.fn().mockResolvedValue({ run: vi.fn(), disconnectSync: vi.fn() }),
});
const res = await app.request(`${BASE}/c1/test`, { method: "POST" });
expect(res.status).toBe(200);
expect(mocks.testSingleConnection).toHaveBeenCalledTimes(1);
});
});
20 changes: 19 additions & 1 deletion apps/api/src/routes/connections.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { connectDB } from "@archmax/core/infra/db";
import { Connection, CONNECTION_TYPES, SLUG_PATTERN, slugifyConnectionName, Project, type IConnectionDocument } from "@archmax/core/models/index";
import { deleteProjectDuckdbFile, disposeProjectInstance, getProjectInstance, testSingleConnection, withQueryTimeout } from "@archmax/core/services/duckdb";
import { encryptConnectionCredentials, decryptConnectionCredentials } from "@archmax/core/infra/crypto";
import { getEnv } from "@archmax/core/config/env";
import { customFirebirdEnabled, getEnv } from "@archmax/core/config/env";
import { AppError } from "../utils/errors";

const IDENTIFIER_RE = /^[a-zA-Z_][a-zA-Z0-9_]*$/;
Expand All @@ -20,6 +20,7 @@ const connectionConfigSchema = z.object({
password: z.string().optional(),
uri: z.string().optional(),
encrypt: z.boolean().optional(),
charset: z.string().optional(),
endpoint: z.url("Endpoint must be a valid URL").optional(),
warehouse: z.string().optional(),
token: z.string().optional(),
Expand Down Expand Up @@ -181,6 +182,9 @@ const app = new Hono()
if (!project) throw AppError.notFound("Project not found");

const body = c.req.valid("json");
if (body.type === "firebird" && !customFirebirdEnabled()) {
throw AppError.badRequest("Firebird connections are not enabled on this server");
}
const slug = body.slug || slugifyConnectionName(body.name);
const encryptedConfig = encryptConnectionCredentials(
body.connectionConfig as Record<string, unknown>,
Expand All @@ -196,6 +200,13 @@ const app = new Hono()
if (!existing) throw AppError.notFound("Connection not found");

const body = c.req.valid("json");
// `updateSchema` is partial, so a PUT can omit `type`. Resolve the
// effective type (incoming override, else the stored one) so a partial
// update of an existing Firebird connection still hits the gate.
const effectiveType = body.type ?? existing.type;
if (effectiveType === "firebird" && !customFirebirdEnabled()) {
throw AppError.badRequest("Firebird connections are not enabled on this server");
}
Comment thread
cursor[bot] marked this conversation as resolved.
if (body.connectionConfig) {
body.connectionConfig = mergeConnectionConfig(
body.connectionConfig as Record<string, unknown>,
Expand Down Expand Up @@ -226,6 +237,13 @@ const app = new Hono()
project: projectId,
});
if (!conn) throw AppError.notFound("Connection not found");
// `testSingleConnection` runs the firebird install/attach branch, which
// loads the unsigned extension. Gate it the same way create/update do so a
// stored firebird connection cannot load the extension while the capability
// is disabled.
if (conn.type === "firebird" && !customFirebirdEnabled()) {
throw AppError.badRequest("Firebird connections are not enabled on this server");
}

try {
const instance = await testSingleConnection(conn as IConnectionDocument);
Expand Down
20 changes: 20 additions & 0 deletions apps/docs/src/content/docs/guides/data-federation.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ When you add a connection to a project and mark it as active, archmax attaches i
| SQLite | `sqlite` | Core |
| DuckDB | Native | - |
| Iceberg REST Catalog | `iceberg` + `httpfs` | Core |
| Firebird | `firebird` | Custom (unsigned) |

Each connection's slug becomes the DuckDB schema alias. For example, a connection with slug `shopify` attached from a Postgres database makes its tables available as `shopify.<schema>.<table>`.

Expand All @@ -36,6 +37,25 @@ When using **Connection URI**, you can pass any format the extension accepts (AD

The **Encrypt connection (TLS)** toggle controls the `Encrypt` parameter and defaults to on. Disable it only for local development servers that don't support TLS.

### Firebird Connections

Firebird is supported through a custom, **unsigned** DuckDB extension hosted at `https://archmaxai.github.io/duckdb_firebird`. Because the extension is unsigned, the `firebird` connection type is disabled by default and only appears in the connection form when the operator sets `DUCKDB_ENABLE_CUSTOM_FIREBIRD=true` (which also starts every DuckDB instance with `allow_unsigned_extensions`).

When active, archmax installs the extension automatically (`SET custom_extension_repository = '<repo>'; INSTALL firebird; LOAD firebird;`) and attaches Firebird connections from the **Connection Details** fields:

| Field | Description |
|-------|-------------|
| **Host** | Firebird server host |
| **Port** | Firebird server port (default `3050`) |
| **Database** | Database path or alias **as seen on the Firebird host machine** (e.g. `C:\firebird.fdb`) |
| **User** | Firebird user (e.g. `SYSDBA`) |
| **Password** | Firebird password |
| **Charset** | Connection charset (default `UTF8`) |

<Aside type="caution">
The Firebird extension is unsigned and executes arbitrary native code inside the application process. Only enable `DUCKDB_ENABLE_CUSTOM_FIREBIRD` when you trust the extension source.
</Aside>

### Iceberg REST Catalog Connections

Iceberg connections let you query data in [Apache Iceberg](https://iceberg.apache.org/) tables exposed through an [Iceberg REST Catalog](https://duckdb.org/docs/current/core_extensions/iceberg/iceberg_rest_catalogs) (Lakekeeper, Polaris, Cloudflare R2, etc.).
Expand Down
12 changes: 12 additions & 0 deletions apps/docs/src/content/docs/reference/docker.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,18 @@ If you access archmax through a reverse proxy, load balancer, or custom domain,
|----------|---------|-------------|
| `ARCHMAX_DATA_DIR` | `/data` | Root data directory for all persistent application data. Normally not changed in Docker. |

### Data Federation (DuckDB)

| Variable | Default | Description |
|----------|---------|-------------|
| `QUERY_TIMEOUT_MS` | `30000` | Per-query DuckDB timeout in milliseconds. Applies to MCP, agent, data browser, and connection-test queries. On timeout the query is cancelled via `interrupt()`. |
| `MAX_CONCURRENT_QUERIES` | `10` | Max concurrent DuckDB queries per project. Additional requests wait up to `QUERY_TIMEOUT_MS` for a slot. |
| `DUCKDB_ENABLE_CUSTOM_FIREBIRD` | `false` | Enable the custom (unsigned) `firebird` connection type. When enabled, the type appears in the connection form and every DuckDB instance starts with `allow_unsigned_extensions` so the Firebird extension can load. This is the only switch that enables unsigned-extension support; the federation console never installs unsigned extensions from arbitrary custom sources. The extension is installed from the public archmax-hosted repository (`https://archmaxai.github.io/duckdb_firebird`). |

<Aside type="caution">
The Firebird extension is unsigned and executes arbitrary native code inside the application process. Only set `DUCKDB_ENABLE_CUSTOM_FIREBIRD=true` when you trust the Firebird extension source. It is disabled by default.
</Aside>

### AI Agent

<Aside type="caution">
Expand Down
42 changes: 41 additions & 1 deletion apps/frontend/src/routes/_auth/$projectId/connections/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,7 @@ interface Connection {
user?: string;
uri?: string;
encrypt?: boolean;
charset?: string;
endpoint?: string;
warehouse?: string;
token?: string;
Expand Down Expand Up @@ -328,17 +329,32 @@ function ConnectionFormDialog({
const [password, setPassword] = useState("");
const [uri, setUri] = useState("");
const [encrypt, setEncrypt] = useState(true);
const [charset, setCharset] = useState("UTF8");
const [endpoint, setEndpoint] = useState("");
const [warehouse, setWarehouse] = useState("");
const [token, setToken] = useState("");
const [description, setDescription] = useState("");

const { data: appConfig } = useQuery({
queryKey: ["app-config"],
queryFn: async () => {
const res = await fetch("/api/config");
return res.json() as Promise<{ agentConfigured?: boolean; firebirdEnabled?: boolean }>;
},
staleTime: Infinity,
});
const firebirdEnabled = appConfig?.firebirdEnabled === true;
const connectionTypes = firebirdEnabled
? [...CONNECTION_TYPES, "firebird"]
: CONNECTION_TYPES;

const uriPlaceholders: Record<string, string> = {
postgres: "postgres://user:pass@host:5432/db",
mysql: "mysql://user:pass@host:3306/db",
mssql: "Server=host,1433;Database=db;User Id=user;Password=pass",
sqlite: "/path/to/database.db",
duckdb: "/path/to/database.duckdb",
firebird: "host=localhost port=3050 database=C:\\firebird.fdb user=SYSDBA password=... charset=UTF8",
};

const defaultPorts: Record<string, string> = {
Expand All @@ -347,6 +363,7 @@ function ConnectionFormDialog({
mssql: "1433",
sqlite: "",
duckdb: "",
firebird: "3050",
};

function autoSlug(n: string): string {
Expand All @@ -371,6 +388,7 @@ function ConnectionFormDialog({
setPassword("");
setUri(initialEditing.connectionConfig.uri ?? "");
setEncrypt(initialEditing.connectionConfig.encrypt ?? true);
setCharset(initialEditing.connectionConfig.charset ?? "UTF8");
setEndpoint(initialEditing.connectionConfig.endpoint ?? "");
setWarehouse(initialEditing.connectionConfig.warehouse ?? "");
setToken("");
Expand All @@ -389,6 +407,7 @@ function ConnectionFormDialog({
setPassword("");
setUri("");
setEncrypt(true);
setCharset("UTF8");
setEndpoint("");
setWarehouse("");
setToken("");
Expand All @@ -398,6 +417,7 @@ function ConnectionFormDialog({
}, [open, initialEditing]);

const isIceberg = type === "iceberg";
const isFirebird = type === "firebird";
const showSchema = SCHEMA_TYPES.has(type);
const isFileType = FILE_TYPES.has(type);

Expand All @@ -420,6 +440,7 @@ function ConnectionFormDialog({
if (database) config.database = database;
if (user) config.user = user;
if (password) config.password = password;
if (type === "firebird" && charset) config.charset = charset;
}
if (schema) config.schema = schema;
if (type === "mssql") config.encrypt = encrypt;
Expand Down Expand Up @@ -520,7 +541,7 @@ function ConnectionFormDialog({
<SelectValue />
</SelectTrigger>
<SelectContent>
{CONNECTION_TYPES.map((t) => (
{connectionTypes.map((t) => (
<SelectItem key={t} value={t}>
{t}
</SelectItem>
Expand Down Expand Up @@ -658,7 +679,14 @@ function ConnectionFormDialog({
id="conn-db"
value={database}
onChange={(e) => setDatabase(e.target.value)}
placeholder={isFirebird ? "C:\\firebird.fdb" : undefined}
className={isFirebird ? "font-mono" : undefined}
/>
{isFirebird && (
<p className="text-muted-foreground text-xs">
Database path or alias as seen on the Firebird host machine
</p>
)}
</div>
<div className="content-tight">
<Label htmlFor="conn-user">User</Label>
Expand All @@ -678,6 +706,18 @@ function ConnectionFormDialog({
/>
</div>
</div>

{isFirebird && (
<div className="content-tight">
<Label htmlFor="conn-charset">Charset</Label>
<Input
id="conn-charset"
value={charset}
onChange={(e) => setCharset(e.target.value)}
placeholder="UTF8"
/>
</div>
)}
</>
)}
</TabsContent>
Expand Down
Loading
Loading