diff --git a/.dockerignore b/.dockerignore index db43474..6478979 100644 --- a/.dockerignore +++ b/.dockerignore @@ -15,3 +15,6 @@ target/ Thumbs.db .idea/ .vscode/ + +# A local server's one-time setup token must never reach an image. +checkgate-setup-token diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b1cb65a..9a4b546 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -15,6 +15,19 @@ concurrency: cancel-in-progress: true jobs: + # --------------------------------------------------------------------------- + # Repository hygiene — dependencies, build output and tool caches must not be + # committed; every job below installs and builds what it needs itself. + # --------------------------------------------------------------------------- + hygiene: + name: Repository hygiene + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + + - name: No generated or machine-specific files are tracked + run: scripts/check-tracked-artifacts.sh + # --------------------------------------------------------------------------- # Rust — test the whole workspace # --------------------------------------------------------------------------- @@ -144,6 +157,10 @@ jobs: run: | cargo test -p server --test integration -- --nocapture cargo test -p server --test e2e_sse -- --nocapture + cargo test -p server --test rate_limit -- --nocapture + cargo test -p server --test setup_ownership -- --nocapture + cargo test -p server --test sdk_health -- --nocapture + cargo test -p server --test environment_isolation -- --nocapture # --------------------------------------------------------------------------- # Native SDK e2e — build the real @checkgate/node addon and drive it over a @@ -203,17 +220,48 @@ jobs: REDIS_URL: redis://localhost:6379 run: | PORT=3111 + SETUP_TOKEN="ci-setup-token-$(openssl rand -hex 16)" SESSION_SECRET=ci-secret-0123456789-0123456789-0123456789-0123456789 \ + SETUP_TOKEN="$SETUP_TOKEN" \ COOKIE_SECURE=false PORT=$PORT PUBLIC_DIR="$(mktemp -d)" RUST_LOG=warn \ ./target/debug/server > server.log 2>&1 & SRV=$! for i in $(seq 1 60); do curl -sf "http://127.0.0.1:$PORT/health" >/dev/null && break; sleep 1; done - CHECKGATE_URL="http://127.0.0.1:$PORT" node sdks/nodejs/e2e/e2e.cjs + CHECKGATE_URL="http://127.0.0.1:$PORT" CHECKGATE_SETUP_TOKEN="$SETUP_TOKEN" \ + node sdks/nodejs/e2e/e2e.cjs RC=$? kill $SRV 2>/dev/null || true if [ $RC -ne 0 ]; then echo "=== server log ==="; cat server.log; fi exit $RC + # --------------------------------------------------------------------------- + # Docker image — build it and smoke-test both deployment modes (all-in-one + # and external PostgreSQL/Redis), restarts, persistence and refusal of + # half-specified configuration. + # --------------------------------------------------------------------------- + docker-smoke: + name: Docker image smoke tests + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Build image + uses: docker/build-push-action@v6 + with: + context: . + file: Dockerfile + push: false + load: true + tags: checkgate:smoke + cache-from: type=gha + cache-to: type=gha,mode=max + + - name: Smoke-test deployment modes + run: scripts/docker-smoke.sh checkgate:smoke + # --------------------------------------------------------------------------- # Dashboard — TypeScript build check # --------------------------------------------------------------------------- diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 8cc382c..f1f276a 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -94,11 +94,10 @@ jobs: artifact: checkgate.linux-arm64-gnu.node cross: true - # Linux x64 (musl — Alpine) — cross-compile via napi-rs cross toolchain + # Linux x64 (musl — Alpine) — cross-compile with Zig - os: ubuntu-latest target: x86_64-unknown-linux-musl artifact: checkgate.linux-x64-musl.node - cross: true # macOS x64 — cross-compile natively from arm64 runner - os: macos-latest @@ -118,10 +117,19 @@ jobs: steps: - uses: actions/checkout@v5 - - name: Install Rust toolchain - uses: dtolnay/rust-toolchain@stable + # Install targets on the toolchain selected by rust-toolchain.toml. + # Installing them on `stable` can leave the pinned toolchain without core/std. + - name: Install Rust toolchain and target + shell: bash + run: | + rustup show + rustup target add "${{ matrix.target }}" + + - name: Install Zig for musl + if: matrix.target == 'x86_64-unknown-linux-musl' + uses: mlugg/setup-zig@v2 with: - targets: ${{ matrix.target }} + version: 0.14.1 - name: Cache Rust build artifacts uses: Swatinem/rust-cache@v2 @@ -141,10 +149,10 @@ jobs: shell: bash run: | JS_FLAGS="--js native-binding.js --dts native-binding.d.ts" - if [ "${{ matrix.target }}" = "aarch64-unknown-linux-musl" ]; then - # Satisfy host-side NAPI-RS linker check (will use real linker inside Docker) - sudo ln -s /usr/bin/true /usr/bin/aarch64-linux-musl-gcc - npx napi build --platform --release --target ${{ matrix.target }} --use-napi-cross $JS_FLAGS + if [ "${{ matrix.target }}" = "x86_64-unknown-linux-musl" ]; then + # Zig supplies the musl runtime and unwinder that Ubuntu's + # musl-gcc wrapper lacks. NAPI also disables static CRT linkage. + npx napi build --platform --release --target ${{ matrix.target }} --cross-compile $JS_FLAGS elif [ "${{ matrix.cross }}" = "true" ]; then npx napi build --platform --release --target ${{ matrix.target }} --use-napi-cross $JS_FLAGS else @@ -179,10 +187,10 @@ jobs: steps: - uses: actions/checkout@v5 - - name: Install Rust toolchain - uses: dtolnay/rust-toolchain@stable - with: - targets: wasm32-unknown-unknown + - name: Install Rust toolchain and target + run: | + rustup show + rustup target add wasm32-unknown-unknown - name: Cache Rust build artifacts uses: Swatinem/rust-cache@v2 @@ -291,6 +299,11 @@ jobs: cache-from: type=gha cache-to: type=gha,mode=max + # Both deployment modes must start, persist data and refuse + # misconfiguration before anything is published. + - name: Smoke-test deployment modes + run: scripts/docker-smoke.sh "${{ env.IMAGE_NAME }}:scan" + - name: Run Trivy vulnerability scanner uses: aquasecurity/trivy-action@master with: diff --git a/.gitignore b/.gitignore index f45c362..697fee1 100644 --- a/.gitignore +++ b/.gitignore @@ -1,11 +1,19 @@ # Rust target/ -# Node / Bun -dashboard/node_modules/ -docs/node_modules/ +# Node / Bun — dependencies are installed by each package's own +# `npm install` / `bun install`; never commit them. +node_modules/ docs/.vitepress/dist/ server/public/ +coverage/ + +# Dart / Flutter — tool state and build/test caches are machine-specific and +# regenerated by `flutter pub get` / `flutter test`. +.dart_tool/ +.flutter-plugins +.flutter-plugins-dependencies +sdks/flutter/dart/build/ # TypeScript incremental build state — regenerated by every `tsc -b`, so # tracking it churns the diff on each commit. @@ -22,3 +30,6 @@ docs/bun.lock .env .env.* !.env.example + +# One-time setup token written by a local server that has not been set up. +checkgate-setup-token diff --git a/Cargo.lock b/Cargo.lock index 89bcdb8..fb0e844 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2228,6 +2228,7 @@ dependencies = [ "futures-util", "governor", "hmac", + "ipnet", "rand 0.10.2", "redis", "reqwest", @@ -2238,6 +2239,7 @@ dependencies = [ "time", "tokio", "tokio-stream", + "tower", "tower-http", "tracing", "tracing-subscriber", diff --git a/Dockerfile b/Dockerfile index 798deb6..8be4c32 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,8 +1,14 @@ # ============================================================================= -# Checkgate — all-in-one image +# Checkgate image # -# Bundles: PostgreSQL 16 + Redis 7 + Checkgate server + React dashboard. -# Perfect for demos, staging, or single-node self-hosted deployments. +# Bundles: PostgreSQL + Redis + Checkgate server + React dashboard. +# +# One image, two modes (see docker/entrypoint-full.sh, CHECKGATE_MODE): +# all-in-one (default) starts the bundled PostgreSQL and Redis — demos, +# staging, single-node self-hosting. +# external uses DATABASE_URL and REDIS_URL and starts neither bundled +# service — managed databases, multiple replicas. Chosen +# automatically when either URL is set. # ============================================================================= # --------------------------------------------------------------------------- @@ -77,7 +83,8 @@ COPY --from=dashboard-builder /app/server/public ./public COPY docker/entrypoint-full.sh /entrypoint.sh RUN sed -i 's/\r//' /entrypoint.sh && chmod +x /entrypoint.sh && \ - mkdir -p /var/lib/postgresql/data /var/log && \ + mkdir -p /var/lib/postgresql/data /var/log /var/lib/checkgate /var/lib/redis && \ + chmod 700 /var/lib/checkgate && \ chown -R postgres:postgres /var/lib/postgresql && \ touch /var/log/postgresql.log /var/log/redis.log && \ chown postgres:postgres /var/log/postgresql.log /var/log/redis.log @@ -89,6 +96,7 @@ HEALTHCHECK --interval=30s --timeout=10s --start-period=10s --retries=3 \ CMD curl -f http://localhost:3000/health || exit 1 ENV PUBLIC_DIR=/app/public \ + SETUP_TOKEN_FILE=/var/lib/checkgate/setup-token \ PGDATA=/var/lib/postgresql/data \ POSTGRES_USER=checkgate \ POSTGRES_PASSWORD=checkgate \ diff --git a/NOTICE b/NOTICE index abe2ee9..9fa5454 100644 --- a/NOTICE +++ b/NOTICE @@ -1,5 +1,5 @@ Checkgate -Copyright 2025 ThinkGrid Labs +Copyright 2025 -This product includes software developed at ThinkGrid Labs +This product includes software developed (https://github.com/checkgate-sh/checkgate). diff --git a/README.md b/README.md index 7b523bc..a16b51d 100644 --- a/README.md +++ b/README.md @@ -116,6 +116,6 @@ See [CONTRIBUTING.md](CONTRIBUTING.md). ## License -Apache License 2.0 — © 2026 ThinkGrid Labs +Apache License 2.0 — © 2025 diff --git a/dashboard/scripts/capture-screenshots.mjs b/dashboard/scripts/capture-screenshots.mjs index 0a7b425..64c3c9f 100644 --- a/dashboard/scripts/capture-screenshots.mjs +++ b/dashboard/scripts/capture-screenshots.mjs @@ -8,6 +8,7 @@ const baseURL = process.env.CHECKGATE_SCREENSHOT_BASE_URL ?? 'http://127.0.0.1:3 const output = (process.env.CHECKGATE_SCREENSHOT_OUTPUT ?? fileURLToPath(new URL('../../assets/screenshots/', import.meta.url))).replace(/\/$/, '') const staging = `${output}.capture` +const setupToken = process.env.CHECKGATE_SCREENSHOT_SETUP_TOKEN ?? 'checkgate-screenshot-demo-setup-token' const workspaceName = 'Vantage Robotics' const projectName = 'Vantage Mobile' const password = 'checkgate-screenshot-demo-password' @@ -44,10 +45,10 @@ try { extraHTTPHeaders: { 'X-Checkgate-Request': 'true' }, }) - async function api(path, method = 'GET', data, request = context.request) { - // Respect the server's 60-request-per-minute API quota while seeding. + async function api(path, method = 'GET', data, request = context.request, headers = {}) { + // Stay well inside the server's per-client API quota while seeding. await new Promise(resolve => setTimeout(resolve, 1050)) - const response = await request.fetch(path, { method, data }) + const response = await request.fetch(path, { method, data, headers }) if (!response.ok()) throw new Error(`${method} ${path}: ${response.status()} ${await response.text()}`) return response.status() === 204 ? undefined : response.json() } @@ -56,7 +57,7 @@ try { if (!workspace.is_setup_complete) { await api('/api/setup/complete', 'POST', { workspace_name: workspaceName, project_name: projectName, ...admin, - }) + }, context.request, { 'X-Checkgate-Setup-Token': setupToken }) } else { assert.equal(workspace.workspace_name, workspaceName, 'Expected the disposable screenshot workspace') await api('/api/auth/login', 'POST', admin) diff --git a/dashboard/src/context/AuthContext.tsx b/dashboard/src/context/AuthContext.tsx index d2f773c..07e8a6b 100644 --- a/dashboard/src/context/AuthContext.tsx +++ b/dashboard/src/context/AuthContext.tsx @@ -41,6 +41,7 @@ interface AuthContextValue { name: string, email: string, password: string, + setupToken: string, ) => Promise<{ ok: boolean; error?: string }> } @@ -167,12 +168,16 @@ export function AuthProvider({ children }: { children: ReactNode }) { }, []) const completeSetup = useCallback( - async (workspaceName: string, projectName: string, name: string, email: string, password: string) => { + async (workspaceName: string, projectName: string, name: string, email: string, password: string, setupToken: string) => { try { const res = await fetch('/api/setup/complete', { method: 'POST', credentials: 'same-origin', - headers: { 'Content-Type': 'application/json', 'X-Checkgate-Request': 'true' }, + headers: { + 'Content-Type': 'application/json', + 'X-Checkgate-Request': 'true', + 'X-Checkgate-Setup-Token': setupToken, + }, body: JSON.stringify({ workspace_name: workspaceName.trim(), project_name: projectName.trim() || 'My App', @@ -185,6 +190,12 @@ export function AuthProvider({ children }: { children: ReactNode }) { if (res.status === 404) { return { ok: false, error: 'Setup already complete. Please log in.' } } + if (res.status === 401) { + return { ok: false, error: 'The setup token is not valid. Go back to the first step and re-enter it.' } + } + if (res.status === 409) { + return { ok: false, error: 'An account with this email already exists. Use a different email.' } + } if (res.status === 422) { return { ok: false, error: 'Password must be at least 8 characters.' } } diff --git a/dashboard/src/pages/SdkHealth.tsx b/dashboard/src/pages/SdkHealth.tsx index 456ef9a..191b14a 100644 --- a/dashboard/src/pages/SdkHealth.tsx +++ b/dashboard/src/pages/SdkHealth.tsx @@ -45,7 +45,12 @@ export default function SdkHealth() {
- Live SSE connections to this server instance. Refreshes every 10 s. + Live SSE connections to this server instance, for the projects you can access. + Refreshes every 10 s. +
++ Each instance tracks its own connections. Client addresses are shown as a network + prefix unless you are a workspace admin.