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
14 changes: 14 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ jobs:
echo "API_IMAGE=ghcr.io/${REPO_LC}/api" >> $GITHUB_ENV
echo "WEB_IMAGE=ghcr.io/${REPO_LC}/web" >> $GITHUB_ENV
echo "AGENT_IMAGE=ghcr.io/${REPO_LC}/agent" >> $GITHUB_ENV
echo "SANDBOX_IMAGE=ghcr.io/${REPO_LC}/sandbox" >> $GITHUB_ENV
env:
REPO: ${{ github.repository }}

Expand All @@ -56,6 +57,7 @@ jobs:
push: true
build-args: |
BUILD_TIME=${{ github.sha }}
DEQUEL_POSTHOG_KEY=${{ secrets.DEQUEL_POSTHOG_KEY }}
tags: |
${{ env.API_IMAGE }}:${{ steps.version.outputs.VERSION }}
${{ steps.version.outputs.IS_PRERELEASE == 'false' && format('{0}:latest', env.API_IMAGE) || format('{0}:next', env.API_IMAGE) }}
Expand Down Expand Up @@ -89,6 +91,18 @@ jobs:
cache-from: type=gha,scope=agent-${{ github.sha }}
cache-to: type=gha,scope=agent-${{ github.sha }},mode=max

- name: Build and push diagnose sandbox image
uses: docker/build-push-action@v6
with:
context: apps/api
file: apps/api/Dockerfile.sandbox
push: true
tags: |
${{ env.SANDBOX_IMAGE }}:${{ steps.version.outputs.VERSION }}
${{ steps.version.outputs.IS_PRERELEASE == 'false' && format('{0}:latest', env.SANDBOX_IMAGE) || format('{0}:next', env.SANDBOX_IMAGE) }}
cache-from: type=gha,scope=sandbox-${{ github.sha }}
cache-to: type=gha,scope=sandbox-${{ github.sha }},mode=max

