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
17 changes: 17 additions & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
.git
.codex
.DS_Store
.env
.env.*
.evidence-cache
.vercel
.vs
coverage
dist
docs
docs/evidence-candidates/dna-bulk-*.json
docs/evidence-candidates/dna-seed-*.json
docs/evidence-candidates/dna-seed-*/
node_modules
stats.html
*.tsbuildinfo
69 changes: 69 additions & 0 deletions .github/workflows/publish-container.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
name: Publish container

on:
pull_request:
push:
branches:
- main
tags:
- "v*"
workflow_dispatch:

permissions:
contents: read
packages: write
id-token: write

env:
REGISTRY: ghcr.io

jobs:
image:
name: Build container image
runs-on: ubuntu-latest

steps:
- name: Check out repository
uses: actions/checkout@v4

- name: Resolve image name
run: echo "IMAGE_NAME=${GITHUB_REPOSITORY_OWNER,,}/deana" >> "$GITHUB_ENV"

- name: Set up QEMU
uses: docker/setup-qemu-action@v3

- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3

- name: Log in to GitHub Container Registry
if: github.event_name != 'pull_request'
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}

- name: Extract metadata
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
flavor: latest=false
tags: |
type=ref,event=branch
type=ref,event=tag
type=sha,prefix=sha-
type=raw,value=latest,enable=${{ startsWith(github.ref, 'refs/tags/v') }}

