Skip to content
Merged
Show file tree
Hide file tree
Changes from 6 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
2 changes: 1 addition & 1 deletion .github/ISSUE_TEMPLATE/ops_deploy.yml
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ body:
1. Pull latest main
2. Build frontend/backend
3. Restart service
4. Verify /api/health
4. Verify /api/health/ready
validations:
required: true
- type: checkboxes
Expand Down
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ lerna-debug.log*

node_modules
dist
release-manifest.json
backend/data/
data/
.test-openclaw/
Expand Down
13 changes: 8 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -103,22 +103,25 @@ Production preparation is deliberately separate from ordinary builds:
bun run deploy:prepare
```

This builds both applications and runs the restore-verified SQLite preflight.
Use it before a production restart; plain `build` remains safe for CI and local
verification.
This builds both applications, runs the restore-verified SQLite preflight, and
writes the checksummed release manifest used by production readiness and
activation. Use it before a production restart; plain `build` remains safe for
CI and local verification.

## Runtime notes

- Backend default port: `3100`.
- Frontend dev port: `5173`.
- Health endpoints: `/health` and `/api/health`.
- Health endpoints: public `/api/health/live`, public `/api/health/ready`, and
authenticated `/api/health/diagnostics`.
- Dashboard SQLite uses WAL, numbered checksum-validated migrations,
restrictive storage modes, deploy/maintenance snapshots, and automated
restore checks.
- Frontend builds and the local frontend dev server use Bun's HTML bundler with Babel React Compiler and Bun Tailwind plugins.
- Dev server listens on all addresses so the dashboard can be reached over Tailscale when needed.
- Auth is enforced by the backend request policy for every API route except
`GET|HEAD /api/health`, `GET|HEAD /api/auth/bootstrap`,
`GET|HEAD /api/health/live`, `GET|HEAD /api/health/ready`,
`GET|HEAD /api/auth/bootstrap`,
`POST /api/auth/register-first-user`, `POST /api/auth/login`,
`POST /api/auth/login/totp`, `POST /api/auth/login/recovery`,
`POST /api/auth/login/webauthn/options`,
Expand Down
31 changes: 29 additions & 2 deletions backend/scripts/build.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,26 @@
import { mkdir, rm } from "node:fs/promises";
import { mkdir, rm, writeFile } from "node:fs/promises";
import path from "node:path";

const backendDirectory = path.resolve(import.meta.dirname, "..");
const outdir = path.join(backendDirectory, "dist");
const commitResult = Bun.spawnSync({
cmd: ["git", "-C", backendDirectory, "rev-parse", "HEAD"],
stderr: "pipe",
stdin: "ignore",
stdout: "pipe",
});
const commitSha = new TextDecoder().decode(commitResult.stdout).trim();
Comment thread
mira-2026 marked this conversation as resolved.
Outdated
if (commitResult.exitCode !== 0 || !/^[\da-f]{40}$/u.test(commitSha)) {
throw new Error("Backend build requires a full Git commit identity");
}

await rm(outdir, { force: true, recursive: true });
await mkdir(outdir, { recursive: true });

const result = await Bun.build({
define: {
__BACKEND_BUILD_COMMIT__: JSON.stringify(commitSha),
},
entrypoints: [
path.join(backendDirectory, "src/serverStart.ts"),
path.join(backendDirectory, "src/workerStart.ts"),
Expand All @@ -16,7 +29,7 @@ const result = await Bun.build({
],
format: "esm",
outdir,
packages: "external",
packages: "bundle",
splitting: false,
sourcemap: "external",
target: "bun",
Expand All @@ -25,3 +38,17 @@ const result = await Bun.build({
if (!result.success) {
throw new AggregateError(result.logs, "Backend build failed");
}

await writeFile(
path.join(outdir, "build-identity.json"),
`${JSON.stringify(
{
bunVersion: Bun.version,
commitSha,
component: "backend",
formatVersion: 1,
},
undefined,
2
)}\n`
);
19 changes: 19 additions & 0 deletions backend/src/buildIdentity.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
declare const __BACKEND_BUILD_COMMIT__: string | undefined;

const FULL_COMMIT_SHA_PATTERN = /^[\da-f]{40}$/u;

/**
* Returns the commit embedded by the backend bundler.
*
* Source-mode development and tests intentionally use a non-release identity.
* Production readiness requires a bundled full SHA that matches the manifest.
*/
export function getBackendBuildCommit(): string {
if (
typeof __BACKEND_BUILD_COMMIT__ === "string" &&
FULL_COMMIT_SHA_PATTERN.test(__BACKEND_BUILD_COMMIT__)
) {
return __BACKEND_BUILD_COMMIT__;
}
return "development";
}
38 changes: 28 additions & 10 deletions backend/src/databaseMigrationRunner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {
type DatabaseMigration,
databaseMigrations,
} from "./databaseMigrations/index.ts";
import { DASHBOARD_DATABASE_SCHEMA_COMPATIBILITY } from "./databaseSchemaCompatibility.ts";
import {
createVerifiedSqliteBackup,
pruneSqliteBackups,
Expand Down Expand Up @@ -76,20 +77,37 @@ function appliedMigrationRows(database: Database): AppliedMigrationRow[] {
.all() as AppliedMigrationRow[];
}

export function validateDatabaseMigrationHistory(database: Database): number {
export function validateDatabaseMigrationHistory(
database: Database,
maximumCompatibleVersion: number = DASHBOARD_DATABASE_SCHEMA_COMPATIBILITY.maximum
): number {
assertMigrationRegistry();
if (
!Number.isSafeInteger(maximumCompatibleVersion) ||
maximumCompatibleVersion < databaseMigrations.length
) {
throw new TypeError("Invalid maximum compatible SQLite schema version");
}
const appliedRows = appliedMigrationRows(database);
for (const [index, row] of appliedRows.entries()) {
const expected = databaseMigrations[index];
if (!expected) {
const expectedVersion = index + 1;
if (row.version !== expectedVersion) {
throw new Error(
`Database contains unknown SQLite migration version ${row.version}`
`SQLite migration history is not contiguous at version ${expectedVersion}`
);
}
if (row.version !== expected.version) {
throw new Error(
`SQLite migration history is not contiguous at version ${expected.version}`
);
const expected = databaseMigrations[index];
if (!expected) {
if (
row.version > maximumCompatibleVersion ||
!/^[a-z0-9]+(?:-[a-z0-9]+)*$/u.test(row.name) ||
!/^[\da-f]{64}$/u.test(row.checksum)
) {
throw new Error(
`Database contains incompatible SQLite migration version ${row.version}`
);
}
continue;
}
if (row.name !== expected.name) {
throw new Error(
Expand Down Expand Up @@ -153,7 +171,7 @@ function applyPendingDatabaseMigrations(
createBackup?: () => SqliteBackupResult | undefined
): DatabaseMigrationResult {
const appliedCountBeforeLock = validateDatabaseMigrationHistory(database);
if (appliedCountBeforeLock === databaseMigrations.length) {
if (appliedCountBeforeLock >= databaseMigrations.length) {
return { applied: [] };
}

Expand All @@ -162,7 +180,7 @@ function applyPendingDatabaseMigrations(
database.run("BEGIN IMMEDIATE");
try {
const appliedCount = validateDatabaseMigrationHistory(database);
if (appliedCount === databaseMigrations.length) {
if (appliedCount >= databaseMigrations.length) {
database.run("COMMIT");
return { applied };
}
Expand Down
41 changes: 41 additions & 0 deletions backend/src/databaseSchemaCompatibility.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import { databaseMigrations } from "./databaseMigrations/index.ts";

const CURRENT_DATABASE_SCHEMA_VERSION = databaseMigrations.at(-1)?.version ?? 0;

/**
* Keep this range explicit. An expand migration may widen the maximum before
* the migration ships; a contract migration must narrow it only after the
* previous release has left the rollback window.
*/
export const DASHBOARD_DATABASE_SCHEMA_COMPATIBILITY = Object.freeze({
maximum: 6,
minimum: 6,
target: CURRENT_DATABASE_SCHEMA_VERSION,
});

interface DatabaseSchemaCompatibility {
maximum: number;
minimum: number;
}

if (
DASHBOARD_DATABASE_SCHEMA_COMPATIBILITY.target <
DASHBOARD_DATABASE_SCHEMA_COMPATIBILITY.minimum ||
DASHBOARD_DATABASE_SCHEMA_COMPATIBILITY.target >
DASHBOARD_DATABASE_SCHEMA_COMPATIBILITY.maximum
) {
throw new Error(
"Dashboard database schema target is outside the declared release compatibility range"
);
}

export function isDatabaseSchemaCompatible(
version: number,
compatibility: DatabaseSchemaCompatibility = DASHBOARD_DATABASE_SCHEMA_COMPATIBILITY
): boolean {
return (
Number.isSafeInteger(version) &&
version >= compatibility.minimum &&
version <= compatibility.maximum
);
}
33 changes: 33 additions & 0 deletions backend/src/frontendAssets.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import fs from "node:fs";
import path from "node:path";

import { getProcessReleaseRoot } from "./releaseManifest.ts";

export function resolveFrontendPath(
environment: Record<string, string | undefined> = process.env,
releaseRoot = getProcessReleaseRoot()
): string {
const releaseFrontendPath = path.join(releaseRoot, "dist");
const configuredPath = environment.MIRA_DASHBOARD_FRONTEND_PATH?.trim();
if (!configuredPath) {
return releaseFrontendPath;
}
if (
environment.NODE_ENV === "production" &&
path.resolve(configuredPath) !== path.resolve(releaseFrontendPath)
) {
Comment thread
mira-2026 marked this conversation as resolved.
throw new Error(
"MIRA_DASHBOARD_FRONTEND_PATH cannot override the checksummed release frontend in production"
);
}
return configuredPath;
}

export function isFrontendIndexReady(): boolean {
try {
const indexStat = fs.statSync(path.join(resolveFrontendPath(), "index.html"));
return indexStat.isFile();
} catch {
return false;
}
}
Loading