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
11 changes: 11 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
# Optional: openapi-typescript types-only generator (src/types/openapi.generated.d.ts). See scripts/generate-openapi-types.mjs
# OPENAPI_PRIMARY_URL=https://bitloops.local:5667/api/openapi.json
# OPENAPI_FALLBACK_URL=http://127.0.0.1:5667/api/openapi.json
# OPENAPI_TYPES_OUTPUT=src/types/openapi.generated.d.ts
# OPENAPI_FETCH_TIMEOUT_MS=8000

# Vite dev server: proxy /api to this origin (see vite.config.ts)
# VITE_API_PROXY_TARGET=

# Query explorer: max age (ms) for persisted run history entries (default: 2592000000 = 30 days).
# VITE_QUERY_HISTORY_TTL_MS=2592000000
125 changes: 116 additions & 9 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -8,16 +8,15 @@ on:
branches:
- main

permissions:
contents: read

env:
NODE_VERSION: '22'

jobs:
validate:
name: Lint And Build
runs-on: ubuntu-latest
quality:
name: Quality
runs-on: self-hosted
permissions:
contents: read
steps:
- name: Checkout
uses: actions/checkout@v4
Expand All @@ -36,20 +35,128 @@ jobs:
- name: Install dependencies
run: pnpm install --frozen-lockfile

- name: Install Playwright browser
run: pnpm exec playwright install --with-deps chromium

- name: Run format checker
run: pnpm run format:check

- name: Run linter
run: pnpm run lint

# Fails on critical; run `pnpm audit` locally to see high/moderate (e.g. transitive devDeps).
- name: Dependency audit
run: pnpm audit --audit-level=critical

test:
name: Unit And Integration Tests
runs-on: self-hosted
permissions:
contents: read
steps:
- name: Checkout
uses: actions/checkout@v4

- name: Setup pnpm
uses: pnpm/action-setup@v4
with:
version: 9

- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
cache: pnpm

- name: Install dependencies
run: pnpm install --frozen-lockfile

- name: Run unit and integration tests
run: pnpm run test

e2e:
name: E2E Tests
runs-on: self-hosted
permissions:
contents: read
steps:
- name: Checkout
uses: actions/checkout@v4

- name: Setup pnpm
uses: pnpm/action-setup@v4
with:
version: 9

- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
cache: pnpm

- name: Install dependencies
run: pnpm install --frozen-lockfile

- name: Install Playwright browser
run: pnpm exec playwright install --with-deps chromium

- name: Run e2e tests
run: pnpm run test:e2e

codeql:
name: Security Analysis
runs-on: self-hosted
permissions:
contents: read
security-events: write
steps:
- name: Checkout
uses: actions/checkout@v4

- name: Initialize CodeQL
uses: github/codeql-action/init@v3
with:
languages: javascript-typescript

- name: Setup pnpm
uses: pnpm/action-setup@v4
with:
version: 9

- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
cache: pnpm

- name: Install dependencies
run: pnpm install --frozen-lockfile

- name: Autobuild
uses: github/codeql-action/autobuild@v3

- name: Perform CodeQL analysis
uses: github/codeql-action/analyze@v3

build:
name: Build
runs-on: self-hosted
permissions:
contents: read
steps:
- name: Checkout
uses: actions/checkout@v4

- name: Setup pnpm
uses: pnpm/action-setup@v4
with:
version: 9

- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
cache: pnpm

- name: Install dependencies
run: pnpm install --frozen-lockfile

- name: Build
run: pnpm run build
36 changes: 36 additions & 0 deletions SECURITY.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
# Security Policy

## Reporting Security Issues

The Bitloops team takes security issues seriously. We appreciate responsible disclosure and will make every effort to acknowledge valid reports and handle them carefully.

If you believe you found a vulnerability, please report it privately. Do not open a public issue for anything that could expose users, repositories, credentials, prompts, local metadata, or private code.

### Preferred reporting channels

- GitHub Security Advisories for this repository (use the "Report a vulnerability" button on the Security tab)
- Email [opencode@bitloops.com](mailto:opencode@bitloops.com) with `SECURITY` in the subject line

### What to include

Please include as much of the following as possible:

- A clear description of the issue and its impact
- The affected version, release, or commit SHA
- Your environment, including OS and install method
- The affected Bitloops surface area, such as the CLI, dashboard, git hooks, local storage, or agent integration
- Reproduction steps or a proof of concept
- Any logs, screenshots, or sample payloads with secrets redacted

### Response expectations

We will aim to:

- Acknowledge receipt within 3 business days
- Triage the report and determine severity
- Keep you updated as the investigation progresses
- Coordinate disclosure after a fix or mitigation is available

### Supported versions