- name: Build image and publish on non-PR events
uses: docker/build-push-action@v6
with:
context: .
platforms: linux/amd64,linux/arm64
push: ${{ github.event_name != 'pull_request' }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max
provenance: true
sbom: true
1 change: 0 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@ dist
.env.*
!.env.example
.vercel
bun.lock
*.tsbuildinfo
stats.html
vite.config.js
Expand Down
40 changes: 40 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
# syntax=docker/dockerfile:1.7

ARG BUN_IMAGE=oven/bun:1.3.10-slim

FROM ${BUN_IMAGE} AS deps
WORKDIR /app
COPY package.json bun.lock ./
RUN bun install --frozen-lockfile

FROM deps AS build
COPY index.html tsconfig*.json vite.config.ts ./
COPY public ./public
COPY src ./src
COPY api ./api
RUN bun run build

FROM ${BUN_IMAGE} AS production-deps
WORKDIR /app
ENV NODE_ENV=production
COPY package.json bun.lock ./
RUN bun install --frozen-lockfile --production

FROM ${BUN_IMAGE} AS runtime
WORKDIR /app
ENV NODE_ENV=production \
HOST=0.0.0.0 \
PORT=8080

COPY --from=production-deps --chown=bun:bun /app/node_modules ./node_modules
COPY --from=build --chown=bun:bun /app/dist ./dist
COPY --chown=bun:bun package.json ./
COPY --chown=bun:bun server ./server
COPY --chown=bun:bun api/ai-status.ts api/chat.ts api/chat-title.ts ./api/
COPY --chown=bun:bun src/lib ./src/lib
COPY --chown=bun:bun src/types.ts ./src/types.ts

USER bun
EXPOSE 8080
HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 CMD bun -e "fetch('http://127.0.0.1:' + (process.env.PORT || '8080') + '/healthz').then((r) => { if (!r.ok) process.exit(1); }).catch(() => process.exit(1));"
CMD ["bun", "run", "start"]
34 changes: 34 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,40 @@ bun run preview

Use Bun for package scripts so local development matches the lockfile and CI workflow.

## Container Install

Deana can also run as a self-hosted container. The image serves the production Vite build, local evidence-pack assets, browser-history route fallbacks, and the same `/api` chat endpoints used by Vercel deployments.

Run a published image:

```bash
docker run --rm -p 8080:8080 ghcr.io/stephenradford/deana:latest
```

Build and run from this checkout:

```bash
docker compose up --build
```

Then open `http://localhost:8080`. Raw DNA parsing, report storage, and evidence matching still happen locally in the browser. The container does not add a raw-DNA upload service.

AI chat is included in the container, but first-party Gateway chat needs an AI Gateway API key because Vercel OIDC is only available on Vercel deployments:

```bash
AI_GATEWAY_API_KEY=... docker compose up --build
```

Optional runtime model configuration:

```bash
AI_GATEWAY_API_KEY=...
DEANA_LLM_MODEL=google/gemini-3-flash
VITE_DEANA_MODEL_LIST=google/gemini-3-flash,openai/gpt-5.4-nano,openai/gpt-5-mini
```

`VITE_DEANA_MODEL_LIST` is read by the container server at startup and exposed as public model-selector metadata through `/api/ai-status`; it does not expose secrets. If `AI_GATEWAY_API_KEY` is absent, the app hides first-party AI chat, while local report exploration continues to work.

## AI Chat Setup

Deana uses the Vercel AI SDK v6 package set for opt-in Explorer chat:
Expand Down
50 changes: 50 additions & 0 deletions api/ai-status.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import { afterEach, describe, expect, it } from "vitest";
import handler from "./ai-status.js";

const originalEnv = { ...process.env };

function statusRequest(): Request {
return new Request("https://deana.test/api/ai-status", {
method: "GET",
});
}

async function responseBody(response: Response): Promise<Record<string, unknown>> {
return await response.json() as Record<string, unknown>;
}

function enableGatewayAuth(): void {
process.env.AI_GATEWAY_API_KEY = "secret-key";
}

describe("AI status endpoint", () => {
afterEach(() => {
process.env = { ...originalEnv };
});

it("reports runtime model options without exposing secrets", async () => {
enableGatewayAuth();
process.env.VITE_DEANA_MODEL_LIST = "google/gemini-3-flash, openai/gpt-5-mini";

const response = await handler(statusRequest());

expect(response.status).toBe(200);
await expect(responseBody(response)).resolves.toEqual({
enabled: true,
model: "google/gemini-3-flash",
models: ["google/gemini-3-flash", "openai/gpt-5-mini"],
});
});

it("hides the fallback model in production responses", async () => {
enableGatewayAuth();
process.env.VERCEL_ENV = "production";

const response = await handler(statusRequest());
const body = await responseBody(response);

expect(body.enabled).toBe(true);
expect(body.models).toEqual(expect.arrayContaining(["google/gemini-3-flash"]));
expect(body).not.toHaveProperty("model");
});
});
25 changes: 23 additions & 2 deletions api/ai-status.ts
Original file line number Diff line number Diff line change
@@ -1,21 +1,42 @@
import { hasGatewayAuth } from "../src/lib/aiGatewayAuth.js";
import { chatModelFromEnv } from "../src/lib/ai/models.js";
import { availableModelsFromEnv, chatModelFromEnv } from "../src/lib/ai/models.js";

declare const process: {
env: Record<string, string | undefined>;
};

interface AiStatusResponse {
enabled: boolean;
model?: string;
models: string[];
}

export const config = {
runtime: "edge",
};

let cachedModelsEnv: string | undefined;
let cachedModels: string[] | null = null;

function statusModelsFromEnv(env: Record<string, string | undefined>): string[] {
const modelListEnv = env.VITE_DEANA_MODEL_LIST;
if (cachedModels && cachedModelsEnv === modelListEnv) {
return cachedModels;
}

cachedModelsEnv = modelListEnv;
cachedModels = availableModelsFromEnv(env);
return cachedModels;
}

export default async function handler(request: Request): Promise<Response> {
if (request.method !== "GET") {
return Response.json({ error: "Method not allowed." }, { status: 405 });
}

const body: { enabled: boolean; model?: string } = {
const body: AiStatusResponse = {
enabled: hasGatewayAuth(request, process.env),
models: statusModelsFromEnv(process.env),
};
if (process.env.VERCEL_ENV !== "production") {
body.model = chatModelFromEnv(process.env);
Expand Down
Loading
Loading