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
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
27 changes: 25 additions & 2 deletions backend/scripts/build.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,22 @@
import { mkdir, rm } from "node:fs/promises";
import { mkdir, rm, writeFile } from "node:fs/promises";
import path from "node:path";

import { resolveBuildSourceIdentity } from "./buildSourceIdentity.ts";

const backendDirectory = path.resolve(import.meta.dirname, "..");
const outdir = path.join(backendDirectory, "dist");
const commitSha = resolveBuildSourceIdentity(backendDirectory);
if (commitSha === "unknown") {
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 +25,7 @@ const result = await Bun.build({
],
format: "esm",
outdir,
packages: "external",
packages: "bundle",
splitting: false,
sourcemap: "external",
target: "bun",
Expand All @@ -25,3 +34,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`
);
42 changes: 42 additions & 0 deletions backend/scripts/buildSourceIdentity.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
const FULL_GIT_COMMIT_PATTERN = /^[\da-f]{40}$/u;

function gitOutput(repoDirectory: string, arguments_: string[]): string | undefined {
try {
const result = Bun.spawnSync({
cmd: ["git", "-C", repoDirectory, ...arguments_],
stderr: "ignore",
stdin: "ignore",
stdout: "pipe",
});
if (result.exitCode !== 0) {
return undefined;
}
return new TextDecoder().decode(result.stdout).trim();
} catch {
return undefined;
}
}

export function isReleaseBuildCommit(value: string): boolean {
return FULL_GIT_COMMIT_PATTERN.test(value);
}

/**
* Returns a full release commit only for a clean source tree. Dirty builds stay
* usable for local verification but cannot be accepted by release:manifest.
*/
export function resolveBuildSourceIdentity(repoDirectory = process.cwd()): string {
const commit = gitOutput(repoDirectory, ["rev-parse", "HEAD"]);
if (!commit || !isReleaseBuildCommit(commit)) {
return "unknown";
}
const status = gitOutput(repoDirectory, [
"status",
"--porcelain=v1",
"--untracked-files=all",
]);
if (status === undefined) {
return "unknown";
}
return status ? `${commit}-dirty` : commit;
}
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