Bitloops is evolving quickly. We prioritize security fixes for the latest release and the current main branch. Older versions may be reviewed on a case-by-case basis, but backports are not guaranteed.
41 changes: 25 additions & 16 deletions e2e/dashboard.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -254,7 +254,7 @@ async function stubApiRoutes(page: Page) {
body: JSON.stringify(STUB_CHECKPOINT_DETAIL),
})
})
await page.route('**/api/checkpoints*', (route: Route) => {
await page.route('**/api/checkpoints/**', (route: Route) => {
void route.fulfill({
status: 200,
contentType: 'application/json',
Expand Down Expand Up @@ -311,39 +311,48 @@ test.describe('App / dashboard load', () => {
test('dashboard shows API error banner when data endpoints fail', async ({
page,
}) => {
const base = 'http://127.0.0.1:5667'

// All other data endpoints fail
await page.route(`${base}/api/**`, (route: Route) => {
// Keep option endpoints healthy so branch resolution succeeds, but force
// commit loading to fail to trigger the dashboard data error banner.
await page.route('**/api/branches*', (route: Route) => {
void route.fulfill({
status: 500,
status: 200,
contentType: 'application/json',
body: JSON.stringify({ error: 'Internal Server Error' }),
body: JSON.stringify(STUB_BRANCHES),
})
})

// Branches must succeed so effectiveBranch is set and the data request fires.
// Register this after the catch-all so it takes precedence for /api/branches*.
await page.route(`${base}/api/branches*`, (route: Route) => {
await page.route('**/api/users*', (route: Route) => {
void route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(STUB_BRANCHES),
body: JSON.stringify(STUB_USERS),
})
})
await page.route('**/api/agents*', (route: Route) => {
void route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(STUB_AGENTS),
})
})
await page.route('**/api/commits*', (route: Route) => {
void route.fulfill({
status: 500,
contentType: 'application/json',
body: JSON.stringify({ error: 'Internal Server Error' }),
})
})

await page.goto('/')

await expect(
page.getByText(/Could not load dashboard data from the API/),
).toBeVisible()
).toBeVisible({ timeout: 8000 })
})

test('shows "No branches" message when /api/branches returns empty', async ({
page,
}) => {
const base = 'http://127.0.0.1:5667'
await page.route(`${base}/api/branches*`, (route: Route) => {
await page.route('**/api/branches*', (route: Route) => {
void route.fulfill({
status: 200,
contentType: 'application/json',
Expand Down Expand Up @@ -581,7 +590,7 @@ test.describe('Checkpoint sheet', () => {
body: JSON.stringify({ error: 'error' }),
})
})
await page.route('**/api/checkpoints*', (route) => {
await page.route('**/api/checkpoints/**', (route) => {
void route.fulfill({
status: 500,
contentType: 'application/json',
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
"version": "0.0.3",
"type": "module",
"scripts": {
"generate-openapi-types": "node scripts/generate-openapi-types.mjs",
"open-api-codegen": "curl -o ./swagger.json http://127.0.0.1:5667/api/openapi.json && npx openapi-typescript-codegen --input ./swagger.json --output ./src/api/types/schema --name BitloopsCli --useOptions",
"dev": "vite",
"build": "tsc -b && vite build",
Expand Down
8 changes: 1 addition & 7 deletions scripts/generate-openapi-types.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -38,15 +38,9 @@ const fileExists = async (filePath) => {

const runGenerator = async (schemaUrl) =>
new Promise((resolve, reject) => {
const env = { ...process.env }

if (schemaUrl.startsWith('https://')) {
env.NODE_TLS_REJECT_UNAUTHORIZED ??= '0'
}

const child = spawn(generatorBin, [schemaUrl, '--output', outputPath], {
stdio: isVerbose ? 'inherit' : 'pipe',
env,
env: process.env,
})

let output = ''
Expand Down
64 changes: 64 additions & 0 deletions src/config/query-history-storage.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import type { HistoryEntry } from '@/store/types'

/** Persisted preference (always in localStorage). */
export const STORAGE_MODE_KEY = 'query-explorer-storage-mode'

/** Run history blob key in localStorage or sessionStorage. */
export const RUN_HISTORY_KEY = 'query-explorer-history'

export type HistoryStorageMode = 'local' | 'session' | 'off'

export function parseHistoryStorageMode(
raw: string | null,
): HistoryStorageMode {
if (raw === 'session' || raw === 'off' || raw === 'local') return raw
return 'local'
}

export function getHistoryStorageModeFromWindow(
w: Window & typeof globalThis,
): HistoryStorageMode {
try {
return parseHistoryStorageMode(w.localStorage.getItem(STORAGE_MODE_KEY))
} catch {
return 'local'
}
}

/** Returns null when mode is `off` or accessing storage throws (e.g. SecurityError). */
export function getHistoryStorageForMode(
w: Window & typeof globalThis,
mode: HistoryStorageMode,
): Storage | null {
if (mode === 'off') return null
try {
return mode === 'session' ? w.sessionStorage : w.localStorage
} catch {
return null
}
}

/** Returns null when mode is `off` (no persistence). */
export function getHistoryStorage(
w: Window & typeof globalThis,
): Storage | null {
const mode = getHistoryStorageModeFromWindow(w)
return getHistoryStorageForMode(w, mode)
}

export function getHistoryTtlMs(): number {
const raw = import.meta.env.VITE_QUERY_HISTORY_TTL_MS
const n = Number(raw)
if (raw !== undefined && raw !== '' && Number.isFinite(n) && n > 0) {
return n
}
return 30 * 24 * 60 * 60 * 1000
}

export function pruneHistoryByTtl(
entries: HistoryEntry[],
now: number,
ttlMs: number,
): HistoryEntry[] {
return entries.filter((e) => now - e.runAt <= ttlMs)
}
8 changes: 1 addition & 7 deletions src/features/dashboard/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,13 +22,7 @@ import {
} from './utils'

export function Dashboard() {
const cli = useMemo(
() =>
new BitloopsCli({
BASE: import.meta.env.VITE_BITLOOPS_CLI_BASE ?? 'http://127.0.0.1:5667',
}),
[],
)
const cli = useMemo(() => new BitloopsCli(), [])

const [selectedBranch, setSelectedBranch] = useState<string | null>(null)
const [selectedUser, setSelectedUser] = useState<string | null>(null)
Expand Down
Loading
Loading