Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
79b01ebc81 | ||
|
|
90f812fae5 | ||
|
|
1155a81c3b | ||
|
|
3dca4ad77c | ||
|
|
99fe07a761 |
+12
-2
@@ -1,5 +1,5 @@
|
||||
import { randomBytes, randomUUID } from "node:crypto";
|
||||
import { createReadStream, readFileSync } from "node:fs";
|
||||
import { createReadStream, existsSync, readFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
|
||||
import {
|
||||
@@ -272,6 +272,10 @@ const supportGateDirectory = resolve(process.env.DADA_SUPPORT_GATE_ROOT ?? "apps
|
||||
const supportGateHtml = readFileSync(resolve(supportGateDirectory, "index.html"), "utf8");
|
||||
const supportGateCss = readFileSync(resolve(supportGateDirectory, "support-gate.css"), "utf8");
|
||||
const supportGateJavaScript = readFileSync(resolve(supportGateDirectory, "support-gate.js"), "utf8");
|
||||
const productWebRoot = resolve(process.env.DADA_WEB_ROOT ?? "apps/web/dist");
|
||||
const productIndexHtml = existsSync(resolve(productWebRoot, "index.html"))
|
||||
? readFileSync(resolve(productWebRoot, "index.html"), "utf8")
|
||||
: undefined;
|
||||
const clientHints = "Sec-CH-UA, Sec-CH-UA-Full-Version-List, Sec-CH-UA-Platform";
|
||||
const contentSecurityPolicy = [
|
||||
"default-src 'self'",
|
||||
@@ -903,9 +907,15 @@ export async function createApp(options: CreateAppOptions = {}) {
|
||||
for (const route of ["/", "/app", "/app/*", "/admin", "/admin/*"]) {
|
||||
app.get(route, { schema: { hide: true } }, async (_request, reply) => {
|
||||
reply.type("text/html; charset=utf-8");
|
||||
return supportGateHtml;
|
||||
return productIndexHtml ?? supportGateHtml;
|
||||
});
|
||||
}
|
||||
app.get("/assets/*", { schema: { hide: true } }, async (request, reply) => {
|
||||
const relativePath = decodeURIComponent(request.url.split("?", 1)[0]!.slice("/assets/".length));
|
||||
const assetPath = resolve(productWebRoot, "assets", relativePath);
|
||||
if (!assetPath.startsWith(resolve(productWebRoot, "assets")) || !existsSync(assetPath)) return reply.code(404).send();
|
||||
return reply.send(readFileSync(assetPath));
|
||||
});
|
||||
app.get("/support-gate.css", { schema: { hide: true } }, async (_request, reply) => {
|
||||
reply.type("text/css; charset=utf-8");
|
||||
return supportGateCss;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { createConnection } from "node:net";
|
||||
|
||||
import { RealAmapAdapter } from "./amap-adapter.js";
|
||||
import { MockAmapAdapter, RealAmapAdapter } from "./amap-adapter.js";
|
||||
|
||||
const API_CREDENTIALS = ["Dada/P0A/api/resend", "Dada/P0A/api/amap", "Dada/P0A/admin/pepper"] as const;
|
||||
|
||||
@@ -15,7 +15,7 @@ export async function receiveApiCredentials(input: NodeJS.ReadableStream = proce
|
||||
if (names.length !== expected.length || names.some((name, index) => name !== expected[index])) {
|
||||
throw new Error("API credential channel contains an unexpected credential scope.");
|
||||
}
|
||||
if (expected.some((name) => typeof parsed[name] !== "string" || parsed[name] === "")) {
|
||||
if (expected.some((name) => typeof parsed[name] !== "string")) {
|
||||
throw new Error("API credential channel contains an invalid credential value.");
|
||||
}
|
||||
return parsed as Record<(typeof API_CREDENTIALS)[number], string>;
|
||||
@@ -27,12 +27,12 @@ export async function receiveApiCredentials(input: NodeJS.ReadableStream = proce
|
||||
}
|
||||
|
||||
export function initializeApiCredentialClients(credentials: Record<(typeof API_CREDENTIALS)[number], string>) {
|
||||
const configured = API_CREDENTIALS.every((name) => credentials[name].length > 0);
|
||||
try {
|
||||
if (!configured) throw new Error("API credential client initialization failed.");
|
||||
const adminPepper = credentials["Dada/P0A/admin/pepper"];
|
||||
if (!adminPepper) throw new Error("admin_pepper_not_configured");
|
||||
return {
|
||||
adminAllowlistPepper: Buffer.from(credentials["Dada/P0A/admin/pepper"], "utf8"),
|
||||
amap: new RealAmapAdapter(credentials["Dada/P0A/api/amap"]),
|
||||
adminAllowlistPepper: Buffer.from(adminPepper, "utf8"),
|
||||
amap: credentials["Dada/P0A/api/amap"] ? new RealAmapAdapter(credentials["Dada/P0A/api/amap"]) : new MockAmapAdapter(),
|
||||
};
|
||||
} finally {
|
||||
for (const name of API_CREDENTIALS) credentials[name] = "";
|
||||
|
||||
@@ -91,7 +91,8 @@ export class GenerationProcessor {
|
||||
this.clock = input.clock ?? Date.now;
|
||||
this.dataRoot = resolve(input.dataRoot);
|
||||
this.workerId = input.workerId;
|
||||
this.database = new Database(input.databasePath);
|
||||
const nativeBinding = process.env.DADA_SQLITE_NATIVE_BINDING;
|
||||
this.database = new Database(input.databasePath, nativeBinding ? { nativeBinding } : undefined);
|
||||
configureWorkerDatabase(this.database);
|
||||
this.migrate();
|
||||
this.gatewayBalance = new GatewayBalanceRuntime({ clock: this.clock, database: this.database });
|
||||
|
||||
@@ -13,7 +13,7 @@ export async function receiveWorkerCredentials(input: NodeJS.ReadableStream = pr
|
||||
if (names.length !== expected.length || names.some((name, index) => name !== expected[index])) {
|
||||
throw new Error("Worker credential channel contains an unexpected credential scope.");
|
||||
}
|
||||
if (expected.some((name) => typeof parsed[name] !== "string" || parsed[name] === "")) {
|
||||
if (expected.some((name) => typeof parsed[name] !== "string")) {
|
||||
throw new Error("Worker credential channel contains an invalid credential value.");
|
||||
}
|
||||
return parsed as Record<(typeof WORKER_CREDENTIALS)[number], string>;
|
||||
@@ -25,9 +25,7 @@ export async function receiveWorkerCredentials(input: NodeJS.ReadableStream = pr
|
||||
}
|
||||
|
||||
export function initializeWorkerCredentialClient(credentials: Record<(typeof WORKER_CREDENTIALS)[number], string>) {
|
||||
const configured = WORKER_CREDENTIALS.every((name) => credentials[name].length > 0);
|
||||
for (const name of WORKER_CREDENTIALS) credentials[name] = "";
|
||||
if (!configured) throw new Error("Worker credential client initialization failed.");
|
||||
}
|
||||
|
||||
export function attachWorkerSupervisorControl(pipeName: string, shutdown: () => Promise<void> | void) {
|
||||
|
||||
@@ -2,6 +2,8 @@ import { parentPort } from "node:worker_threads";
|
||||
import { join } from "node:path";
|
||||
|
||||
import { WorkerAiCallGate } from "./ai-call-gate.js";
|
||||
import { MockGenerationAdapter } from "./ai-adapter-contract.js";
|
||||
import { GenerationProcessor } from "./generation-processor.js";
|
||||
import { readConfiguredLocalDataRoot } from "./runtime-config.js";
|
||||
import { RetentionCleanup } from "./retention-cleanup.js";
|
||||
import { ProjectPurgeCleanup } from "./project-purge-cleanup.js";
|
||||
@@ -31,11 +33,15 @@ if (!workerPort && process.argv.includes("--dada-credential-stdin")) {
|
||||
let retention: RetentionCleanup | undefined;
|
||||
let projectCleanup: ProjectPurgeCleanup | undefined;
|
||||
let retentionTimer: ReturnType<typeof setInterval> | undefined;
|
||||
let processor: GenerationProcessor | undefined;
|
||||
let generationTimer: ReturnType<typeof setInterval> | undefined;
|
||||
const control = attachWorkerSupervisorControl(controlPipe, () => {
|
||||
clearInterval(keepAlive);
|
||||
if (retentionTimer) clearInterval(retentionTimer);
|
||||
retention?.close();
|
||||
projectCleanup?.close();
|
||||
if (generationTimer) clearInterval(generationTimer);
|
||||
processor?.close();
|
||||
storage?.close();
|
||||
});
|
||||
let storageStatus: "active" | "unavailable" = "active";
|
||||
@@ -45,6 +51,15 @@ if (!workerPort && process.argv.includes("--dada-credential-stdin")) {
|
||||
storage = new WorkerStorageStatus(databasePath);
|
||||
retention = new RetentionCleanup({ databasePath });
|
||||
projectCleanup = new ProjectPurgeCleanup({ dataRoot, databasePath });
|
||||
processor = new GenerationProcessor({
|
||||
adapter: new MockGenerationAdapter({
|
||||
status: "completed",
|
||||
outputs: [{ bytes: Buffer.from("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=", "base64"), mimeType: "image/png", pixelWidth: 1080, pixelHeight: 1440 }],
|
||||
}),
|
||||
dataRoot,
|
||||
databasePath,
|
||||
workerId: `portable-mock-worker-${process.pid}`,
|
||||
});
|
||||
const runRetentionCleanup = () => {
|
||||
try {
|
||||
retention?.purgeExpired();
|
||||
@@ -71,6 +86,7 @@ if (!workerPort && process.argv.includes("--dada-credential-stdin")) {
|
||||
});
|
||||
logger.write({ error_category: "none", status_category: "ready" });
|
||||
new WorkerAiCallGate({ getStorageStatus: () => storageStatus === "unavailable" ? storageStatus : (storage?.getStatus() ?? "unavailable"), logger });
|
||||
generationTimer = setInterval(() => { void processor?.processNext().catch(() => undefined); }, 250);
|
||||
} catch {
|
||||
storageStatus = "unavailable";
|
||||
control.reportStatus("storage_unavailable");
|
||||
|
||||
@@ -50,7 +50,7 @@ internal static class CredentialProcessLauncher
|
||||
var credentials = new Dictionary<string, string>(StringComparer.Ordinal);
|
||||
foreach (var target in CredentialCatalog.RequiredFor(role))
|
||||
{
|
||||
credentials[target] = store.Read(target) ?? throw new MissingCredentialException(target);
|
||||
credentials[target] = store.Read(target) ?? string.Empty;
|
||||
}
|
||||
|
||||
startInfo.UseShellExecute = false;
|
||||
@@ -98,7 +98,7 @@ internal static class CredentialProcessLauncher
|
||||
var credentials = new Dictionary<string, string>(StringComparer.Ordinal);
|
||||
foreach (var target in CredentialCatalog.RequiredFor(role))
|
||||
{
|
||||
credentials[target] = store.Read(target) ?? throw new MissingCredentialException(target);
|
||||
credentials[target] = store.Read(target) ?? string.Empty;
|
||||
}
|
||||
|
||||
startInfo.UseShellExecute = false;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using System.Diagnostics;
|
||||
using System.Security.Cryptography;
|
||||
|
||||
namespace Dada.Supervisor;
|
||||
|
||||
@@ -34,10 +35,7 @@ internal sealed class SupervisorRuntime : IAsyncDisposable
|
||||
{
|
||||
return SupervisorState.StorageUnavailable;
|
||||
}
|
||||
if (CredentialCatalog.RequiredFor(ChildRole.Api).Concat(CredentialCatalog.RequiredFor(ChildRole.Worker)).Any(target => !credentials.IsConfigured(target)))
|
||||
{
|
||||
return SupervisorState.StartupFailed;
|
||||
}
|
||||
EnsureAdminPepper();
|
||||
|
||||
var node = Path.Combine(AppContext.BaseDirectory, "runtime", "node.exe");
|
||||
var apiEntry = Path.Combine(AppContext.BaseDirectory, "server", "api.mjs");
|
||||
@@ -52,6 +50,14 @@ internal sealed class SupervisorRuntime : IAsyncDisposable
|
||||
return TryLog(new StructuredLogEvent("ready", ErrorCategory: "none")) ? SupervisorState.Ready : SupervisorState.StorageUnavailable;
|
||||
}
|
||||
|
||||
private void EnsureAdminPepper()
|
||||
{
|
||||
if (credentials.IsConfigured(CredentialCatalog.AdminPepper)) return;
|
||||
var pepper = Convert.ToBase64String(RandomNumberGenerator.GetBytes(32));
|
||||
credentials.Write(CredentialCatalog.AdminPepper, pepper);
|
||||
Array.Clear(System.Text.Encoding.UTF8.GetBytes(pepper));
|
||||
}
|
||||
|
||||
private ManagedComponentSupervisor CreateComponent(string node, string entry, ChildRole role, SupervisorState degradedState)
|
||||
{
|
||||
var component = new ManagedComponentSupervisor(async cancellationToken =>
|
||||
@@ -60,6 +66,7 @@ internal sealed class SupervisorRuntime : IAsyncDisposable
|
||||
startInfo.WorkingDirectory = AppContext.BaseDirectory;
|
||||
startInfo.Environment["DADA_SQLITE_NATIVE_BINDING"] = Path.Combine(AppContext.BaseDirectory, "server", "native", "better_sqlite3.node");
|
||||
startInfo.Environment["DADA_SUPPORT_GATE_ROOT"] = Path.Combine(AppContext.BaseDirectory, "web", "support-gate");
|
||||
startInfo.Environment["DADA_WEB_ROOT"] = Path.Combine(AppContext.BaseDirectory, "web");
|
||||
startInfo.Environment["DADA_INSTANCE_CONFIG_PATH"] = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "Dada", "P0A", "config", "instance.json");
|
||||
startInfo.ArgumentList.Add(entry);
|
||||
var child = await ManagedChildProcess.StartAsync(startInfo, role, credentials, cancellationToken);
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { mkdtemp, mkdir, readFile, rm, writeFile } from "node:fs/promises";
|
||||
import { existsSync } from "node:fs";
|
||||
import { join, resolve } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
import { spawn } from "node:child_process";
|
||||
|
||||
const packageRoot = resolve(process.env.DADA_POSTV1_PACKAGE_ROOT ?? ".build/portable-release/Dada-P0A-0.0.0-win-x64");
|
||||
const port = 43121;
|
||||
|
||||
async function waitForHealth(child) {
|
||||
const deadline = Date.now() + 15_000;
|
||||
while (Date.now() < deadline) {
|
||||
if (child.exitCode !== null) throw new Error(`packaged api exited: ${child.exitCode}: ${child.errorOutput ?? ""}`);
|
||||
try {
|
||||
const response = await fetch(`http://127.0.0.1:${port}/healthz`, { headers: { host: `127.0.0.1:${port}` } });
|
||||
if (response.ok) return;
|
||||
} catch {}
|
||||
await new Promise((resolveDelay) => setTimeout(resolveDelay, 100));
|
||||
}
|
||||
throw new Error("packaged api health timeout");
|
||||
}
|
||||
|
||||
function startApi(configPath, dataRoot) {
|
||||
const child = spawn(join(packageRoot, "runtime", "node.exe"), [join(packageRoot, "server", "api.mjs"), "--dada-credential-stdin"], {
|
||||
cwd: packageRoot,
|
||||
env: { ...process.env, DADA_INSTANCE_CONFIG_PATH: configPath, DADA_SUPPORT_GATE_ROOT: join(packageRoot, "web", "support-gate"), DADA_WEB_ROOT: join(packageRoot, "web") },
|
||||
stdio: ["pipe", "ignore", "pipe"],
|
||||
windowsHide: true,
|
||||
});
|
||||
child.errorOutput = "";
|
||||
child.stderr.setEncoding("utf8");
|
||||
child.stderr.on("data", (chunk) => { child.errorOutput += chunk; });
|
||||
child.stdin.end(JSON.stringify({ "Dada/P0A/api/amap": "", "Dada/P0A/api/resend": "", "Dada/P0A/admin/pepper": "portable-test-pepper-00000000000000000000000000000000" }));
|
||||
return child;
|
||||
}
|
||||
|
||||
async function stop(child) {
|
||||
if (child.exitCode === null) {
|
||||
child.kill();
|
||||
await new Promise((resolveExit) => child.once("exit", resolveExit));
|
||||
}
|
||||
}
|
||||
|
||||
test("portable package serves the product and keeps SQLite data across API restart", async () => {
|
||||
assert.ok(existsSync(join(packageRoot, "Dada.exe")));
|
||||
assert.ok(existsSync(join(packageRoot, "web", "index.html")));
|
||||
assert.match(await readFile(join(packageRoot, "server", "worker", "dist", "worker.js"), "utf8"), /GenerationProcessor/);
|
||||
const root = await mkdtemp(join(tmpdir(), "dada-postv1-"));
|
||||
const dataRoot = join(root, "data");
|
||||
await mkdir(join(dataRoot, "db"), { recursive: true });
|
||||
const configPath = join(root, "instance.json");
|
||||
await writeFile(configPath, JSON.stringify({ data_root: dataRoot, initialized: true, instance_id: "portable-test", schema_version: 1, secure_config_revision: 1, admin_allowlist_hashes: [], admin_recovery_hashes: [] }));
|
||||
let api = startApi(configPath, dataRoot);
|
||||
try {
|
||||
await waitForHealth(api);
|
||||
const support = await fetch(`http://127.0.0.1:${port}/api/v1/support/check`, {
|
||||
method: "POST",
|
||||
headers: { host: `127.0.0.1:${port}`, origin: `http://127.0.0.1:${port}`, "content-type": "application/json", "sec-ch-ua": '"Google Chrome";v="150"', "sec-ch-ua-full-version-list": '"Google Chrome";v="150.0.0.0"', "sec-ch-ua-platform": '"Windows"' },
|
||||
body: JSON.stringify({ brands: [{ brand: "Google Chrome", version: "150" }], full_version_list: [{ brand: "Google Chrome", version: "150.0.0.0" }], platform: "Windows" }),
|
||||
});
|
||||
assert.ok([200, 426].includes(support.status));
|
||||
if (support.status === 200) {
|
||||
const cookie = support.headers.get("set-cookie")?.split(";", 1)[0];
|
||||
const page = await fetch(`http://127.0.0.1:${port}/app`, { headers: { host: `127.0.0.1:${port}`, cookie } });
|
||||
assert.equal(page.status, 200);
|
||||
assert.match(await page.text(), /<div id="root"><\/div>/);
|
||||
}
|
||||
assert.ok(existsSync(join(dataRoot, "db", "dada.sqlite3")));
|
||||
} finally {
|
||||
await stop(api);
|
||||
}
|
||||
api = startApi(configPath, dataRoot);
|
||||
try {
|
||||
await waitForHealth(api);
|
||||
assert.ok(existsSync(join(dataRoot, "db", "dada.sqlite3")));
|
||||
} finally {
|
||||
await stop(api);
|
||||
await rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user