- name: Build config tarball
run: |
VERSION="${{ steps.version.outputs.VERSION }}"
Expand Down
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ infra/caddy/routes/
bugs
apps/api/index
docker-compose.yml
docker-compose.override.yml
bump.sh
scripts/workflow/bump.sh
__pycache__
Expand Down
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -178,7 +178,7 @@ Requires secrets: `VERCEL_TOKEN`, `VERCEL_ORG_ID`, `VERCEL_PROJECT_ID`
| `DATABASE_URL` | `postgresql://dequel:dequel@localhost:5432/dequel` | PostgreSQL connection string |
| `WORKSPACE_ROOT` | `./workspace` | Build staging |
| `CADDY_ROUTES_DIR` | `./infra/caddy/routes` | Caddy route output |
| `CADDY_BASE_DOMAIN` | `localhost` | Base domain for deployment subdomains. Set to a real domain (e.g. `example.com`) for Let's Encrypt auto-SSL. |
| `CADDY_BASE_DOMAIN` | `localhost` | Base domain for deployment subdomains. Set to a real domain (e.g. `example.com`) for Let's Encrypt auto-SSL. Public links (e.g. failure email logs) derive their base URL from this. |
| `CADDY_EMAIL` | _(empty)_ | Email for Let's Encrypt SSL certificate notifications |
| `DOCKER_NETWORK` | `dequel_net` | Docker network for deployments |
| `BUILDKIT_HOST` | `tcp://buildkit:1234` | Buildkit daemon |
Expand Down
2 changes: 1 addition & 1 deletion VERSION
Original file line number Diff line number Diff line change
@@ -1 +1 @@
0.3.0
0.4.0
2 changes: 1 addition & 1 deletion apps/agent/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "dequel-agent",
"version": "0.3.0",
"version": "0.4.0",
"private": true,
"type": "module",
"scripts": {
Expand Down
3 changes: 3 additions & 0 deletions apps/api/Dockerfile
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
FROM oven/bun:1

ARG BUILD_TIME=0
ARG DEQUEL_POSTHOG_KEY=""

ENV DEQUEL_POSTHOG_KEY=$DEQUEL_POSTHOG_KEY

RUN apt-get update && apt-get install -y --no-install-recommends \
ca-certificates \
Expand Down
23 changes: 23 additions & 0 deletions apps/api/Dockerfile.sandbox
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
FROM oven/bun:1

RUN apt-get update && apt-get install -y --no-install-recommends \
ca-certificates \
git \
ripgrep \
&& rm -rf /var/lib/apt/lists/*

WORKDIR /runner
COPY package.sandbox.json ./package.json
RUN bun install --production

COPY src/fixdiag/sandbox-runner/ ./fixdiag/sandbox-runner/
COPY src/fixdiag/types.ts ./fixdiag/types.ts
COPY src/fixdiag/llm.ts ./fixdiag/llm.ts
RUN rm -rf ./fixdiag/sandbox-runner/__tests__

RUN mkdir -p /srv/jobs /srv/dequel-src /srv/project-src \
&& chown -R bun:bun /srv/jobs /srv/dequel-src /srv/project-src

USER bun

CMD ["sleep", "infinity"]
3 changes: 2 additions & 1 deletion apps/api/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "dequel-api",
"version": "0.3.0",
"version": "0.4.0",
"private": true,
"type": "module",
"scripts": {
Expand All @@ -10,6 +10,7 @@
},
"dependencies": {
"@aws-sdk/client-s3": "^3.1130.0",
"@ax-llm/ax": "^25.0.0",
"@elysiajs/cors": "^1.1.1",
"@sinclair/typebox": "^0.34.13",
"drizzle-orm": "^0.45.2",
Expand Down
7 changes: 7 additions & 0 deletions apps/api/package.sandbox.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"name": "dequel-diag-sandbox",
"private": true,
"dependencies": {
"@ax-llm/ax": "^25.0.0"
}
}
7 changes: 5 additions & 2 deletions apps/api/src/agents/job-channel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
leaseNextAgentJob,
listCancelledJobIds,
listDeployments,
recordDeploymentFailure,
updateAgentHeartbeat,
updateDeploymentCommitSha,
updateDeploymentStatus,
Expand Down Expand Up @@ -119,8 +120,10 @@ export const processAgentJobUpdate = async (
}
}
} else {
await updateDeploymentStatus(deploymentId, "failed", {
failureReason: update.error || "Remote agent deployment failed",
await recordDeploymentFailure({
deploymentId,
reason: update.error || "Remote agent deployment failed",
source: "job-channel",
});
await appendLog(deploymentId, "system", `Remote deployment failed: ${update.error || "Unknown agent error"}`);
}
Expand Down
110 changes: 110 additions & 0 deletions apps/api/src/api/__tests__/auth-routes.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
import { afterAll, beforeAll, beforeEach, describe, expect, it } from "bun:test";
import { drizzle } from "drizzle-orm/node-postgres";
import type { Pool } from "pg";
import { setDbProvider } from "../../db/db-provider";
import * as schema from "../../db/schema";
import { createTestPool, truncateAllTables } from "../../db/test-helper";

const TEST_SECRET = "test-jwt-secret-for-testing-purposes-only";
let pool: Pool;

beforeAll(async () => {
pool = createTestPool();
const db = drizzle(pool, { schema });
setDbProvider(async () => db);
const { initAuth } = await import("../../utils/auth");
initAuth(TEST_SECRET);
});

beforeEach(async () => {
await truncateAllTables(pool);
});

afterAll(async () => {
await truncateAllTables(pool);
await pool.end();
});

describe("auth routes", () => {
it("GET /auth/me returns expiresIn when access token is valid", async () => {
const { authRoutes } = await import("../auth/index");
const { signAccessToken } = await import("../../utils/auth");
const token = await signAccessToken("testuser");
const res = await authRoutes.handle(
new Request("http://localhost/auth/me", {
headers: {
cookie: `dequel_session=${token}`,
},
}),
);
expect(res.status).toBe(200);
const json = (await res.json()) as {
status: string;
data: { authenticated: boolean; username: string; expiresIn: number };
};
expect(json.status).toBe("success");
expect(json.data.authenticated).toBe(true);
expect(json.data.username).toBe("testuser");
expect(typeof json.data.expiresIn).toBe("number");
expect(json.data.expiresIn).toBeGreaterThan(0);
expect(json.data.expiresIn).toBeLessThanOrEqual(900);
});

it("GET /auth/me auto-refreshes when access token is missing but refresh token is valid", async () => {
const { authRoutes } = await import("../auth/index");
const { generateRefreshToken, storeRefreshToken } = await import("../../utils/auth");
const rt = generateRefreshToken();
await storeRefreshToken("testuser", rt);

const res = await authRoutes.handle(
new Request("http://localhost/auth/me", {
headers: {
cookie: `dequel_refresh=${rt}`,
},
}),
);
expect(res.status).toBe(200);
const json = (await res.json()) as {
status: string;
data: { authenticated: boolean; username: string; expiresIn: number };
};
expect(json.status).toBe("success");
expect(json.data.authenticated).toBe(true);
expect(json.data.username).toBe("testuser");
expect(json.data.expiresIn).toBe(900);

const cookies = res.headers.get("set-cookie") || "";
expect(cookies).toContain("dequel_session=");
expect(cookies).toContain("dequel_refresh=");
});

it("GET /auth/me returns unauthenticated when no valid tokens exist", async () => {
const { authRoutes } = await import("../auth/index");
const res = await authRoutes.handle(new Request("http://localhost/auth/me"));
expect(res.status).toBe(200);
const json = (await res.json()) as { status: string; data: { authenticated: boolean } };
expect(json.status).toBe("success");
expect(json.data.authenticated).toBe(false);
});

it("POST /auth/refresh returns expiresIn: 900 and rotates tokens", async () => {
const { authRoutes } = await import("../auth/index");
const { generateRefreshToken, storeRefreshToken } = await import("../../utils/auth");
const rt = generateRefreshToken();
await storeRefreshToken("testuser", rt);

const res = await authRoutes.handle(
new Request("http://localhost/auth/refresh", {
method: "POST",
headers: {
cookie: `dequel_refresh=${rt}`,
},
}),
);
expect(res.status).toBe(200);
const json = (await res.json()) as { status: string; data: { username: string; expiresIn: number } };
expect(json.status).toBe("success");
expect(json.data.username).toBe("testuser");
expect(json.data.expiresIn).toBe(900);
});
});
20 changes: 20 additions & 0 deletions apps/api/src/api/alerts/index.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,34 @@
import { Elysia } from "elysia";
import { createAlert, deleteAlert, listAlerts, updateAlertEnabled } from "../../db/repo";
import type { AlertChannel, AlertType } from "../../types";
import { validateDestination } from "../../utils/destination";
import { created, fail, ok } from "../response";

const ALERT_TYPES: ReadonlySet<string> = new Set<AlertType>(["cpu", "memory", "downtime"]);
const ALERT_CHANNELS: ReadonlySet<string> = new Set<AlertChannel>(["email", "slack", "webhook"]);

export const alertsRoutes = new Elysia()
.get("/projects/:id/alerts", async ({ params }) => ok(await listAlerts(params.id)))
.post("/projects/:id/alerts", async ({ params, body, set }: any) => {
if (!body?.type || !body?.channel) {
set.status = 400;
return fail("type and channel are required");
}
if (!ALERT_TYPES.has(String(body.type))) {
set.status = 400;
return fail(`type must be one of: ${[...ALERT_TYPES].join(", ")}`);
}
if (!ALERT_CHANNELS.has(String(body.channel))) {
set.status = 400;
return fail(`channel must be one of: ${[...ALERT_CHANNELS].join(", ")}`);
}
if (body.channel !== "email") {
const destinationError = await validateDestination(String(body.destination ?? ""));
if (destinationError) {
set.status = 400;
return fail(destinationError);
}
}
return created(
await createAlert({
projectId: params.id,
Expand Down
41 changes: 34 additions & 7 deletions apps/api/src/api/auth/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@ export const authRoutes = new Elysia()
dequel_session.set({ ...SESSION_COOKIE_OPTS, secure: isSecure });
dequel_refresh.value = refreshToken;
dequel_refresh.set({ ...REFRESH_COOKIE_OPTS, secure: isSecure });
return ok({ username }, "Logged in");
return ok({ username, expiresIn: 900 }, "Logged in");
})
.post("/auth/logout", async ({ cookie: { dequel_session, dequel_refresh } }) => {
const rt = dequel_refresh.value;
Expand Down Expand Up @@ -99,12 +99,39 @@ export const authRoutes = new Elysia()
dequel_session.set({ ...SESSION_COOKIE_OPTS, secure: isSecure });
dequel_refresh.value = newRefreshToken;
dequel_refresh.set({ ...REFRESH_COOKIE_OPTS, secure: isSecure });
return ok({ username }, "Token refreshed");
return ok({ username, expiresIn: 900 }, "Token refreshed");
})
.get("/auth/me", async ({ cookie: { dequel_session } }) => {
.get("/auth/me", async ({ cookie: { dequel_session, dequel_refresh }, isSecure }) => {
const token = dequel_session.value;
if (!token) return ok({ authenticated: false });
const payload = await verifyAccessToken(token);
if (!payload) return ok({ authenticated: false });
return ok({ authenticated: true, username: payload.sub });
if (token) {
const payload = await verifyAccessToken(token);
if (payload) {
const current = Math.floor(Date.now() / 1000);
return ok({
authenticated: true,
username: payload.sub,
expiresIn: Math.max(0, payload.exp - current),
});
}
}
const rt = dequel_refresh.value;
if (rt) {
const username = await validateRefreshToken(rt);
if (username) {
await blacklistRefreshToken(rt);
const accessToken = await signAccessToken(username);
const newRefreshToken = generateRefreshToken();
await storeRefreshToken(username, newRefreshToken);
dequel_session.value = accessToken;
dequel_session.set({ ...SESSION_COOKIE_OPTS, secure: isSecure });
dequel_refresh.value = newRefreshToken;
dequel_refresh.set({ ...REFRESH_COOKIE_OPTS, secure: isSecure });
return ok({
authenticated: true,
username,
expiresIn: 900,
});
}
}
return ok({ authenticated: false });
});
2 changes: 1 addition & 1 deletion apps/api/src/api/backups/index.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { Elysia } from "elysia";
import { BackupOrchestrator } from "../../backup/orchestrator";
import { S3_BACKUP_PREFIX } from "../../backup/types";
import type { BackupTarget, StorageConfig } from "../../backup/types";
import { S3_BACKUP_PREFIX } from "../../backup/types";
import { deleteBackupRecord, getBackupRecord, listBackupRecords } from "../../db/repo/backups";
import { getDatabaseById } from "../../db/repo/databases";
import { getBackupStorageSettings } from "../../db/repo/settings";
Expand Down
Loading
Loading