Compare commits
18
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e9fd15e7b6 | ||
|
|
d9e39702e0 | ||
|
|
7de633d4c9 | ||
|
|
b995662388 | ||
|
|
92434aec17 | ||
|
|
f7cac5dabf | ||
|
|
a7f62adad4 | ||
|
|
b1f143c238 | ||
|
|
99fd3b1802 | ||
|
|
fd0003a804 | ||
|
|
bfdfe44f87 | ||
|
|
cbb7f658a3 | ||
|
|
43d946bb5c | ||
|
|
79b01ebc81 | ||
|
|
90f812fae5 | ||
|
|
1155a81c3b | ||
|
|
3dca4ad77c | ||
|
|
99fe07a761 |
@@ -16,7 +16,7 @@ const forbiddenDiagnosticPatterns = [
|
|||||||
/https?:\/\//i,
|
/https?:\/\//i,
|
||||||
];
|
];
|
||||||
const safePauseReasons = new Set([
|
const safePauseReasons = new Set([
|
||||||
"asset_root_state_missing", "balance_insufficient", "configured_disabled", "contract_blocked",
|
"asset_manifest_invalid", "asset_root_missing", "asset_root_state_missing", "balance_insufficient", "configured_disabled", "contract_blocked",
|
||||||
"contract_unverified", "gateway_balance_insufficient", "gateway_paused", "health_check_failed",
|
"contract_unverified", "gateway_balance_insufficient", "gateway_paused", "health_check_failed",
|
||||||
"model_disabled", "provider_unavailable", "quota_exhausted", "service_state_missing", "unknown",
|
"model_disabled", "provider_unavailable", "quota_exhausted", "service_state_missing", "unknown",
|
||||||
"worker_degraded", "worker_state_missing", "worker_stopped",
|
"worker_degraded", "worker_state_missing", "worker_stopped",
|
||||||
|
|||||||
+82
-4
@@ -1,6 +1,6 @@
|
|||||||
import { randomBytes, randomUUID } from "node:crypto";
|
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 { extname, resolve } from "node:path";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
AccountDeletionCompleteRequestSchema,
|
AccountDeletionCompleteRequestSchema,
|
||||||
@@ -218,6 +218,24 @@ import type { ManagedStorage } from "./managed-storage.js";
|
|||||||
import { PrivateContentError, PrivateContentService } from "./private-content.js";
|
import { PrivateContentError, PrivateContentService } from "./private-content.js";
|
||||||
import { assertSafeAdminDiagnostics, assertSafeAdminServicesStorage } from "./admin-state.js";
|
import { assertSafeAdminDiagnostics, assertSafeAdminServicesStorage } from "./admin-state.js";
|
||||||
|
|
||||||
|
const productAssetContentTypes: Readonly<Record<string, string>> = {
|
||||||
|
".css": "text/css; charset=utf-8",
|
||||||
|
".jpeg": "image/jpeg",
|
||||||
|
".jpg": "image/jpeg",
|
||||||
|
".js": "text/javascript; charset=utf-8",
|
||||||
|
".json": "application/json; charset=utf-8",
|
||||||
|
".mjs": "text/javascript; charset=utf-8",
|
||||||
|
".png": "image/png",
|
||||||
|
".svg": "image/svg+xml",
|
||||||
|
".webp": "image/webp",
|
||||||
|
".woff": "font/woff",
|
||||||
|
".woff2": "font/woff2",
|
||||||
|
};
|
||||||
|
|
||||||
|
function productAssetContentType(path: string) {
|
||||||
|
return productAssetContentTypes[extname(path).toLowerCase()] ?? "application/octet-stream";
|
||||||
|
}
|
||||||
|
|
||||||
const defaultBootstrap: BootstrapResponse = {
|
const defaultBootstrap: BootstrapResponse = {
|
||||||
app_version: "0.0.0",
|
app_version: "0.0.0",
|
||||||
dependencies: [],
|
dependencies: [],
|
||||||
@@ -238,12 +256,14 @@ export interface CreateAppOptions {
|
|||||||
assetReleases?: AssetReleaseReader;
|
assetReleases?: AssetReleaseReader;
|
||||||
bootstrap?: () => BootstrapResponse | Promise<BootstrapResponse>;
|
bootstrap?: () => BootstrapResponse | Promise<BootstrapResponse>;
|
||||||
browserGate?: boolean;
|
browserGate?: boolean;
|
||||||
|
productIndexHtml?: string;
|
||||||
browserSupportRelease?: BrowserSupportRelease;
|
browserSupportRelease?: BrowserSupportRelease;
|
||||||
browserSupportSecret?: Buffer;
|
browserSupportSecret?: Buffer;
|
||||||
credits?: CreditService;
|
credits?: CreditService;
|
||||||
eventHub?: EventHub;
|
eventHub?: EventHub;
|
||||||
generations?: GenerationSubmissionService;
|
generations?: GenerationSubmissionService;
|
||||||
latestExports?: LatestExportService;
|
latestExports?: LatestExportService;
|
||||||
|
localTestAuth?: boolean;
|
||||||
models?: ModelConfigurationService;
|
models?: ModelConfigurationService;
|
||||||
networkBoundary?: NetworkBoundaryOptions;
|
networkBoundary?: NetworkBoundaryOptions;
|
||||||
publicAssets?: PublicAssetResolver;
|
publicAssets?: PublicAssetResolver;
|
||||||
@@ -272,6 +292,10 @@ const supportGateDirectory = resolve(process.env.DADA_SUPPORT_GATE_ROOT ?? "apps
|
|||||||
const supportGateHtml = readFileSync(resolve(supportGateDirectory, "index.html"), "utf8");
|
const supportGateHtml = readFileSync(resolve(supportGateDirectory, "index.html"), "utf8");
|
||||||
const supportGateCss = readFileSync(resolve(supportGateDirectory, "support-gate.css"), "utf8");
|
const supportGateCss = readFileSync(resolve(supportGateDirectory, "support-gate.css"), "utf8");
|
||||||
const supportGateJavaScript = readFileSync(resolve(supportGateDirectory, "support-gate.js"), "utf8");
|
const supportGateJavaScript = readFileSync(resolve(supportGateDirectory, "support-gate.js"), "utf8");
|
||||||
|
const productWebRoot = resolve(process.env.DADA_WEB_ROOT ?? "apps/web/dist");
|
||||||
|
const packagedProductIndexHtml = 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 clientHints = "Sec-CH-UA, Sec-CH-UA-Full-Version-List, Sec-CH-UA-Platform";
|
||||||
const contentSecurityPolicy = [
|
const contentSecurityPolicy = [
|
||||||
"default-src 'self'",
|
"default-src 'self'",
|
||||||
@@ -710,6 +734,7 @@ export async function createApp(options: CreateAppOptions = {}) {
|
|||||||
)
|
)
|
||||||
: undefined);
|
: undefined);
|
||||||
const browserGate = options.browserGate ?? true;
|
const browserGate = options.browserGate ?? true;
|
||||||
|
const productIndexHtml = options.productIndexHtml ?? packagedProductIndexHtml;
|
||||||
const browserSupportSecret = options.browserSupportSecret ?? randomBytes(32);
|
const browserSupportSecret = options.browserSupportSecret ?? randomBytes(32);
|
||||||
const browserSupportRelease = options.browserSupportRelease;
|
const browserSupportRelease = options.browserSupportRelease;
|
||||||
const app = Fastify({
|
const app = Fastify({
|
||||||
@@ -901,11 +926,25 @@ export async function createApp(options: CreateAppOptions = {}) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
for (const route of ["/", "/app", "/app/*", "/admin", "/admin/*"]) {
|
for (const route of ["/", "/app", "/app/*", "/admin", "/admin/*"]) {
|
||||||
app.get(route, { schema: { hide: true } }, async (_request, reply) => {
|
app.get(route, { schema: { hide: true } }, async (request, reply) => {
|
||||||
reply.type("text/html; charset=utf-8");
|
reply.type("text/html; charset=utf-8");
|
||||||
return supportGateHtml;
|
if (!browserGate) return productIndexHtml ?? supportGateHtml;
|
||||||
|
const verified = verifyBrowserSupportCookie({
|
||||||
|
cookieHeader: headerValue(request.headers.cookie),
|
||||||
|
release: browserSupportRelease,
|
||||||
|
secChUa: headerValue(request.headers["sec-ch-ua"]),
|
||||||
|
secret: browserSupportSecret,
|
||||||
|
});
|
||||||
|
return verified.supported && productIndexHtml ? 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();
|
||||||
|
reply.type(productAssetContentType(assetPath));
|
||||||
|
return reply.send(readFileSync(assetPath));
|
||||||
|
});
|
||||||
app.get("/support-gate.css", { schema: { hide: true } }, async (_request, reply) => {
|
app.get("/support-gate.css", { schema: { hide: true } }, async (_request, reply) => {
|
||||||
reply.type("text/css; charset=utf-8");
|
reply.type("text/css; charset=utf-8");
|
||||||
return supportGateCss;
|
return supportGateCss;
|
||||||
@@ -2037,6 +2076,45 @@ export async function createApp(options: CreateAppOptions = {}) {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
if (options.localTestAuth && options.registration) {
|
||||||
|
app.get(
|
||||||
|
"/api/v1/auth/local-test",
|
||||||
|
{ schema: { hide: true } },
|
||||||
|
async () => ({ available: true }),
|
||||||
|
);
|
||||||
|
app.post(
|
||||||
|
"/api/v1/auth/local-test",
|
||||||
|
{ schema: { hide: true } },
|
||||||
|
async (request, reply) => {
|
||||||
|
try {
|
||||||
|
const result = options.registration!.createLocalTestSession();
|
||||||
|
reply.header(
|
||||||
|
"Set-Cookie",
|
||||||
|
`${userSessionCookieName}=${result.sessionToken}; Max-Age=${30 * 24 * 60 * 60}; Path=/; HttpOnly; SameSite=Strict`,
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
audience: result.audience,
|
||||||
|
credits: {
|
||||||
|
available_balance: result.credits.availableBalance,
|
||||||
|
reserved_balance: result.credits.reservedBalance,
|
||||||
|
},
|
||||||
|
session_expires_at: new Date(result.sessionExpiresAt).toISOString(),
|
||||||
|
status: result.status,
|
||||||
|
user: {
|
||||||
|
creator_name: result.user.creatorName,
|
||||||
|
role: result.user.role,
|
||||||
|
social_id: result.user.socialId,
|
||||||
|
status: result.user.status,
|
||||||
|
user_id: result.user.userId,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
return registrationFailure(reply, request.id, error);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
app.post(
|
app.post(
|
||||||
"/api/v1/auth/login/complete",
|
"/api/v1/auth/login/complete",
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ import { dirname, isAbsolute, join, parse, relative, resolve, sep } from "node:p
|
|||||||
const require = createRequire(import.meta.url);
|
const require = createRequire(import.meta.url);
|
||||||
const Database = require("better-sqlite3") as typeof import("better-sqlite3");
|
const Database = require("better-sqlite3") as typeof import("better-sqlite3");
|
||||||
|
|
||||||
const assetIdPattern = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
const assetIdPattern = /^[a-z0-9][a-z0-9_-]{2,119}$/i;
|
||||||
const fixedDirectories = [
|
const fixedDirectories = [
|
||||||
"db",
|
"db",
|
||||||
"content/references",
|
"content/references",
|
||||||
@@ -33,6 +33,12 @@ const fixedDirectories = [
|
|||||||
"logs/supervisor",
|
"logs/supervisor",
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
|
export function ensureLocalDataRuntimeDirectories(dataRoot: string) {
|
||||||
|
for (const directory of fixedDirectories) {
|
||||||
|
mkdirSync(join(resolve(dataRoot), directory), { recursive: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export const DATA_TRANSFER_POLICY = {
|
export const DATA_TRANSFER_POLICY = {
|
||||||
allowed_downloads: ["original_generation", "jpg", "png"],
|
allowed_downloads: ["original_generation", "jpg", "png"],
|
||||||
application_backup: false,
|
application_backup: false,
|
||||||
@@ -76,13 +82,21 @@ export function readConfiguredLocalDataRoot(configFile = defaultInstanceConfigPa
|
|||||||
return resolve(candidate);
|
return resolve(candidate);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function readConfiguredAssetRoot(configFile = defaultInstanceConfigPath()) {
|
||||||
|
const configuration = JSON.parse(readFileSync(configFile, "utf8")) as Record<string, unknown>;
|
||||||
|
if (typeof configuration.asset_root !== "string" || !isAbsolute(configuration.asset_root)) {
|
||||||
|
throw new Error("asset_root_configuration_invalid");
|
||||||
|
}
|
||||||
|
return resolve(configuration.asset_root);
|
||||||
|
}
|
||||||
|
|
||||||
export interface ValidatedReadOnlyAssetRoot {
|
export interface ValidatedReadOnlyAssetRoot {
|
||||||
absolute_root: string;
|
absolute_root: string;
|
||||||
ok: true;
|
ok: true;
|
||||||
root_ref: string;
|
root_ref: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface PublicAssetEntry {
|
export interface PublicAssetEntry {
|
||||||
assetId: string;
|
assetId: string;
|
||||||
mimeType: string;
|
mimeType: string;
|
||||||
relativePath: string;
|
relativePath: string;
|
||||||
@@ -219,9 +233,7 @@ export function initializeLocalDataRoot(input: {
|
|||||||
|
|
||||||
const createdRoot = !existsSync(validation.normalized_path);
|
const createdRoot = !existsSync(validation.normalized_path);
|
||||||
try {
|
try {
|
||||||
for (const directory of fixedDirectories) {
|
ensureLocalDataRuntimeDirectories(validation.normalized_path);
|
||||||
mkdirSync(join(validation.normalized_path, directory), { recursive: true });
|
|
||||||
}
|
|
||||||
openInstanceDatabase(join(validation.normalized_path, "db", "dada.sqlite3"));
|
openInstanceDatabase(join(validation.normalized_path, "db", "dada.sqlite3"));
|
||||||
const configuration: InstanceConfiguration = {
|
const configuration: InstanceConfiguration = {
|
||||||
data_root: validation.normalized_path,
|
data_root: validation.normalized_path,
|
||||||
@@ -313,18 +325,19 @@ export function createPublicAssetResolver(input: {
|
|||||||
const roots = new Map(input.roots.map((root) => [root.root_ref, root.absolute_root]));
|
const roots = new Map(input.roots.map((root) => [root.root_ref, root.absolute_root]));
|
||||||
const entries = new Map<string, PublicAssetEntry>();
|
const entries = new Map<string, PublicAssetEntry>();
|
||||||
for (const entry of input.entries) {
|
for (const entry of input.entries) {
|
||||||
if (!assetIdPattern.test(entry.assetId) || entries.has(entry.assetId)) throw new Error("asset_id_invalid");
|
const key = `${entry.resourceVersion}\u0000${entry.assetId}`;
|
||||||
|
if (!assetIdPattern.test(entry.assetId) || entries.has(key)) throw new Error("asset_id_invalid");
|
||||||
if (!roots.has(entry.rootRef)) throw new Error("asset_root_unvalidated");
|
if (!roots.has(entry.rootRef)) throw new Error("asset_root_unvalidated");
|
||||||
if (!/^[a-z0-9][a-z0-9._-]{0,79}$/i.test(entry.resourceVersion)) throw new Error("resource_version_invalid");
|
if (!/^[a-z0-9][a-z0-9._-]{0,79}$/i.test(entry.resourceVersion)) throw new Error("resource_version_invalid");
|
||||||
if (!/^[a-z0-9][a-z0-9.+-]*\/[a-z0-9][a-z0-9.+-]*$/i.test(entry.mimeType)) throw new Error("mime_type_invalid");
|
if (!/^[a-z0-9][a-z0-9.+-]*\/[a-z0-9][a-z0-9.+-]*$/i.test(entry.mimeType)) throw new Error("mime_type_invalid");
|
||||||
entries.set(entry.assetId, { ...entry });
|
entries.set(key, { ...entry });
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
read(resourceVersion, assetId) {
|
read(resourceVersion, assetId) {
|
||||||
if (!assetIdPattern.test(assetId)) return undefined;
|
if (!assetIdPattern.test(assetId)) return undefined;
|
||||||
const entry = entries.get(assetId);
|
const entry = entries.get(`${resourceVersion}\u0000${assetId}`);
|
||||||
if (!entry || entry.resourceVersion !== resourceVersion) return undefined;
|
if (!entry) return undefined;
|
||||||
const root = roots.get(entry.rootRef);
|
const root = roots.get(entry.rootRef);
|
||||||
if (!root) return undefined;
|
if (!root) return undefined;
|
||||||
let path: string;
|
let path: string;
|
||||||
|
|||||||
+34
-3
@@ -5,7 +5,7 @@ import { registrationNotice } from "@dada/shared-contracts";
|
|||||||
|
|
||||||
import { createApp } from "./app.js";
|
import { createApp } from "./app.js";
|
||||||
import { readBrowserSupportRelease } from "./browser-support.js";
|
import { readBrowserSupportRelease } from "./browser-support.js";
|
||||||
import { defaultInstanceConfigPath, readConfiguredLocalDataRoot } from "./local-data-root.js";
|
import { defaultInstanceConfigPath, ensureLocalDataRuntimeDirectories, readConfiguredLocalDataRoot, type PublicAssetResolver } from "./local-data-root.js";
|
||||||
import { ManagedStorage } from "./managed-storage.js";
|
import { ManagedStorage } from "./managed-storage.js";
|
||||||
import { LatestExportService } from "./latest-exports.js";
|
import { LatestExportService } from "./latest-exports.js";
|
||||||
import { CreditService } from "./credits.js";
|
import { CreditService } from "./credits.js";
|
||||||
@@ -16,10 +16,16 @@ import { MockResendAdapter } from "./resend-adapter.js";
|
|||||||
import { readSecureConfigCandidate } from "./secure-config.js";
|
import { readSecureConfigCandidate } from "./secure-config.js";
|
||||||
import { StructuredJsonlLogger } from "./structured-log.js";
|
import { StructuredJsonlLogger } from "./structured-log.js";
|
||||||
import { attachApiSupervisorControl, initializeApiCredentialClients, receiveApiCredentials } from "./supervisor-channel.js";
|
import { attachApiSupervisorControl, initializeApiCredentialClients, receiveApiCredentials } from "./supervisor-channel.js";
|
||||||
import { ModelConfigurationService } from "./model-configuration.js";
|
import { GenerationSubmissionService } from "./generation-submission.js";
|
||||||
|
import {
|
||||||
|
GenerationModelConfigurationCatalog,
|
||||||
|
ModelConfigurationService,
|
||||||
|
portableRuntimeModelCandidates,
|
||||||
|
} from "./model-configuration.js";
|
||||||
import { MockAmapAdapter, type AmapAdapter } from "./amap-adapter.js";
|
import { MockAmapAdapter, type AmapAdapter } from "./amap-adapter.js";
|
||||||
import { StickerReleaseService } from "./sticker-releases.js";
|
import { StickerReleaseService } from "./sticker-releases.js";
|
||||||
import { createAdminDiagnosticsProvider, createAdminServicesStorageProvider } from "./admin-state.js";
|
import { createAdminDiagnosticsProvider, createAdminServicesStorageProvider } from "./admin-state.js";
|
||||||
|
import { loadConfiguredRuntimeAssets, type RuntimeAssetState } from "./runtime-assets.js";
|
||||||
|
|
||||||
const credentialChannelEnabled = process.argv.includes("--dada-credential-stdin");
|
const credentialChannelEnabled = process.argv.includes("--dada-credential-stdin");
|
||||||
let registration: RegistrationService | undefined;
|
let registration: RegistrationService | undefined;
|
||||||
@@ -28,18 +34,31 @@ let credits: CreditService | undefined;
|
|||||||
let storage: ManagedStorage | undefined;
|
let storage: ManagedStorage | undefined;
|
||||||
let latestExports: LatestExportService | undefined;
|
let latestExports: LatestExportService | undefined;
|
||||||
let models: ModelConfigurationService | undefined;
|
let models: ModelConfigurationService | undefined;
|
||||||
|
let generations: GenerationSubmissionService | undefined;
|
||||||
let recentAssets: RecentAssetService | undefined;
|
let recentAssets: RecentAssetService | undefined;
|
||||||
let stickers: StickerReleaseService | undefined;
|
let stickers: StickerReleaseService | undefined;
|
||||||
|
let publicAssets: PublicAssetResolver | undefined;
|
||||||
|
let assetRootState: RuntimeAssetState | undefined;
|
||||||
let amap: AmapAdapter = new MockAmapAdapter();
|
let amap: AmapAdapter = new MockAmapAdapter();
|
||||||
|
let localTestAuth = false;
|
||||||
const instanceConfigPath = process.env.DADA_INSTANCE_CONFIG_PATH ?? defaultInstanceConfigPath();
|
const instanceConfigPath = process.env.DADA_INSTANCE_CONFIG_PATH ?? defaultInstanceConfigPath();
|
||||||
if (credentialChannelEnabled) {
|
if (credentialChannelEnabled) {
|
||||||
const clients = initializeApiCredentialClients(await receiveApiCredentials());
|
const clients = initializeApiCredentialClients(await receiveApiCredentials());
|
||||||
try {
|
try {
|
||||||
amap = clients.amap;
|
amap = clients.amap;
|
||||||
|
localTestAuth = !clients.resendConfigured;
|
||||||
const derivePepper = (purpose: string) => createHmac("sha256", clients.adminAllowlistPepper)
|
const derivePepper = (purpose: string) => createHmac("sha256", clients.adminAllowlistPepper)
|
||||||
.update(`Dada/P0A/${purpose}/v1`, "utf8")
|
.update(`Dada/P0A/${purpose}/v1`, "utf8")
|
||||||
.digest();
|
.digest();
|
||||||
const dataRoot = readConfiguredLocalDataRoot(instanceConfigPath);
|
const dataRoot = readConfiguredLocalDataRoot(instanceConfigPath);
|
||||||
|
ensureLocalDataRuntimeDirectories(dataRoot);
|
||||||
|
const runtimeAssets = loadConfiguredRuntimeAssets({
|
||||||
|
configFile: instanceConfigPath,
|
||||||
|
dataRoot,
|
||||||
|
trustedManifestPath: resolve("asset-metadata", "manifest.json"),
|
||||||
|
});
|
||||||
|
publicAssets = runtimeAssets.publicAssets;
|
||||||
|
assetRootState = runtimeAssets.state;
|
||||||
const databasePath = join(dataRoot, "db", "dada.sqlite3");
|
const databasePath = join(dataRoot, "db", "dada.sqlite3");
|
||||||
registration = new RegistrationService({
|
registration = new RegistrationService({
|
||||||
adminAllowlistPepper: Buffer.from(clients.adminAllowlistPepper),
|
adminAllowlistPepper: Buffer.from(clients.adminAllowlistPepper),
|
||||||
@@ -55,7 +74,12 @@ if (credentialChannelEnabled) {
|
|||||||
storage = new ManagedStorage({ dataRoot, databasePath });
|
storage = new ManagedStorage({ dataRoot, databasePath });
|
||||||
stickers = new StickerReleaseService({ databasePath, storage });
|
stickers = new StickerReleaseService({ databasePath, storage });
|
||||||
latestExports = new LatestExportService({ databasePath, storage });
|
latestExports = new LatestExportService({ databasePath, storage });
|
||||||
models = new ModelConfigurationService({ database: registration.database });
|
models = new ModelConfigurationService({ database: registration.database, seedCandidates: portableRuntimeModelCandidates });
|
||||||
|
generations = new GenerationSubmissionService({
|
||||||
|
credits,
|
||||||
|
models: new GenerationModelConfigurationCatalog(models),
|
||||||
|
storage,
|
||||||
|
});
|
||||||
recentAssets = new RecentAssetService({ database: registration.database });
|
recentAssets = new RecentAssetService({ database: registration.database });
|
||||||
registration.applySecureConfig(readSecureConfigCandidate(instanceConfigPath));
|
registration.applySecureConfig(readSecureConfigCandidate(instanceConfigPath));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -65,6 +89,8 @@ if (credentialChannelEnabled) {
|
|||||||
stickers = undefined;
|
stickers = undefined;
|
||||||
latestExports?.close();
|
latestExports?.close();
|
||||||
latestExports = undefined;
|
latestExports = undefined;
|
||||||
|
generations?.close();
|
||||||
|
generations = undefined;
|
||||||
storage?.close();
|
storage?.close();
|
||||||
storage = undefined;
|
storage = undefined;
|
||||||
credits?.close();
|
credits?.close();
|
||||||
@@ -85,6 +111,7 @@ const adminServicesStorage = registration
|
|||||||
database: registration.database,
|
database: registration.database,
|
||||||
...(models ? { models } : {}),
|
...(models ? { models } : {}),
|
||||||
...(storage ? { storage } : {}),
|
...(storage ? { storage } : {}),
|
||||||
|
...(assetRootState ? { assetRoot: assetRootState } : {}),
|
||||||
})
|
})
|
||||||
: undefined;
|
: undefined;
|
||||||
const adminDiagnostics = adminServicesStorage
|
const adminDiagnostics = adminServicesStorage
|
||||||
@@ -99,9 +126,12 @@ const app = await createApp({
|
|||||||
amap,
|
amap,
|
||||||
...(browserSupportRelease ? { browserSupportRelease } : {}),
|
...(browserSupportRelease ? { browserSupportRelease } : {}),
|
||||||
...(credits ? { credits } : {}),
|
...(credits ? { credits } : {}),
|
||||||
|
...(generations ? { generations } : {}),
|
||||||
...(latestExports ? { latestExports } : {}),
|
...(latestExports ? { latestExports } : {}),
|
||||||
|
...(registration && localTestAuth ? { localTestAuth: true } : {}),
|
||||||
...(models ? { models } : {}),
|
...(models ? { models } : {}),
|
||||||
...(projects ? { projects } : {}),
|
...(projects ? { projects } : {}),
|
||||||
|
...(publicAssets ? { publicAssets } : {}),
|
||||||
...(registration ? { registration } : {}),
|
...(registration ? { registration } : {}),
|
||||||
...(recentAssets ? { recentAssets } : {}),
|
...(recentAssets ? { recentAssets } : {}),
|
||||||
...(stickers ? { stickers } : {}),
|
...(stickers ? { stickers } : {}),
|
||||||
@@ -121,6 +151,7 @@ if (controlPipeIndex >= 0) {
|
|||||||
await app.close();
|
await app.close();
|
||||||
amap.dispose?.();
|
amap.dispose?.();
|
||||||
latestExports?.close();
|
latestExports?.close();
|
||||||
|
generations?.close();
|
||||||
credits?.close();
|
credits?.close();
|
||||||
projects?.close();
|
projects?.close();
|
||||||
registration?.close();
|
registration?.close();
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ import { randomUUID, createHash } from "node:crypto";
|
|||||||
import type BetterSqlite3 from "better-sqlite3";
|
import type BetterSqlite3 from "better-sqlite3";
|
||||||
|
|
||||||
import { serializeAuditSummary, auditRetentionMilliseconds } from "./audit-policy.js";
|
import { serializeAuditSummary, auditRetentionMilliseconds } from "./audit-policy.js";
|
||||||
|
import type { GenerationModelCatalog, GenerationModelSnapshot } from "./generation-submission.js";
|
||||||
|
import { projectRatios } from "./projects.js";
|
||||||
|
|
||||||
export const modelIds = [
|
export const modelIds = [
|
||||||
"gemini-3.1-flash-image-preview",
|
"gemini-3.1-flash-image-preview",
|
||||||
@@ -59,6 +61,45 @@ export interface ModelConfigurationView {
|
|||||||
models: ModelConfigView[];
|
models: ModelConfigView[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type ReadableModelConfiguration = Pick<ModelConfigurationService, "read">;
|
||||||
|
|
||||||
|
function generationRuntimeReason(reason: ModelRuntimeReason): GenerationModelSnapshot["runtimeAvailability"]["reason"] {
|
||||||
|
if (reason === "gateway_balance_insufficient") return reason;
|
||||||
|
if (reason === "contract_unverified" || reason === "contract_blocked") return "gateway_contract_invalid";
|
||||||
|
if (reason === "available") return null;
|
||||||
|
return "model_disabled";
|
||||||
|
}
|
||||||
|
|
||||||
|
export class GenerationModelConfigurationCatalog implements GenerationModelCatalog {
|
||||||
|
constructor(private readonly models: ReadableModelConfiguration) {}
|
||||||
|
|
||||||
|
readModel(modelId: string): GenerationModelSnapshot | undefined {
|
||||||
|
const configuration = this.models.read();
|
||||||
|
const model = configuration.models.find((entry) => entry.model_id === modelId);
|
||||||
|
if (!model) return undefined;
|
||||||
|
const supportedRatios = projectRatios.filter((ratio) => model.supported_ratios.includes(ratio));
|
||||||
|
return {
|
||||||
|
configSetVersion: configuration.config_set_version,
|
||||||
|
configVersion: model.config_version,
|
||||||
|
contractValidationStatus: model.contract_validation_status === "verified" ? "verified" : "unverified",
|
||||||
|
creditCost: model.credit_cost,
|
||||||
|
enabled: model.enabled,
|
||||||
|
modelId: model.model_id,
|
||||||
|
promptMaxLength: model.prompt_max_length,
|
||||||
|
referenceLimits: {
|
||||||
|
maxFileBytes: model.reference_limits.max_file_bytes,
|
||||||
|
maxFiles: model.reference_limits.max_files,
|
||||||
|
maxTotalBytes: model.reference_limits.max_total_bytes,
|
||||||
|
},
|
||||||
|
runtimeAvailability: {
|
||||||
|
availableForNewJobs: model.runtime_availability.available_for_new_jobs,
|
||||||
|
reason: generationRuntimeReason(model.runtime_availability.reason),
|
||||||
|
},
|
||||||
|
supportedRatios,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export class ModelConfigurationError extends Error {
|
export class ModelConfigurationError extends Error {
|
||||||
constructor(
|
constructor(
|
||||||
readonly code:
|
readonly code:
|
||||||
@@ -111,7 +152,7 @@ const defaultErrorMapping: Record<string, string> = {
|
|||||||
upstream_timeout: "upstream_timeout",
|
upstream_timeout: "upstream_timeout",
|
||||||
};
|
};
|
||||||
|
|
||||||
const seedCandidates: ModelConfigCandidate[] = [
|
const defaultSeedCandidates: ModelConfigCandidate[] = [
|
||||||
{
|
{
|
||||||
model_id: modelIds[0], display_name: "Gemini 3.1 Flash Image Preview", enabled: true, is_default: true,
|
model_id: modelIds[0], display_name: "Gemini 3.1 Flash Image Preview", enabled: true, is_default: true,
|
||||||
recommendation_priority: 1, route_profile: { endpoint: "https://mock.invalid/v1/images", mode: "sync" },
|
recommendation_priority: 1, route_profile: { endpoint: "https://mock.invalid/v1/images", mode: "sync" },
|
||||||
@@ -138,6 +179,42 @@ const seedCandidates: ModelConfigCandidate[] = [
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
export const portableRuntimeModelCandidates: ModelConfigCandidate[] = [
|
||||||
|
{
|
||||||
|
...defaultSeedCandidates[0]!,
|
||||||
|
display_name: "Gemini 3.1 Flash Image",
|
||||||
|
route_profile: {
|
||||||
|
endpoint: "https://oneapi.intelligrow.cn/v1/chat/completions",
|
||||||
|
mode: "sync",
|
||||||
|
protocol_version: "gemini-openai-chat-v1",
|
||||||
|
provider_model_id: "gemini-3.1-flash-image",
|
||||||
|
},
|
||||||
|
gateway_account_ref: "oneapi-intelligrow-test",
|
||||||
|
contract_validation_status: "verified",
|
||||||
|
contract_evidence_ref: "contract:wp7-02:gemini-3.1-flash-image:v7",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
...defaultSeedCandidates[1]!,
|
||||||
|
enabled: false,
|
||||||
|
route_profile: { endpoint: "https://oneapi.intelligrow.cn/unsupported", mode: "disabled" },
|
||||||
|
gateway_account_ref: "oneapi-intelligrow-test",
|
||||||
|
contract_validation_status: "unverified",
|
||||||
|
contract_evidence_ref: null,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
...defaultSeedCandidates[2]!,
|
||||||
|
route_profile: {
|
||||||
|
endpoint: "https://oneapi.intelligrow.cn/v1/images/generations",
|
||||||
|
mode: "sync",
|
||||||
|
protocol_version: "openai-images-v1",
|
||||||
|
reference_endpoint: "https://oneapi.intelligrow.cn/v1/images/edits",
|
||||||
|
},
|
||||||
|
gateway_account_ref: "oneapi-intelligrow-test",
|
||||||
|
contract_validation_status: "verified",
|
||||||
|
contract_evidence_ref: "contract:wp7-02:gpt-image-2:v2",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
function stableJson(value: unknown): string {
|
function stableJson(value: unknown): string {
|
||||||
if (Array.isArray(value)) return `[${value.map(stableJson).join(",")}]`;
|
if (Array.isArray(value)) return `[${value.map(stableJson).join(",")}]`;
|
||||||
if (value && typeof value === "object") {
|
if (value && typeof value === "object") {
|
||||||
@@ -207,17 +284,20 @@ export interface ModelConfigurationServiceOptions {
|
|||||||
clock?: () => number;
|
clock?: () => number;
|
||||||
database: BetterSqlite3.Database;
|
database: BetterSqlite3.Database;
|
||||||
onChanged?: (configSetVersion: number) => void;
|
onChanged?: (configSetVersion: number) => void;
|
||||||
|
seedCandidates?: ModelConfigCandidate[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export class ModelConfigurationService {
|
export class ModelConfigurationService {
|
||||||
readonly database: BetterSqlite3.Database;
|
readonly database: BetterSqlite3.Database;
|
||||||
readonly #clock: () => number;
|
readonly #clock: () => number;
|
||||||
readonly #onChanged: ((configSetVersion: number) => void) | undefined;
|
readonly #onChanged: ((configSetVersion: number) => void) | undefined;
|
||||||
|
readonly #seedCandidates: ModelConfigCandidate[];
|
||||||
|
|
||||||
constructor(options: ModelConfigurationServiceOptions) {
|
constructor(options: ModelConfigurationServiceOptions) {
|
||||||
this.database = options.database;
|
this.database = options.database;
|
||||||
this.#clock = options.clock ?? Date.now;
|
this.#clock = options.clock ?? Date.now;
|
||||||
this.#onChanged = options.onChanged;
|
this.#onChanged = options.onChanged;
|
||||||
|
this.#seedCandidates = structuredClone(options.seedCandidates ?? defaultSeedCandidates);
|
||||||
this.ensureSchema();
|
this.ensureSchema();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -503,7 +583,7 @@ export class ModelConfigurationService {
|
|||||||
const current = this.database.prepare("SELECT config_set_id FROM model_config_current WHERE singleton = 1").get() as { config_set_id: string } | undefined;
|
const current = this.database.prepare("SELECT config_set_id FROM model_config_current WHERE singleton = 1").get() as { config_set_id: string } | undefined;
|
||||||
if (current) return;
|
if (current) return;
|
||||||
const seed = this.database.transaction(() => {
|
const seed = this.database.transaction(() => {
|
||||||
validateModelConfigurationCandidateSet(seedCandidates);
|
validateModelConfigurationCandidateSet(this.#seedCandidates);
|
||||||
const now = this.#clock();
|
const now = this.#clock();
|
||||||
const setId = randomUUID();
|
const setId = randomUUID();
|
||||||
this.database.prepare("INSERT INTO model_config_sets (config_set_id, config_set_version, created_at, created_by) VALUES (?, 1, ?, 'system_seed')")
|
this.database.prepare("INSERT INTO model_config_sets (config_set_id, config_set_version, created_at, created_by) VALUES (?, 1, ?, 'system_seed')")
|
||||||
@@ -520,7 +600,9 @@ export class ModelConfigurationService {
|
|||||||
INSERT INTO model_config_set_members (config_set_id, model_id, config_version, enabled, is_default, recommendation_priority)
|
INSERT INTO model_config_set_members (config_set_id, model_id, config_version, enabled, is_default, recommendation_priority)
|
||||||
VALUES (?, ?, 1, ?, ?, ?)
|
VALUES (?, ?, 1, ?, ?, ?)
|
||||||
`);
|
`);
|
||||||
for (const candidate of seedCandidates) {
|
for (const candidate of this.#seedCandidates) {
|
||||||
|
const contractStatus = candidate.contract_validation_status ?? "unverified";
|
||||||
|
const contractEvidenceRef = contractStatus === "unverified" ? null : candidate.contract_evidence_ref ?? null;
|
||||||
const routeProfileId = profileRef("route", candidate.route_profile);
|
const routeProfileId = profileRef("route", candidate.route_profile);
|
||||||
const errorMappingProfileId = profileRef("error", candidate.error_mapping_profile);
|
const errorMappingProfileId = profileRef("error", candidate.error_mapping_profile);
|
||||||
this.database.prepare("INSERT OR IGNORE INTO gateway_route_profiles (route_profile_id, profile_json, created_at) VALUES (?, ?, ?)")
|
this.database.prepare("INSERT OR IGNORE INTO gateway_route_profiles (route_profile_id, profile_json, created_at) VALUES (?, ?, ?)")
|
||||||
@@ -530,12 +612,14 @@ export class ModelConfigurationService {
|
|||||||
insertVersion.run(candidate.model_id, candidate.display_name, candidate.enabled ? 1 : 0, candidate.is_default ? 1 : 0,
|
insertVersion.run(candidate.model_id, candidate.display_name, candidate.enabled ? 1 : 0, candidate.is_default ? 1 : 0,
|
||||||
candidate.recommendation_priority, routeProfileId, stableJson(candidate.route_profile), candidate.gateway_account_ref,
|
candidate.recommendation_priority, routeProfileId, stableJson(candidate.route_profile), candidate.gateway_account_ref,
|
||||||
errorMappingProfileId, stableJson(candidate.error_mapping_profile), candidate.credit_cost, stableJson(candidate.supported_ratios), stableJson(candidate.reference_limits),
|
errorMappingProfileId, stableJson(candidate.error_mapping_profile), candidate.credit_cost, stableJson(candidate.supported_ratios), stableJson(candidate.reference_limits),
|
||||||
candidate.prompt_max_length, candidate.safety_source, "unverified", null, fingerprint(candidate), now);
|
candidate.prompt_max_length, candidate.safety_source, contractStatus, contractEvidenceRef, fingerprint(candidate), now);
|
||||||
insertMember.run(setId, candidate.model_id, candidate.enabled ? 1 : 0, candidate.is_default ? 1 : 0, candidate.recommendation_priority);
|
insertMember.run(setId, candidate.model_id, candidate.enabled ? 1 : 0, candidate.is_default ? 1 : 0, candidate.recommendation_priority);
|
||||||
|
const available = candidate.enabled && contractStatus === "verified";
|
||||||
|
const runtimeReason = !candidate.enabled ? "configured_disabled" : available ? "available" : "contract_unverified";
|
||||||
this.database.prepare(`
|
this.database.prepare(`
|
||||||
INSERT INTO model_runtime_availability (model_id, available_for_new_jobs, reason, checked_at, runtime_availability_version)
|
INSERT INTO model_runtime_availability (model_id, available_for_new_jobs, reason, checked_at, runtime_availability_version)
|
||||||
VALUES (?, 0, 'contract_unverified', ?, 0)
|
VALUES (?, ?, ?, ?, 0)
|
||||||
`).run(candidate.model_id, now);
|
`).run(candidate.model_id, available ? 1 : 0, runtimeReason, now);
|
||||||
}
|
}
|
||||||
this.database.prepare("INSERT INTO model_config_current (singleton, config_set_id) VALUES (1, ?)").run(setId);
|
this.database.prepare("INSERT INTO model_config_current (singleton, config_set_id) VALUES (1, ?)").run(setId);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ export interface RegistrationTransactionEvent {
|
|||||||
| "registration_send"
|
| "registration_send"
|
||||||
| "registration_complete"
|
| "registration_complete"
|
||||||
| "registration_send_compensation"
|
| "registration_send_compensation"
|
||||||
|
| "local_test_session"
|
||||||
| "login_send"
|
| "login_send"
|
||||||
| "login_complete"
|
| "login_complete"
|
||||||
| "admin_login_send"
|
| "admin_login_send"
|
||||||
@@ -654,6 +655,60 @@ export class RegistrationService {
|
|||||||
return outcome;
|
return outcome;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
createLocalTestSession(): LoginCompleteResult {
|
||||||
|
const now = this.options.clock();
|
||||||
|
return this.runImmediate("local_test_session", () => {
|
||||||
|
const registrationId = "local-test-user-v1";
|
||||||
|
const existing = this.database.prepare(`
|
||||||
|
SELECT user_id, role, status FROM users WHERE registration_id = ?
|
||||||
|
`).get(registrationId) as {
|
||||||
|
role: "user" | "super_admin";
|
||||||
|
status: "active" | "suspended" | "deleted";
|
||||||
|
user_id: string;
|
||||||
|
} | undefined;
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
if (existing.role !== "user" || existing.status !== "active") {
|
||||||
|
throw new RegistrationError("AUTH_ENTRY_REJECTED", "account_suspended");
|
||||||
|
}
|
||||||
|
const session = this.insertSession(existing.user_id, "user", now);
|
||||||
|
return {
|
||||||
|
outcome: "committed",
|
||||||
|
value: this.loginResult(this.readCompletedRegistration(existing.user_id, session.sessionId)),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const userId = randomUUID();
|
||||||
|
this.database.prepare(`
|
||||||
|
INSERT INTO users (
|
||||||
|
user_id, normalized_email, role, status, counts_toward_stage_limit,
|
||||||
|
registration_id, created_at
|
||||||
|
) VALUES (?, 'local-test-user@dada.invalid', 'user', 'active', 0, ?, ?)
|
||||||
|
`).run(userId, registrationId, now);
|
||||||
|
this.database.prepare(`
|
||||||
|
INSERT INTO user_profiles (
|
||||||
|
user_id, creator_name, social_id, private_content_notice_version,
|
||||||
|
private_content_notice_acknowledged_at
|
||||||
|
) VALUES (?, '本机测试用户', '@dada_local_test', NULL, NULL)
|
||||||
|
`).run(userId);
|
||||||
|
this.database.prepare(`
|
||||||
|
INSERT INTO credit_accounts (user_id, available_balance, reserved_balance, updated_at)
|
||||||
|
VALUES (?, 10, 0, ?)
|
||||||
|
`).run(userId, now);
|
||||||
|
this.database.prepare(`
|
||||||
|
INSERT INTO credit_ledger (
|
||||||
|
ledger_id, user_id, operation_key, entry_type, amount,
|
||||||
|
available_before, available_after, reserved_before, reserved_after, created_at
|
||||||
|
) VALUES (?, ?, 'local-test-registration:v1', 'registration_grant', 10, 0, 10, 0, 0, ?)
|
||||||
|
`).run(randomUUID(), userId, now);
|
||||||
|
const session = this.insertSession(userId, "user", now);
|
||||||
|
return {
|
||||||
|
outcome: "committed",
|
||||||
|
value: this.loginResult(this.readCompletedRegistration(userId, session.sessionId)),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
applySecureConfig(candidate: SecureConfigCandidate) {
|
applySecureConfig(candidate: SecureConfigCandidate) {
|
||||||
const now = this.options.clock();
|
const now = this.options.clock();
|
||||||
const fail = (reason: string): never => {
|
const fail = (reason: string): never => {
|
||||||
|
|||||||
@@ -0,0 +1,93 @@
|
|||||||
|
import { createHash } from "node:crypto";
|
||||||
|
import { existsSync, readFileSync } from "node:fs";
|
||||||
|
|
||||||
|
import {
|
||||||
|
createPublicAssetResolver,
|
||||||
|
readConfiguredAssetRoot,
|
||||||
|
validateReadOnlyAssetRoot,
|
||||||
|
type PublicAssetEntry,
|
||||||
|
type PublicAssetResolver,
|
||||||
|
} from "./local-data-root.js";
|
||||||
|
|
||||||
|
const rootRef = "p0a_runtime_assets";
|
||||||
|
const schemaVersion = "DadaRuntimeAssets/v1";
|
||||||
|
const assetIdPattern = /^[a-z0-9][a-z0-9_-]{2,119}$/i;
|
||||||
|
const releasePattern = /^[a-z0-9][a-z0-9._-]{0,79}$/i;
|
||||||
|
const shaPattern = /^[a-f0-9]{64}$/i;
|
||||||
|
|
||||||
|
export interface RuntimeAssetState {
|
||||||
|
checked_at: string;
|
||||||
|
configured: boolean;
|
||||||
|
pause_reason: "asset_manifest_invalid" | "asset_root_missing" | "asset_root_state_missing" | null;
|
||||||
|
status: "active" | "unavailable";
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LoadedRuntimeAssets {
|
||||||
|
publicAssets?: PublicAssetResolver;
|
||||||
|
state: RuntimeAssetState;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseRuntimeManifest(bytes: Buffer): PublicAssetEntry[] {
|
||||||
|
const value = JSON.parse(bytes.toString("utf8")) as Record<string, unknown>;
|
||||||
|
if (value.schema_version !== schemaVersion || value.source !== "external_read_only" || value.root_ref !== rootRef) {
|
||||||
|
throw new Error("runtime_asset_manifest_invalid");
|
||||||
|
}
|
||||||
|
if (!Array.isArray(value.entries) || value.entries.length === 0) throw new Error("runtime_asset_manifest_invalid");
|
||||||
|
return value.entries.map((candidate) => {
|
||||||
|
if (!candidate || typeof candidate !== "object" || Array.isArray(candidate)) throw new Error("runtime_asset_manifest_invalid");
|
||||||
|
const entry = candidate as Record<string, unknown>;
|
||||||
|
if (
|
||||||
|
typeof entry.assetId !== "string" || !assetIdPattern.test(entry.assetId)
|
||||||
|
|| typeof entry.mimeType !== "string" || !/^[a-z0-9][a-z0-9.+-]*\/[a-z0-9][a-z0-9.+-]*$/i.test(entry.mimeType)
|
||||||
|
|| typeof entry.relativePath !== "string" || entry.relativePath.includes("\\") || entry.relativePath.split("/").includes("..")
|
||||||
|
|| typeof entry.resourceVersion !== "string" || !releasePattern.test(entry.resourceVersion)
|
||||||
|
|| entry.rootRef !== rootRef
|
||||||
|
|| typeof entry.sha256 !== "string" || !shaPattern.test(entry.sha256)
|
||||||
|
) throw new Error("runtime_asset_manifest_invalid");
|
||||||
|
return entry as unknown as PublicAssetEntry;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function unavailable(
|
||||||
|
configured: boolean,
|
||||||
|
pauseReason: Exclude<RuntimeAssetState["pause_reason"], null>,
|
||||||
|
checkedAt: string,
|
||||||
|
): LoadedRuntimeAssets {
|
||||||
|
return { state: { checked_at: checkedAt, configured, pause_reason: pauseReason, status: "unavailable" } };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function loadConfiguredRuntimeAssets(input: {
|
||||||
|
configFile: string;
|
||||||
|
dataRoot: string;
|
||||||
|
trustedManifestPath: string;
|
||||||
|
clock?: () => number;
|
||||||
|
}): LoadedRuntimeAssets {
|
||||||
|
const checkedAt = new Date((input.clock ?? Date.now)()).toISOString();
|
||||||
|
let assetRoot: string;
|
||||||
|
try {
|
||||||
|
assetRoot = readConfiguredAssetRoot(input.configFile);
|
||||||
|
} catch {
|
||||||
|
return unavailable(false, "asset_root_state_missing", checkedAt);
|
||||||
|
}
|
||||||
|
if (!existsSync(input.trustedManifestPath)) return unavailable(true, "asset_manifest_invalid", checkedAt);
|
||||||
|
try {
|
||||||
|
const trustedBytes = readFileSync(input.trustedManifestPath);
|
||||||
|
const entries = parseRuntimeManifest(trustedBytes);
|
||||||
|
const validatedRoot = validateReadOnlyAssetRoot({
|
||||||
|
dataRoot: input.dataRoot,
|
||||||
|
expectedSha256: createHash("sha256").update(trustedBytes).digest("hex"),
|
||||||
|
manifestRelativePath: "manifest.json",
|
||||||
|
root: assetRoot,
|
||||||
|
rootRef,
|
||||||
|
});
|
||||||
|
if (!validatedRoot.ok) {
|
||||||
|
return unavailable(true, validatedRoot.reason === "asset_root_missing" ? "asset_root_missing" : "asset_manifest_invalid", checkedAt);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
publicAssets: createPublicAssetResolver({ entries, roots: [validatedRoot] }),
|
||||||
|
state: { checked_at: checkedAt, configured: true, pause_reason: null, status: "active" },
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
return unavailable(true, "asset_manifest_invalid", checkedAt);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { createConnection } from "node:net";
|
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;
|
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])) {
|
if (names.length !== expected.length || names.some((name, index) => name !== expected[index])) {
|
||||||
throw new Error("API credential channel contains an unexpected credential scope.");
|
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.");
|
throw new Error("API credential channel contains an invalid credential value.");
|
||||||
}
|
}
|
||||||
return parsed as Record<(typeof API_CREDENTIALS)[number], string>;
|
return parsed as Record<(typeof API_CREDENTIALS)[number], string>;
|
||||||
@@ -27,12 +27,13 @@ export async function receiveApiCredentials(input: NodeJS.ReadableStream = proce
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function initializeApiCredentialClients(credentials: Record<(typeof API_CREDENTIALS)[number], string>) {
|
export function initializeApiCredentialClients(credentials: Record<(typeof API_CREDENTIALS)[number], string>) {
|
||||||
const configured = API_CREDENTIALS.every((name) => credentials[name].length > 0);
|
|
||||||
try {
|
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 {
|
return {
|
||||||
adminAllowlistPepper: Buffer.from(credentials["Dada/P0A/admin/pepper"], "utf8"),
|
adminAllowlistPepper: Buffer.from(adminPepper, "utf8"),
|
||||||
amap: new RealAmapAdapter(credentials["Dada/P0A/api/amap"]),
|
amap: credentials["Dada/P0A/api/amap"] ? new RealAmapAdapter(credentials["Dada/P0A/api/amap"]) : new MockAmapAdapter(),
|
||||||
|
resendConfigured: Boolean(credentials["Dada/P0A/api/resend"]),
|
||||||
};
|
};
|
||||||
} finally {
|
} finally {
|
||||||
for (const name of API_CREDENTIALS) credentials[name] = "";
|
for (const name of API_CREDENTIALS) credentials[name] = "";
|
||||||
|
|||||||
@@ -124,6 +124,25 @@ button {
|
|||||||
margin-inline: auto;
|
margin-inline: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.auth-test-entry {
|
||||||
|
margin-bottom: 18px;
|
||||||
|
border-bottom: 1px solid #b4b4af;
|
||||||
|
padding-bottom: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-test-entry .auth-primary {
|
||||||
|
margin-top: 0;
|
||||||
|
border-color: #111111;
|
||||||
|
background: #111111;
|
||||||
|
color: #f2f500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-test-entry .auth-primary:disabled {
|
||||||
|
border-color: #777773;
|
||||||
|
background: #deded9;
|
||||||
|
color: #777773;
|
||||||
|
}
|
||||||
|
|
||||||
.auth-tabs {
|
.auth-tabs {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 1fr 1fr;
|
grid-template-columns: 1fr 1fr;
|
||||||
|
|||||||
@@ -77,6 +77,9 @@ export function UserAuthPage() {
|
|||||||
const [sendState, setSendState] = useState<SendState>("idle");
|
const [sendState, setSendState] = useState<SendState>("idle");
|
||||||
const [countdown, setCountdown] = useState(0);
|
const [countdown, setCountdown] = useState(0);
|
||||||
const [error, setError] = useState<string>();
|
const [error, setError] = useState<string>();
|
||||||
|
const [localTestAvailable, setLocalTestAvailable] = useState(false);
|
||||||
|
const [localTestError, setLocalTestError] = useState<string>();
|
||||||
|
const [localTestSubmitting, setLocalTestSubmitting] = useState(false);
|
||||||
const [submitting, setSubmitting] = useState(false);
|
const [submitting, setSubmitting] = useState(false);
|
||||||
const emailValid = /^[^@\s]+@[^@\s]+$/.test(email);
|
const emailValid = /^[^@\s]+@[^@\s]+$/.test(email);
|
||||||
const registrationReady = Boolean(
|
const registrationReady = Boolean(
|
||||||
@@ -93,6 +96,18 @@ export function UserAuthPage() {
|
|||||||
return () => window.clearInterval(timer);
|
return () => window.clearInterval(timer);
|
||||||
}, [countdown]);
|
}, [countdown]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const controller = new AbortController();
|
||||||
|
void fetch("/api/v1/auth/local-test", { credentials: "same-origin", signal: controller.signal })
|
||||||
|
.then(async (response) => {
|
||||||
|
if (!response.ok || !response.headers.get("content-type")?.includes("application/json")) return;
|
||||||
|
const body = await response.json() as { available?: boolean };
|
||||||
|
if (body.available === true) setLocalTestAvailable(true);
|
||||||
|
})
|
||||||
|
.catch(() => undefined);
|
||||||
|
return () => controller.abort();
|
||||||
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!noticeOpen) return;
|
if (!noticeOpen) return;
|
||||||
const previousOverflow = document.body.style.overflow;
|
const previousOverflow = document.body.style.overflow;
|
||||||
@@ -255,6 +270,27 @@ export function UserAuthPage() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function enterLocalTest() {
|
||||||
|
if (localTestSubmitting) return;
|
||||||
|
setLocalTestSubmitting(true);
|
||||||
|
setLocalTestError(undefined);
|
||||||
|
try {
|
||||||
|
const response = await fetch("/api/v1/auth/local-test", {
|
||||||
|
credentials: "same-origin",
|
||||||
|
method: "POST",
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
setLocalTestError("本机测试会话未能建立,请重试。");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
window.location.assign("/app");
|
||||||
|
} catch {
|
||||||
|
setLocalTestError("本机测试会话未能建立,请重试。");
|
||||||
|
} finally {
|
||||||
|
setLocalTestSubmitting(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<main className="auth-page">
|
<main className="auth-page">
|
||||||
@@ -271,6 +307,14 @@ export function UserAuthPage() {
|
|||||||
<section className="auth-content">
|
<section className="auth-content">
|
||||||
<a className="auth-admin-link" href="/admin/login">管理员登录</a>
|
<a className="auth-admin-link" href="/admin/login">管理员登录</a>
|
||||||
<div className="auth-panel">
|
<div className="auth-panel">
|
||||||
|
{localTestAvailable ? (
|
||||||
|
<div className="auth-test-entry">
|
||||||
|
<button className="auth-primary" disabled={localTestSubmitting} onClick={enterLocalTest} type="button">
|
||||||
|
{localTestSubmitting ? "正在进入" : "直接进入本机测试"}
|
||||||
|
</button>
|
||||||
|
{localTestError ? <p className="auth-error" role="alert">{localTestError}</p> : null}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
<div className="auth-tabs" role="tablist" aria-label="认证方式">
|
<div className="auth-tabs" role="tablist" aria-label="认证方式">
|
||||||
<button
|
<button
|
||||||
aria-selected={mode === "login"}
|
aria-selected={mode === "login"}
|
||||||
|
|||||||
@@ -69,6 +69,7 @@ async function checkSupport() {
|
|||||||
browserValue.textContent = `${result.browser.brand} ${result.browser.major}`;
|
browserValue.textContent = `${result.browser.brand} ${result.browser.major}`;
|
||||||
supportedValue.textContent = supportedLabel(result.supported_browsers);
|
supportedValue.textContent = supportedLabel(result.supported_browsers);
|
||||||
window.dispatchEvent(new CustomEvent("dada:support-ready"));
|
window.dispatchEvent(new CustomEvent("dada:support-ready"));
|
||||||
|
window.location.replace(window.location.pathname.startsWith("/admin") ? "/admin" : "/app");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
showBlocked(
|
showBlocked(
|
||||||
|
|||||||
@@ -7,6 +7,11 @@ export interface GenerationAdapterRequest {
|
|||||||
prompt: string;
|
prompt: string;
|
||||||
ratio: "3:4" | "1:1" | "4:3" | "9:16";
|
ratio: "3:4" | "1:1" | "4:3" | "9:16";
|
||||||
referenceAssetIds: readonly string[];
|
referenceAssetIds: readonly string[];
|
||||||
|
referenceImages?: readonly {
|
||||||
|
assetId: string;
|
||||||
|
bytes: Buffer;
|
||||||
|
mimeType: "image/jpeg" | "image/png" | "image/webp";
|
||||||
|
}[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface NormalizedGenerationOutput {
|
export interface NormalizedGenerationOutput {
|
||||||
@@ -27,6 +32,7 @@ export type GenerationAdapterResult =
|
|||||||
};
|
};
|
||||||
|
|
||||||
export interface GenerationAdapter {
|
export interface GenerationAdapter {
|
||||||
|
dispose?(): void;
|
||||||
start(request: GenerationAdapterRequest): Promise<GenerationAdapterResult>;
|
start(request: GenerationAdapterRequest): Promise<GenerationAdapterResult>;
|
||||||
poll?(upstreamJobReference: string): Promise<GenerationAdapterResult>;
|
poll?(upstreamJobReference: string): Promise<GenerationAdapterResult>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import type { GenerationAdapter } from "./ai-adapter-contract.js";
|
||||||
|
|
||||||
|
export type AiRuntimeProbeResult =
|
||||||
|
| {
|
||||||
|
code: "ai_probe_passed";
|
||||||
|
mime_type: "image/jpeg" | "image/png" | "image/webp";
|
||||||
|
pixel_height: number;
|
||||||
|
pixel_width: number;
|
||||||
|
real_calls: 1;
|
||||||
|
success: true;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
code: "ai_probe_failed";
|
||||||
|
error_category: string;
|
||||||
|
real_calls: 1;
|
||||||
|
success: false;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function runAiRuntimeProbe(adapter: GenerationAdapter): Promise<AiRuntimeProbeResult> {
|
||||||
|
const result = await adapter.start({
|
||||||
|
configSnapshot: { probe: true },
|
||||||
|
generationId: "00000000-0000-4000-8000-000000000002",
|
||||||
|
modelId: "gemini-3.1-flash-image-preview",
|
||||||
|
prompt: "生成一张简洁的红蓝几何色块测试图,不含文字。",
|
||||||
|
ratio: "1:1",
|
||||||
|
referenceAssetIds: [],
|
||||||
|
});
|
||||||
|
if (result.status === "failed") {
|
||||||
|
return { code: "ai_probe_failed", error_category: result.category, real_calls: 1, success: false };
|
||||||
|
}
|
||||||
|
if (result.status !== "completed" || result.outputs.length !== 1) {
|
||||||
|
return { code: "ai_probe_failed", error_category: "gateway_contract_invalid", real_calls: 1, success: false };
|
||||||
|
}
|
||||||
|
const output = result.outputs[0]!;
|
||||||
|
try {
|
||||||
|
return {
|
||||||
|
code: "ai_probe_passed",
|
||||||
|
mime_type: output.mimeType,
|
||||||
|
pixel_height: output.pixelHeight,
|
||||||
|
pixel_width: output.pixelWidth,
|
||||||
|
real_calls: 1,
|
||||||
|
success: true,
|
||||||
|
};
|
||||||
|
} finally {
|
||||||
|
output.bytes.fill(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
export interface GenerationPollingProcessor {
|
||||||
|
processNext(): Promise<unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class GenerationPollingLoop {
|
||||||
|
private closed = false;
|
||||||
|
private inFlight = false;
|
||||||
|
private readonly timer: ReturnType<typeof setInterval>;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly processor: GenerationPollingProcessor,
|
||||||
|
intervalMilliseconds = 250,
|
||||||
|
) {
|
||||||
|
if (!Number.isSafeInteger(intervalMilliseconds) || intervalMilliseconds <= 0) {
|
||||||
|
throw new Error("generation_polling_interval_invalid");
|
||||||
|
}
|
||||||
|
this.timer = setInterval(() => this.run(), intervalMilliseconds);
|
||||||
|
}
|
||||||
|
|
||||||
|
close() {
|
||||||
|
if (this.closed) return;
|
||||||
|
this.closed = true;
|
||||||
|
clearInterval(this.timer);
|
||||||
|
}
|
||||||
|
|
||||||
|
private run() {
|
||||||
|
if (this.closed || this.inFlight) return;
|
||||||
|
this.inFlight = true;
|
||||||
|
void this.processor.processNext()
|
||||||
|
.catch(() => undefined)
|
||||||
|
.finally(() => {
|
||||||
|
this.inFlight = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,11 +1,11 @@
|
|||||||
import { createHash, randomUUID } from "node:crypto";
|
import { createHash, randomUUID } from "node:crypto";
|
||||||
import { existsSync, mkdirSync, renameSync, rmSync, writeFileSync } from "node:fs";
|
import { existsSync, mkdirSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
|
||||||
import { dirname, join, resolve } from "node:path";
|
import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
|
||||||
|
|
||||||
import Database from "better-sqlite3";
|
import Database from "better-sqlite3";
|
||||||
import type BetterSqlite3 from "better-sqlite3";
|
import type BetterSqlite3 from "better-sqlite3";
|
||||||
|
|
||||||
import type { GenerationAdapter, GenerationAdapterResult, NormalizedGenerationOutput } from "./ai-adapter-contract.js";
|
import type { GenerationAdapter, GenerationAdapterRequest, GenerationAdapterResult, NormalizedGenerationOutput } from "./ai-adapter-contract.js";
|
||||||
import { GatewayBalanceRuntime } from "./gateway-balance-runtime.js";
|
import { GatewayBalanceRuntime } from "./gateway-balance-runtime.js";
|
||||||
import { generationErrorRegistry, type GenerationErrorCategory } from "./generation-error-registry.js";
|
import { generationErrorRegistry, type GenerationErrorCategory } from "./generation-error-registry.js";
|
||||||
import { configureWorkerDatabase } from "./sqlite-connection.js";
|
import { configureWorkerDatabase } from "./sqlite-connection.js";
|
||||||
@@ -91,7 +91,8 @@ export class GenerationProcessor {
|
|||||||
this.clock = input.clock ?? Date.now;
|
this.clock = input.clock ?? Date.now;
|
||||||
this.dataRoot = resolve(input.dataRoot);
|
this.dataRoot = resolve(input.dataRoot);
|
||||||
this.workerId = input.workerId;
|
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);
|
configureWorkerDatabase(this.database);
|
||||||
this.migrate();
|
this.migrate();
|
||||||
this.gatewayBalance = new GatewayBalanceRuntime({ clock: this.clock, database: this.database });
|
this.gatewayBalance = new GatewayBalanceRuntime({ clock: this.clock, database: this.database });
|
||||||
@@ -119,6 +120,7 @@ export class GenerationProcessor {
|
|||||||
.run("worker_stopped", now, this.workerId);
|
.run("worker_stopped", now, this.workerId);
|
||||||
});
|
});
|
||||||
this.gatewayBalance.close();
|
this.gatewayBalance.close();
|
||||||
|
this.adapter.dispose?.();
|
||||||
this.database.close();
|
this.database.close();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -143,6 +145,12 @@ export class GenerationProcessor {
|
|||||||
SELECT managed_file_id FROM generation_reference_snapshots WHERE generation_id = ? ORDER BY position
|
SELECT managed_file_id FROM generation_reference_snapshots WHERE generation_id = ? ORDER BY position
|
||||||
`).all(generationId) as Array<{ managed_file_id: string }>;
|
`).all(generationId) as Array<{ managed_file_id: string }>;
|
||||||
let adapterResult: GenerationAdapterResult;
|
let adapterResult: GenerationAdapterResult;
|
||||||
|
let referenceImages: NonNullable<GenerationAdapterRequest["referenceImages"]>;
|
||||||
|
try {
|
||||||
|
referenceImages = this.loadReferenceImages(references.map((row) => row.managed_file_id));
|
||||||
|
} catch {
|
||||||
|
return this.completeFailure(job, "reference_invalid", "reference_load_failed");
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
if (job.upstream_job_reference) {
|
if (job.upstream_job_reference) {
|
||||||
if (!this.adapter.poll) return this.completeFailure(job, "unknown_retryable", "poll_unsupported", undefined, false, "pending_manual_review");
|
if (!this.adapter.poll) return this.completeFailure(job, "unknown_retryable", "poll_unsupported", undefined, false, "pending_manual_review");
|
||||||
@@ -155,10 +163,13 @@ export class GenerationProcessor {
|
|||||||
prompt: job.prompt,
|
prompt: job.prompt,
|
||||||
ratio: job.ratio,
|
ratio: job.ratio,
|
||||||
referenceAssetIds: references.map((row) => row.managed_file_id),
|
referenceAssetIds: references.map((row) => row.managed_file_id),
|
||||||
|
referenceImages,
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
return this.completeFailure(job, "unknown_retryable", "adapter_exception", undefined, false, "pending_manual_review");
|
return this.completeFailure(job, "unknown_retryable", "adapter_exception", undefined, false, "pending_manual_review");
|
||||||
|
} finally {
|
||||||
|
for (const reference of referenceImages) reference.bytes.fill(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (adapterResult.status === "failed") return this.completeFailure(job, adapterResult.category, adapterResult.sourceCategory, adapterResult.balanceSignal);
|
if (adapterResult.status === "failed") return this.completeFailure(job, adapterResult.category, adapterResult.sourceCategory, adapterResult.balanceSignal);
|
||||||
@@ -174,6 +185,29 @@ export class GenerationProcessor {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private loadReferenceImages(referenceAssetIds: string[]): NonNullable<GenerationAdapterRequest["referenceImages"]> {
|
||||||
|
return referenceAssetIds.map((assetId) => {
|
||||||
|
const row = this.database.prepare(`
|
||||||
|
SELECT relative_path, mime_type FROM managed_files
|
||||||
|
WHERE file_id = ? AND file_kind = 'reference' AND status = 'committed'
|
||||||
|
`).get(assetId) as { mime_type: string; relative_path: string } | undefined;
|
||||||
|
if (!row || !["image/jpeg", "image/png", "image/webp"].includes(row.mime_type) || isAbsolute(row.relative_path)) {
|
||||||
|
throw new Error("reference_invalid");
|
||||||
|
}
|
||||||
|
const path = resolve(this.dataRoot, row.relative_path);
|
||||||
|
const child = relative(this.dataRoot, path);
|
||||||
|
if (!child || child === ".." || child.startsWith(`..${sep}`) || isAbsolute(child)
|
||||||
|
|| !existsSync(path) || !statSync(path).isFile()) {
|
||||||
|
throw new Error("reference_invalid");
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
assetId,
|
||||||
|
bytes: readFileSync(path),
|
||||||
|
mimeType: row.mime_type as "image/jpeg" | "image/png" | "image/webp",
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
private claim(generationId: string) {
|
private claim(generationId: string) {
|
||||||
return this.immediate(() => {
|
return this.immediate(() => {
|
||||||
const row = this.readJob(generationId);
|
const row = this.readJob(generationId);
|
||||||
|
|||||||
@@ -0,0 +1,221 @@
|
|||||||
|
import sharp from "sharp";
|
||||||
|
|
||||||
|
import type {
|
||||||
|
GenerationAdapter,
|
||||||
|
GenerationAdapterRequest,
|
||||||
|
GenerationAdapterResult,
|
||||||
|
NormalizedGenerationOutput,
|
||||||
|
} from "./ai-adapter-contract.js";
|
||||||
|
import { gptImageRequestSizeForRatio, normalizeImageOutputToRatio } from "./image-output-normalizer.mjs";
|
||||||
|
|
||||||
|
const geminiProductModelId = "gemini-3.1-flash-image-preview";
|
||||||
|
const geminiProviderModelId = "gemini-3.1-flash-image";
|
||||||
|
const gptImageModelId = "gpt-image-2";
|
||||||
|
const geminiEndpoint = "https://oneapi.intelligrow.cn/v1/chat/completions";
|
||||||
|
const gptImageEndpoint = "https://oneapi.intelligrow.cn/v1/images/generations";
|
||||||
|
const gptImageReferenceEndpoint = "https://oneapi.intelligrow.cn/v1/images/edits";
|
||||||
|
const maximumResponseBytes = 32 * 1024 * 1024;
|
||||||
|
const requestTimeoutMilliseconds = 180_000;
|
||||||
|
const geminiImageSystemInstruction = "Generate exactly one image from the user's description. Return the generated image and do not answer with text only.";
|
||||||
|
|
||||||
|
type FetchLike = typeof fetch;
|
||||||
|
|
||||||
|
class OneApiRuntimeError extends Error {
|
||||||
|
constructor(
|
||||||
|
readonly category: "gateway_balance_insufficient" | "gateway_contract_invalid" | "model_disabled" | "reference_invalid" | "upstream_failed" | "upstream_timeout" | "unknown_non_retryable",
|
||||||
|
readonly sourceCategory: string,
|
||||||
|
) {
|
||||||
|
super(sourceCategory);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function failure(error: unknown): GenerationAdapterResult {
|
||||||
|
if (error instanceof OneApiRuntimeError) {
|
||||||
|
return { category: error.category, sourceCategory: error.sourceCategory, status: "failed" };
|
||||||
|
}
|
||||||
|
if (error instanceof Error && error.name === "AbortError") {
|
||||||
|
return { category: "upstream_timeout", sourceCategory: "upstream_timeout", status: "failed" };
|
||||||
|
}
|
||||||
|
return { category: "upstream_failed", sourceCategory: "upstream_failed", status: "failed" };
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapHttpFailure(status: number) {
|
||||||
|
if (status === 408 || status === 504) return new OneApiRuntimeError("upstream_timeout", `upstream_http_${status}`);
|
||||||
|
if (status === 429) return new OneApiRuntimeError("gateway_balance_insufficient", "upstream_http_429");
|
||||||
|
if (status >= 500) return new OneApiRuntimeError("upstream_failed", `upstream_http_${status}`);
|
||||||
|
if (status === 400 || status === 404 || status === 422) return new OneApiRuntimeError("gateway_contract_invalid", `upstream_http_${status}`);
|
||||||
|
return new OneApiRuntimeError("unknown_non_retryable", `upstream_http_${status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readBoundedJson(response: Response) {
|
||||||
|
const declaredLength = Number(response.headers.get("content-length") ?? 0);
|
||||||
|
if (Number.isFinite(declaredLength) && declaredLength > maximumResponseBytes) {
|
||||||
|
throw new OneApiRuntimeError("gateway_contract_invalid", "upstream_response_too_large");
|
||||||
|
}
|
||||||
|
if (!response.body) throw new OneApiRuntimeError("gateway_contract_invalid", "upstream_response_empty");
|
||||||
|
const reader = response.body.getReader();
|
||||||
|
const chunks: Buffer[] = [];
|
||||||
|
let total = 0;
|
||||||
|
try {
|
||||||
|
while (true) {
|
||||||
|
const next = await reader.read();
|
||||||
|
if (next.done) break;
|
||||||
|
const chunk = Buffer.from(next.value);
|
||||||
|
total += chunk.length;
|
||||||
|
if (total > maximumResponseBytes) {
|
||||||
|
await reader.cancel();
|
||||||
|
throw new OneApiRuntimeError("gateway_contract_invalid", "upstream_response_too_large");
|
||||||
|
}
|
||||||
|
chunks.push(chunk);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return JSON.parse(Buffer.concat(chunks).toString("utf8")) as unknown;
|
||||||
|
} catch {
|
||||||
|
throw new OneApiRuntimeError("gateway_contract_invalid", "upstream_response_invalid");
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
for (const chunk of chunks) chunk.fill(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractGeminiImage(response: unknown) {
|
||||||
|
if (!response || typeof response !== "object" || !("choices" in response) || !Array.isArray(response.choices)) {
|
||||||
|
throw new OneApiRuntimeError("gateway_contract_invalid", "response_shape_invalid");
|
||||||
|
}
|
||||||
|
const choice = response.choices[0];
|
||||||
|
const content = choice && typeof choice === "object" && "message" in choice && choice.message && typeof choice.message === "object"
|
||||||
|
&& "content" in choice.message && typeof choice.message.content === "string" ? choice.message.content : "";
|
||||||
|
const matches = [...content.matchAll(/!\[[^\]]*\]\(\s*data:(image\/(?:jpeg|png|webp));base64,([A-Za-z0-9+/=\r\n]+)\s*\)/gi)];
|
||||||
|
if (matches.length !== 1) throw new OneApiRuntimeError("gateway_contract_invalid", "response_single_image_required");
|
||||||
|
return { bytes: Buffer.from(matches[0]![2]!, "base64"), declaredMimeType: matches[0]![1]!.toLowerCase() };
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractGptImage(response: unknown) {
|
||||||
|
if (!response || typeof response !== "object" || !("data" in response) || !Array.isArray(response.data)
|
||||||
|
|| response.data.length !== 1 || !response.data[0] || typeof response.data[0] !== "object"
|
||||||
|
|| !("b64_json" in response.data[0]) || typeof response.data[0].b64_json !== "string") {
|
||||||
|
throw new OneApiRuntimeError("gateway_contract_invalid", "response_single_image_required");
|
||||||
|
}
|
||||||
|
return { bytes: Buffer.from(response.data[0].b64_json, "base64"), declaredMimeType: undefined };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function normalizeOutput(bytes: Buffer, declaredMimeType: string | undefined, ratio: GenerationAdapterRequest["ratio"]): Promise<NormalizedGenerationOutput> {
|
||||||
|
try {
|
||||||
|
const metadata = await sharp(bytes, { failOn: "error", limitInputPixels: 40_000_000 }).metadata();
|
||||||
|
const mimeType = metadata.format === "png" ? "image/png" : metadata.format === "jpeg" ? "image/jpeg" : metadata.format === "webp" ? "image/webp" : undefined;
|
||||||
|
if (!mimeType || !metadata.width || !metadata.height || (declaredMimeType && declaredMimeType !== mimeType)) {
|
||||||
|
throw new OneApiRuntimeError("gateway_contract_invalid", "response_media_invalid");
|
||||||
|
}
|
||||||
|
const normalized = await normalizeImageOutputToRatio({ bytes, mimeType, pixelHeight: metadata.height, pixelWidth: metadata.width, ratio });
|
||||||
|
return { bytes: normalized.bytes, mimeType: normalized.mimeType, pixelHeight: normalized.pixelHeight, pixelWidth: normalized.pixelWidth };
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof OneApiRuntimeError) throw error;
|
||||||
|
throw new OneApiRuntimeError("gateway_contract_invalid", "response_media_invalid");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateRequest(request: GenerationAdapterRequest) {
|
||||||
|
if (!request.prompt.trim() || request.prompt.length > 1_000) throw new OneApiRuntimeError("gateway_contract_invalid", "prompt_invalid");
|
||||||
|
const references = request.referenceImages ?? [];
|
||||||
|
if (references.length !== request.referenceAssetIds.length || references.length > 2) {
|
||||||
|
throw new OneApiRuntimeError("reference_invalid", "reference_count_invalid");
|
||||||
|
}
|
||||||
|
const totalBytes = references.reduce((total, reference) => total + reference.bytes.length, 0);
|
||||||
|
if (totalBytes > 20 * 1024 * 1024 || references.some((reference) => reference.bytes.length === 0 || reference.bytes.length > 10 * 1024 * 1024)) {
|
||||||
|
throw new OneApiRuntimeError("reference_invalid", "reference_size_invalid");
|
||||||
|
}
|
||||||
|
return references;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildRequest(request: GenerationAdapterRequest) {
|
||||||
|
const references = validateRequest(request);
|
||||||
|
if (request.modelId === geminiProductModelId) {
|
||||||
|
const content = references.length === 0
|
||||||
|
? request.prompt
|
||||||
|
: [
|
||||||
|
{ text: request.prompt, type: "text" },
|
||||||
|
...references.map((reference) => ({
|
||||||
|
image_url: { url: `data:${reference.mimeType};base64,${reference.bytes.toString("base64")}` },
|
||||||
|
type: "image_url",
|
||||||
|
})),
|
||||||
|
];
|
||||||
|
return {
|
||||||
|
body: JSON.stringify({
|
||||||
|
extra_body: { google: { image_config: { aspect_ratio: request.ratio, image_size: "1K" } } },
|
||||||
|
messages: [
|
||||||
|
{ content: geminiImageSystemInstruction, role: "system" },
|
||||||
|
{ content, role: "user" },
|
||||||
|
],
|
||||||
|
model: geminiProviderModelId,
|
||||||
|
stream: false,
|
||||||
|
}),
|
||||||
|
contentType: "application/json",
|
||||||
|
endpoint: geminiEndpoint,
|
||||||
|
parser: extractGeminiImage,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (request.modelId !== gptImageModelId) throw new OneApiRuntimeError("model_disabled", "model_not_supported");
|
||||||
|
if (references.length > 0) {
|
||||||
|
const form = new FormData();
|
||||||
|
form.append("model", gptImageModelId);
|
||||||
|
form.append("prompt", request.prompt);
|
||||||
|
form.append("response_format", "b64_json");
|
||||||
|
form.append("size", gptImageRequestSizeForRatio(request.ratio));
|
||||||
|
references.forEach((reference, index) => form.append("image[]", new Blob([reference.bytes], { type: reference.mimeType }), `reference-${index + 1}.png`));
|
||||||
|
return { body: form, contentType: undefined, endpoint: gptImageReferenceEndpoint, parser: extractGptImage };
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
body: JSON.stringify({ model: gptImageModelId, prompt: request.prompt, response_format: "b64_json", size: gptImageRequestSizeForRatio(request.ratio) }),
|
||||||
|
contentType: "application/json",
|
||||||
|
endpoint: gptImageEndpoint,
|
||||||
|
parser: extractGptImage,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export class OneApiGenerationAdapter implements GenerationAdapter {
|
||||||
|
private readonly credential: Buffer;
|
||||||
|
private readonly fetchImpl: FetchLike;
|
||||||
|
private disposed = false;
|
||||||
|
|
||||||
|
constructor(input: { credential: Buffer; fetch?: FetchLike }) {
|
||||||
|
if (input.credential.length < 8) throw new Error("ai_gateway_credential_invalid");
|
||||||
|
this.credential = Buffer.from(input.credential);
|
||||||
|
this.fetchImpl = input.fetch ?? fetch;
|
||||||
|
}
|
||||||
|
|
||||||
|
async start(request: GenerationAdapterRequest): Promise<GenerationAdapterResult> {
|
||||||
|
if (this.disposed) return { category: "upstream_failed", sourceCategory: "adapter_disposed", status: "failed" };
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timeout = setTimeout(() => controller.abort(), requestTimeoutMilliseconds);
|
||||||
|
let sourceBytes: Buffer | undefined;
|
||||||
|
try {
|
||||||
|
const providerRequest = buildRequest(request);
|
||||||
|
const headers = new Headers({ authorization: `Bearer ${this.credential.toString("utf8")}` });
|
||||||
|
if (providerRequest.contentType) headers.set("content-type", providerRequest.contentType);
|
||||||
|
const response = await this.fetchImpl(providerRequest.endpoint, {
|
||||||
|
body: providerRequest.body,
|
||||||
|
headers,
|
||||||
|
method: "POST",
|
||||||
|
redirect: "error",
|
||||||
|
signal: controller.signal,
|
||||||
|
});
|
||||||
|
if (!response.ok) throw mapHttpFailure(response.status);
|
||||||
|
const parsed = await readBoundedJson(response);
|
||||||
|
const extracted = providerRequest.parser(parsed);
|
||||||
|
sourceBytes = extracted.bytes;
|
||||||
|
const output = await normalizeOutput(sourceBytes, extracted.declaredMimeType, request.ratio);
|
||||||
|
return { outputs: [output], status: "completed" };
|
||||||
|
} catch (error) {
|
||||||
|
return failure(error);
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
sourceBytes?.fill(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
dispose() {
|
||||||
|
if (this.disposed) return;
|
||||||
|
this.disposed = true;
|
||||||
|
this.credential.fill(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -13,7 +13,7 @@ export async function receiveWorkerCredentials(input: NodeJS.ReadableStream = pr
|
|||||||
if (names.length !== expected.length || names.some((name, index) => name !== expected[index])) {
|
if (names.length !== expected.length || names.some((name, index) => name !== expected[index])) {
|
||||||
throw new Error("Worker credential channel contains an unexpected credential scope.");
|
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.");
|
throw new Error("Worker credential channel contains an invalid credential value.");
|
||||||
}
|
}
|
||||||
return parsed as Record<(typeof WORKER_CREDENTIALS)[number], string>;
|
return parsed as Record<(typeof WORKER_CREDENTIALS)[number], string>;
|
||||||
@@ -25,9 +25,13 @@ export async function receiveWorkerCredentials(input: NodeJS.ReadableStream = pr
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function initializeWorkerCredentialClient(credentials: Record<(typeof WORKER_CREDENTIALS)[number], string>) {
|
export function initializeWorkerCredentialClient(credentials: Record<(typeof WORKER_CREDENTIALS)[number], string>) {
|
||||||
const configured = WORKER_CREDENTIALS.every((name) => credentials[name].length > 0);
|
const value = credentials["Dada/P0A/worker/ai-gateway"];
|
||||||
for (const name of WORKER_CREDENTIALS) credentials[name] = "";
|
try {
|
||||||
if (!configured) throw new Error("Worker credential client initialization failed.");
|
if (!value) throw new Error("worker_ai_gateway_not_configured");
|
||||||
|
return { aiGatewayCredential: Buffer.from(value, "utf8") };
|
||||||
|
} finally {
|
||||||
|
for (const name of WORKER_CREDENTIALS) credentials[name] = "";
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function attachWorkerSupervisorControl(pipeName: string, shutdown: () => Promise<void> | void) {
|
export function attachWorkerSupervisorControl(pipeName: string, shutdown: () => Promise<void> | void) {
|
||||||
|
|||||||
@@ -2,6 +2,10 @@ import { parentPort } from "node:worker_threads";
|
|||||||
import { join } from "node:path";
|
import { join } from "node:path";
|
||||||
|
|
||||||
import { WorkerAiCallGate } from "./ai-call-gate.js";
|
import { WorkerAiCallGate } from "./ai-call-gate.js";
|
||||||
|
import { runAiRuntimeProbe } from "./ai-runtime-probe.js";
|
||||||
|
import { GenerationPollingLoop } from "./generation-polling-loop.js";
|
||||||
|
import { GenerationProcessor } from "./generation-processor.js";
|
||||||
|
import { OneApiGenerationAdapter } from "./oneapi-generation-adapter.js";
|
||||||
import { readConfiguredLocalDataRoot } from "./runtime-config.js";
|
import { readConfiguredLocalDataRoot } from "./runtime-config.js";
|
||||||
import { RetentionCleanup } from "./retention-cleanup.js";
|
import { RetentionCleanup } from "./retention-cleanup.js";
|
||||||
import { ProjectPurgeCleanup } from "./project-purge-cleanup.js";
|
import { ProjectPurgeCleanup } from "./project-purge-cleanup.js";
|
||||||
@@ -21,8 +25,25 @@ if (workerPort) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!workerPort && process.argv.includes("--dada-credential-stdin")) {
|
if (!workerPort && process.argv.includes("--dada-ai-probe")) {
|
||||||
initializeWorkerCredentialClient(await receiveWorkerCredentials());
|
const credentialClient = initializeWorkerCredentialClient(await receiveWorkerCredentials());
|
||||||
|
let adapter: OneApiGenerationAdapter | undefined;
|
||||||
|
let probeResult: Awaited<ReturnType<typeof runAiRuntimeProbe>> | { code: "ai_probe_failed"; error_category: "upstream_failed"; real_calls: 0; success: false };
|
||||||
|
try {
|
||||||
|
adapter = new OneApiGenerationAdapter({ credential: credentialClient.aiGatewayCredential });
|
||||||
|
probeResult = await runAiRuntimeProbe(adapter);
|
||||||
|
} catch {
|
||||||
|
probeResult = { code: "ai_probe_failed", error_category: "upstream_failed", real_calls: 0, success: false };
|
||||||
|
} finally {
|
||||||
|
credentialClient.aiGatewayCredential.fill(0);
|
||||||
|
adapter?.dispose();
|
||||||
|
}
|
||||||
|
await new Promise<void>((resolveWrite, rejectWrite) => {
|
||||||
|
process.stdout.write(JSON.stringify(probeResult), (error) => error ? rejectWrite(error) : resolveWrite());
|
||||||
|
});
|
||||||
|
process.exit(probeResult.success ? 0 : 2);
|
||||||
|
} else if (!workerPort && process.argv.includes("--dada-credential-stdin")) {
|
||||||
|
const credentialClient = initializeWorkerCredentialClient(await receiveWorkerCredentials());
|
||||||
const controlPipeIndex = process.argv.indexOf("--dada-control-pipe");
|
const controlPipeIndex = process.argv.indexOf("--dada-control-pipe");
|
||||||
const controlPipe = process.argv[controlPipeIndex + 1];
|
const controlPipe = process.argv[controlPipeIndex + 1];
|
||||||
if (controlPipeIndex < 0 || !controlPipe) throw new Error("Supervisor control pipe name is required.");
|
if (controlPipeIndex < 0 || !controlPipe) throw new Error("Supervisor control pipe name is required.");
|
||||||
@@ -31,11 +52,15 @@ if (!workerPort && process.argv.includes("--dada-credential-stdin")) {
|
|||||||
let retention: RetentionCleanup | undefined;
|
let retention: RetentionCleanup | undefined;
|
||||||
let projectCleanup: ProjectPurgeCleanup | undefined;
|
let projectCleanup: ProjectPurgeCleanup | undefined;
|
||||||
let retentionTimer: ReturnType<typeof setInterval> | undefined;
|
let retentionTimer: ReturnType<typeof setInterval> | undefined;
|
||||||
|
let processor: GenerationProcessor | undefined;
|
||||||
|
let generationLoop: GenerationPollingLoop | undefined;
|
||||||
const control = attachWorkerSupervisorControl(controlPipe, () => {
|
const control = attachWorkerSupervisorControl(controlPipe, () => {
|
||||||
clearInterval(keepAlive);
|
clearInterval(keepAlive);
|
||||||
if (retentionTimer) clearInterval(retentionTimer);
|
if (retentionTimer) clearInterval(retentionTimer);
|
||||||
retention?.close();
|
retention?.close();
|
||||||
projectCleanup?.close();
|
projectCleanup?.close();
|
||||||
|
generationLoop?.close();
|
||||||
|
processor?.close();
|
||||||
storage?.close();
|
storage?.close();
|
||||||
});
|
});
|
||||||
let storageStatus: "active" | "unavailable" = "active";
|
let storageStatus: "active" | "unavailable" = "active";
|
||||||
@@ -45,6 +70,13 @@ if (!workerPort && process.argv.includes("--dada-credential-stdin")) {
|
|||||||
storage = new WorkerStorageStatus(databasePath);
|
storage = new WorkerStorageStatus(databasePath);
|
||||||
retention = new RetentionCleanup({ databasePath });
|
retention = new RetentionCleanup({ databasePath });
|
||||||
projectCleanup = new ProjectPurgeCleanup({ dataRoot, databasePath });
|
projectCleanup = new ProjectPurgeCleanup({ dataRoot, databasePath });
|
||||||
|
let adapter: OneApiGenerationAdapter;
|
||||||
|
try {
|
||||||
|
adapter = new OneApiGenerationAdapter({ credential: credentialClient.aiGatewayCredential });
|
||||||
|
} finally {
|
||||||
|
credentialClient.aiGatewayCredential.fill(0);
|
||||||
|
}
|
||||||
|
processor = new GenerationProcessor({ adapter, dataRoot, databasePath, workerId: `portable-oneapi-worker-${process.pid}` });
|
||||||
const runRetentionCleanup = () => {
|
const runRetentionCleanup = () => {
|
||||||
try {
|
try {
|
||||||
retention?.purgeExpired();
|
retention?.purgeExpired();
|
||||||
@@ -71,8 +103,11 @@ if (!workerPort && process.argv.includes("--dada-credential-stdin")) {
|
|||||||
});
|
});
|
||||||
logger.write({ error_category: "none", status_category: "ready" });
|
logger.write({ error_category: "none", status_category: "ready" });
|
||||||
new WorkerAiCallGate({ getStorageStatus: () => storageStatus === "unavailable" ? storageStatus : (storage?.getStatus() ?? "unavailable"), logger });
|
new WorkerAiCallGate({ getStorageStatus: () => storageStatus === "unavailable" ? storageStatus : (storage?.getStatus() ?? "unavailable"), logger });
|
||||||
|
generationLoop = new GenerationPollingLoop(processor);
|
||||||
} catch {
|
} catch {
|
||||||
storageStatus = "unavailable";
|
storageStatus = "unavailable";
|
||||||
control.reportStatus("storage_unavailable");
|
control.reportStatus("storage_unavailable");
|
||||||
|
clearInterval(keepAlive);
|
||||||
|
setTimeout(() => process.exit(1), 50);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -21,6 +21,8 @@
|
|||||||
"test:security": "node scripts/verify-frozen-dependencies.mjs && node scripts/redaction-scan.mjs",
|
"test:security": "node scripts/verify-frozen-dependencies.mjs && node scripts/redaction-scan.mjs",
|
||||||
"test:package": "pnpm build:workspace-packages && pnpm run typecheck && node --test tests/package/wp0-09-portable.test.mjs && node scripts/package-smoke.mjs && node scripts/loopback-boundary-smoke.mjs",
|
"test:package": "pnpm build:workspace-packages && pnpm run typecheck && node --test tests/package/wp0-09-portable.test.mjs && node scripts/package-smoke.mjs && node scripts/loopback-boundary-smoke.mjs",
|
||||||
"package:portable": "node scripts/build-portable.mjs",
|
"package:portable": "node scripts/build-portable.mjs",
|
||||||
|
"assets:manifest": "pnpm build:workspace-packages && node scripts/generate-runtime-asset-manifest.mjs",
|
||||||
|
"assets:deploy": "pnpm build:workspace-packages && node scripts/deploy-runtime-assets.mjs",
|
||||||
"generate:openapi": "node scripts/generate-openapi.mjs",
|
"generate:openapi": "node scripts/generate-openapi.mjs",
|
||||||
"check:openapi": "node scripts/check-openapi.mjs",
|
"check:openapi": "node scripts/check-openapi.mjs",
|
||||||
"validate:tdd-trace": "node scripts/validate-tdd-trace.mjs",
|
"validate:tdd-trace": "node scripts/validate-tdd-trace.mjs",
|
||||||
|
|||||||
@@ -23,6 +23,27 @@ export const P0A_DYNAMIC_STICKER_IDS = [
|
|||||||
"DYN008", "DYN011", "DYN012", "DYN015", "DYN016",
|
"DYN008", "DYN011", "DYN012", "DYN015", "DYN016",
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
|
export const P0A_DYNAMIC_RUNTIME_FONT_SOURCES = [
|
||||||
|
{ assetId: "15974853bc3294ef68e7e6d58fe74fd7", sourceReference: "fonts/15974853bc3294ef68e7e6d58fe74fd7", templateId: "DYN002" },
|
||||||
|
{ assetId: "46f8336813e4c48d06a1aef294fdccf6", sourceReference: "fonts/46f8336813e4c48d06a1aef294fdccf6", templateId: "DYN016" },
|
||||||
|
{ assetId: "53ca6b704728520da50c145eabb2e635", sourceReference: "fonts/53ca6b704728520da50c145eabb2e635", templateId: "DYN007" },
|
||||||
|
{ assetId: "cca5efc0e02fb1bf62349bd68ef30fc1", sourceReference: "fonts/cca5efc0e02fb1bf62349bd68ef30fc1", templateId: "DYN015" },
|
||||||
|
{ assetId: "dd25b35dcb7ba4476cbaa9a9592e39e2", sourceReference: "fonts/dd25b35dcb7ba4476cbaa9a9592e39e2", templateId: "DYN001" },
|
||||||
|
{ assetId: "e4210c9872f0c279b35273f230809821", sourceReference: "fonts/e4210c9872f0c279b35273f230809821", templateId: "DYN011" },
|
||||||
|
{ assetId: "f4bfd4132df2d6be97ceabadf3853505", sourceReference: "fonts/f4bfd4132df2d6be97ceabadf3853505", templateId: "DYN008" },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export const P0A_DYNAMIC_RUNTIME_IMAGE_SOURCES = [
|
||||||
|
{ assetId: "DYN001-image28", sourceReference: "resource/image28.png", templateId: "DYN001" },
|
||||||
|
{ assetId: "DYN002-image29", sourceReference: "resource/image29.png", templateId: "DYN002" },
|
||||||
|
{ assetId: "DYN003-image30", sourceReference: "resource/image30.png", templateId: "DYN003" },
|
||||||
|
{ assetId: "DYN004-image32", sourceReference: "resource/image32.png", templateId: "DYN004" },
|
||||||
|
{ assetId: "DYN008-backendui0", sourceReference: "resource/backendui0.png", templateId: "DYN008" },
|
||||||
|
{ assetId: "DYN011-backendui0", sourceReference: "resource/backendui0.png", templateId: "DYN011" },
|
||||||
|
{ assetId: "DYN015-imager2", sourceReference: "resource/imager2_2.png", templateId: "DYN015" },
|
||||||
|
{ assetId: "DYN016-image21", sourceReference: "resource/image21.png", templateId: "DYN016" },
|
||||||
|
] as const;
|
||||||
|
|
||||||
export type RegisteredComplexFamily = "color_card" | "font_panel" | "interactive_sticker" | "text_template";
|
export type RegisteredComplexFamily = "color_card" | "font_panel" | "interactive_sticker" | "text_template";
|
||||||
|
|
||||||
export interface RegisteredComplexAsset extends Record<string, unknown> {
|
export interface RegisteredComplexAsset extends Record<string, unknown> {
|
||||||
|
|||||||
@@ -1,10 +1,20 @@
|
|||||||
|
import { execFileSync } from "node:child_process";
|
||||||
|
import { readFileSync } from "node:fs";
|
||||||
import { resolve } from "node:path";
|
import { resolve } from "node:path";
|
||||||
|
|
||||||
import { buildAndValidatePortablePackage } from "./lib/portable-package.mjs";
|
import { buildAndValidatePortablePackage } from "./lib/portable-package.mjs";
|
||||||
|
import { validateFinalReleaseRecord } from "./lib/wp7-07-final-release.mjs";
|
||||||
|
|
||||||
const outputIndex = process.argv.indexOf("--output");
|
const outputIndex = process.argv.indexOf("--output");
|
||||||
const outputRoot = outputIndex >= 0 ? resolve(process.argv[outputIndex + 1]) : resolve(".build", "portable-release");
|
const outputRoot = outputIndex >= 0 ? resolve(process.argv[outputIndex + 1]) : resolve(".build", "portable-release");
|
||||||
const result = await buildAndValidatePortablePackage({ outputRoot });
|
const previousRelease = JSON.parse(readFileSync(resolve("RELEASE.json"), "utf8"));
|
||||||
|
const releaseRecord = validateFinalReleaseRecord({
|
||||||
|
...previousRelease,
|
||||||
|
buildCommit: execFileSync("git", ["rev-parse", "HEAD"], { encoding: "utf8" }).trim(),
|
||||||
|
maintenanceFromCommit: previousRelease.buildCommit,
|
||||||
|
recordedAt: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
const result = await buildAndValidatePortablePackage({ outputRoot, releaseRecord });
|
||||||
console.log(JSON.stringify({
|
console.log(JSON.stringify({
|
||||||
package: result.packageManifest.package_name,
|
package: result.packageManifest.package_name,
|
||||||
sha256: result.packageManifest.zip_sha256,
|
sha256: result.packageManifest.zip_sha256,
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import { readFileSync } from "node:fs";
|
||||||
|
import { isAbsolute, join, resolve } from "node:path";
|
||||||
|
|
||||||
|
import {
|
||||||
|
buildP0aRuntimeAssetPlan,
|
||||||
|
defaultReplicationRoot,
|
||||||
|
deployRuntimeAssetPlan,
|
||||||
|
readRuntimeAssetManifest,
|
||||||
|
serializeRuntimeAssetManifest,
|
||||||
|
} from "./lib/runtime-assets.mjs";
|
||||||
|
|
||||||
|
function option(name) {
|
||||||
|
const index = process.argv.indexOf(name);
|
||||||
|
return index >= 0 ? process.argv[index + 1] : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function defaultConfigPath() {
|
||||||
|
if (!process.env.LOCALAPPDATA || !isAbsolute(process.env.LOCALAPPDATA)) throw new Error("local_app_data_unavailable");
|
||||||
|
return join(process.env.LOCALAPPDATA, "Dada", "P0A", "config", "instance.json");
|
||||||
|
}
|
||||||
|
|
||||||
|
const configFile = resolve(option("--config") ?? process.env.DADA_INSTANCE_CONFIG_PATH ?? defaultConfigPath());
|
||||||
|
const configuration = JSON.parse(readFileSync(configFile, "utf8"));
|
||||||
|
const assetRootCandidate = option("--asset-root") ?? configuration.asset_root;
|
||||||
|
if (typeof assetRootCandidate !== "string" || !isAbsolute(assetRootCandidate)) {
|
||||||
|
throw new Error("asset_root_configuration_invalid");
|
||||||
|
}
|
||||||
|
const assetRoot = resolve(assetRootCandidate);
|
||||||
|
const trustedManifest = readRuntimeAssetManifest(resolve(option("--trusted-manifest") ?? "config/runtime-assets-manifest.json"));
|
||||||
|
const plan = await buildP0aRuntimeAssetPlan({
|
||||||
|
replicationRoot: resolve(option("--replication-root") ?? defaultReplicationRoot()),
|
||||||
|
});
|
||||||
|
if (serializeRuntimeAssetManifest(plan.manifest) !== serializeRuntimeAssetManifest(trustedManifest)) {
|
||||||
|
throw new Error("runtime_asset_source_does_not_match_trusted_manifest");
|
||||||
|
}
|
||||||
|
const result = deployRuntimeAssetPlan({ assetRoot, manifest: trustedManifest, resources: plan.resources });
|
||||||
|
process.stdout.write(`${JSON.stringify({ linked_files: result.linked_files, status: result.status })}\n`);
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import { resolve } from "node:path";
|
||||||
|
|
||||||
|
import {
|
||||||
|
buildP0aRuntimeAssetPlan,
|
||||||
|
defaultReplicationRoot,
|
||||||
|
serializeRuntimeAssetManifest,
|
||||||
|
writeRuntimeAssetManifest,
|
||||||
|
} from "./lib/runtime-assets.mjs";
|
||||||
|
|
||||||
|
function option(name) {
|
||||||
|
const index = process.argv.indexOf(name);
|
||||||
|
return index >= 0 ? process.argv[index + 1] : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const replicationRoot = resolve(option("--replication-root") ?? defaultReplicationRoot());
|
||||||
|
const outputPath = resolve(option("--output") ?? "config/runtime-assets-manifest.json");
|
||||||
|
const plan = await buildP0aRuntimeAssetPlan({ replicationRoot });
|
||||||
|
writeRuntimeAssetManifest(outputPath, plan.manifest);
|
||||||
|
process.stdout.write(`${JSON.stringify({
|
||||||
|
counts: plan.manifest.counts,
|
||||||
|
manifest_bytes: Buffer.byteLength(serializeRuntimeAssetManifest(plan.manifest)),
|
||||||
|
status: "generated",
|
||||||
|
})}\n`);
|
||||||
@@ -18,6 +18,7 @@ import { tmpdir } from "node:os";
|
|||||||
import { basename, dirname, join, relative, resolve, sep } from "node:path";
|
import { basename, dirname, join, relative, resolve, sep } from "node:path";
|
||||||
|
|
||||||
import { frozenRuntime } from "../frozen-versions.mjs";
|
import { frozenRuntime } from "../frozen-versions.mjs";
|
||||||
|
import { readRuntimeAssetManifest } from "./runtime-assets.mjs";
|
||||||
|
|
||||||
const repositoryRoot = resolve(import.meta.dirname, "..", "..");
|
const repositoryRoot = resolve(import.meta.dirname, "..", "..");
|
||||||
const fixedPort = 43121;
|
const fixedPort = 43121;
|
||||||
@@ -336,7 +337,7 @@ export async function buildAndValidatePortablePackage({ evidenceDirectory, outpu
|
|||||||
debug("copy API application");
|
debug("copy API application");
|
||||||
const apiDependencies = copyApplication(join(repositoryRoot, "apps", "api"), join(serverRoot, "api"), ["@fastify/multipart", "@fastify/swagger", "@sinclair/typebox", "better-sqlite3", "fastify", "sharp"]);
|
const apiDependencies = copyApplication(join(repositoryRoot, "apps", "api"), join(serverRoot, "api"), ["@fastify/multipart", "@fastify/swagger", "@sinclair/typebox", "better-sqlite3", "fastify", "sharp"]);
|
||||||
debug("copy Worker application");
|
debug("copy Worker application");
|
||||||
const workerDependencies = copyApplication(join(repositoryRoot, "apps", "worker"), join(serverRoot, "worker"), ["better-sqlite3"]);
|
const workerDependencies = copyApplication(join(repositoryRoot, "apps", "worker"), join(serverRoot, "worker"), ["better-sqlite3", "sharp"]);
|
||||||
const sharedDestination = join(serverRoot, "api", "node_modules", "@dada", "shared-contracts");
|
const sharedDestination = join(serverRoot, "api", "node_modules", "@dada", "shared-contracts");
|
||||||
mkdirSync(sharedDestination, { recursive: true });
|
mkdirSync(sharedDestination, { recursive: true });
|
||||||
copyTree(join(repositoryRoot, "packages", "shared-contracts", "dist"), join(sharedDestination, "dist"));
|
copyTree(join(repositoryRoot, "packages", "shared-contracts", "dist"), join(sharedDestination, "dist"));
|
||||||
@@ -363,7 +364,10 @@ export async function buildAndValidatePortablePackage({ evidenceDirectory, outpu
|
|||||||
copyTree(join(repositoryRoot, "apps", "web", "dist"), join(packageDirectory, "web"));
|
copyTree(join(repositoryRoot, "apps", "web", "dist"), join(packageDirectory, "web"));
|
||||||
copyTree(join(repositoryRoot, "apps", "web", "support-gate"), join(packageDirectory, "web", "support-gate"));
|
copyTree(join(repositoryRoot, "apps", "web", "support-gate"), join(packageDirectory, "web", "support-gate"));
|
||||||
writeJson(join(packageDirectory, "migrations", "manifest.json"), { migrations: [], schema_version: "0" });
|
writeJson(join(packageDirectory, "migrations", "manifest.json"), { migrations: [], schema_version: "0" });
|
||||||
writeJson(join(packageDirectory, "asset-metadata", "manifest.json"), { resources: [], schema_version: "1.0", source: "external_read_only" });
|
writeJson(
|
||||||
|
join(packageDirectory, "asset-metadata", "manifest.json"),
|
||||||
|
readRuntimeAssetManifest(join(repositoryRoot, "config", "runtime-assets-manifest.json")),
|
||||||
|
);
|
||||||
writeJson(join(packageDirectory, "LICENSES", "third-party.json"), { api: apiDependencies, runtime: { node: frozenRuntime.node }, schema_version: "1.0", worker: workerDependencies });
|
writeJson(join(packageDirectory, "LICENSES", "third-party.json"), { api: apiDependencies, runtime: { node: frozenRuntime.node }, schema_version: "1.0", worker: workerDependencies });
|
||||||
|
|
||||||
const commit = run("git", ["rev-parse", "HEAD"]);
|
const commit = run("git", ["rev-parse", "HEAD"]);
|
||||||
|
|||||||
@@ -0,0 +1,317 @@
|
|||||||
|
import { createHash } from "node:crypto";
|
||||||
|
import {
|
||||||
|
existsSync,
|
||||||
|
linkSync,
|
||||||
|
lstatSync,
|
||||||
|
mkdirSync,
|
||||||
|
mkdtempSync,
|
||||||
|
readFileSync,
|
||||||
|
readdirSync,
|
||||||
|
realpathSync,
|
||||||
|
rmSync,
|
||||||
|
statSync,
|
||||||
|
writeFileSync,
|
||||||
|
} from "node:fs";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import { basename, dirname, extname, isAbsolute, join, resolve, sep } from "node:path";
|
||||||
|
|
||||||
|
export const P0A_RUNTIME_ASSET_ROOT_REF = "p0a_runtime_assets";
|
||||||
|
export const RUNTIME_ASSET_MANIFEST_SCHEMA = "DadaRuntimeAssets/v1";
|
||||||
|
|
||||||
|
const assetIdPattern = /^[a-z0-9][a-z0-9_-]{2,119}$/i;
|
||||||
|
const mimePattern = /^[a-z0-9][a-z0-9.+-]*\/[a-z0-9][a-z0-9.+-]*$/i;
|
||||||
|
const releasePattern = /^[a-z0-9][a-z0-9._-]{0,79}$/i;
|
||||||
|
const shaPattern = /^[a-f0-9]{64}$/i;
|
||||||
|
const fontMimeTypes = new Map([
|
||||||
|
[".otf", "font/otf"],
|
||||||
|
[".ttf", "font/ttf"],
|
||||||
|
[".woff", "font/woff"],
|
||||||
|
[".woff2", "font/woff2"],
|
||||||
|
]);
|
||||||
|
|
||||||
|
function sha256(bytes) {
|
||||||
|
return createHash("sha256").update(bytes).digest("hex");
|
||||||
|
}
|
||||||
|
|
||||||
|
function fileSha256(path) {
|
||||||
|
return sha256(readFileSync(path));
|
||||||
|
}
|
||||||
|
|
||||||
|
function stableEntries(entries) {
|
||||||
|
return entries.map((entry) => {
|
||||||
|
if (!entry || typeof entry !== "object") throw new Error("runtime_asset_entry_invalid");
|
||||||
|
if (!assetIdPattern.test(entry.assetId)) throw new Error("runtime_asset_id_invalid");
|
||||||
|
if (!mimePattern.test(entry.mimeType)) throw new Error("runtime_asset_mime_invalid");
|
||||||
|
if (!releasePattern.test(entry.resourceVersion)) throw new Error("runtime_asset_version_invalid");
|
||||||
|
if (entry.rootRef !== P0A_RUNTIME_ASSET_ROOT_REF) throw new Error("runtime_asset_root_ref_invalid");
|
||||||
|
if (!shaPattern.test(entry.sha256)) throw new Error("runtime_asset_sha256_invalid");
|
||||||
|
if (
|
||||||
|
typeof entry.relativePath !== "string"
|
||||||
|
|| isAbsolute(entry.relativePath)
|
||||||
|
|| entry.relativePath.includes("\\")
|
||||||
|
|| entry.relativePath.split("/").some((part) => part === "" || part === "..")
|
||||||
|
) throw new Error("runtime_asset_relative_path_invalid");
|
||||||
|
return { ...entry, sha256: entry.sha256.toLowerCase() };
|
||||||
|
}).sort((left, right) => {
|
||||||
|
const byVersion = left.resourceVersion.localeCompare(right.resourceVersion);
|
||||||
|
return byVersion || left.assetId.localeCompare(right.assetId);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function derivedCounts(entries) {
|
||||||
|
return {
|
||||||
|
dynamic_fonts: entries.filter((entry) => entry.resourceVersion === "p0a-complex-v1" && /^[a-f0-9]{32}$/.test(entry.assetId)).length,
|
||||||
|
dynamic_images: entries.filter((entry) => entry.resourceVersion === "p0a-complex-v1" && /^DYN\d{3}-/.test(entry.assetId)).length,
|
||||||
|
font_panel_items: entries.filter((entry) => entry.resourceVersion === "p0a-complex-v1" && /^FONT\d{3}$/.test(entry.assetId)).length,
|
||||||
|
static_stickers: entries.filter((entry) => entry.resourceVersion === "p0a-static-v1" && /^STK\d{3,4}$/.test(entry.assetId)).length,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createRuntimeAssetManifest({ counts, entries, sourceManifestSha256 }) {
|
||||||
|
const normalizedEntries = stableEntries(entries);
|
||||||
|
const keys = new Set();
|
||||||
|
const paths = new Set();
|
||||||
|
for (const entry of normalizedEntries) {
|
||||||
|
const key = `${entry.resourceVersion}\u0000${entry.assetId}`;
|
||||||
|
if (keys.has(key)) throw new Error("runtime_asset_id_duplicate");
|
||||||
|
if (paths.has(entry.relativePath)) throw new Error("runtime_asset_path_duplicate");
|
||||||
|
keys.add(key);
|
||||||
|
paths.add(entry.relativePath);
|
||||||
|
}
|
||||||
|
const actualCounts = derivedCounts(normalizedEntries);
|
||||||
|
if (JSON.stringify(counts) !== JSON.stringify(actualCounts)) throw new Error("runtime_asset_counts_invalid");
|
||||||
|
if (sourceManifestSha256 !== undefined && !shaPattern.test(sourceManifestSha256)) {
|
||||||
|
throw new Error("runtime_asset_source_manifest_sha256_invalid");
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
counts: actualCounts,
|
||||||
|
entries: normalizedEntries,
|
||||||
|
root_ref: P0A_RUNTIME_ASSET_ROOT_REF,
|
||||||
|
schema_version: RUNTIME_ASSET_MANIFEST_SCHEMA,
|
||||||
|
source: "external_read_only",
|
||||||
|
...(sourceManifestSha256 ? { source_manifest_sha256: sourceManifestSha256.toLowerCase() } : {}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function readRuntimeAssetManifest(path) {
|
||||||
|
const value = JSON.parse(readFileSync(path, "utf8"));
|
||||||
|
if (
|
||||||
|
value?.schema_version !== RUNTIME_ASSET_MANIFEST_SCHEMA
|
||||||
|
|| value?.source !== "external_read_only"
|
||||||
|
|| value?.root_ref !== P0A_RUNTIME_ASSET_ROOT_REF
|
||||||
|
|| !Array.isArray(value.entries)
|
||||||
|
) throw new Error("runtime_asset_manifest_invalid");
|
||||||
|
return createRuntimeAssetManifest({
|
||||||
|
counts: value.counts,
|
||||||
|
entries: value.entries,
|
||||||
|
...(value.source_manifest_sha256 ? { sourceManifestSha256: value.source_manifest_sha256 } : {}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function serializeRuntimeAssetManifest(manifest) {
|
||||||
|
return `${JSON.stringify(manifest, null, 2)}\n`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function writeRuntimeAssetManifest(path, manifest) {
|
||||||
|
mkdirSync(dirname(path), { recursive: true });
|
||||||
|
writeFileSync(path, serializeRuntimeAssetManifest(manifest));
|
||||||
|
}
|
||||||
|
|
||||||
|
function targetWithinRoot(root, relativePath) {
|
||||||
|
const absoluteRoot = resolve(root);
|
||||||
|
const target = resolve(absoluteRoot, ...relativePath.split("/"));
|
||||||
|
if (target === absoluteRoot || !target.startsWith(`${absoluteRoot}${sep}`)) throw new Error("asset_target_path_invalid");
|
||||||
|
return target;
|
||||||
|
}
|
||||||
|
|
||||||
|
function sameFile(left, right) {
|
||||||
|
const leftStat = statSync(left);
|
||||||
|
const rightStat = statSync(right);
|
||||||
|
return leftStat.dev === rightStat.dev && leftStat.ino === rightStat.ino;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deployRuntimeAssetPlan({ assetRoot, manifest, resources }) {
|
||||||
|
if (!isAbsolute(assetRoot)) throw new Error("asset_root_must_be_absolute");
|
||||||
|
const normalizedManifest = createRuntimeAssetManifest({
|
||||||
|
counts: manifest.counts,
|
||||||
|
entries: manifest.entries,
|
||||||
|
...(manifest.source_manifest_sha256 ? { sourceManifestSha256: manifest.source_manifest_sha256 } : {}),
|
||||||
|
});
|
||||||
|
const entries = new Map(normalizedManifest.entries.map((entry) => [`${entry.resourceVersion}\u0000${entry.assetId}`, entry]));
|
||||||
|
if (resources.length !== entries.size) throw new Error("asset_resource_plan_incomplete");
|
||||||
|
mkdirSync(assetRoot, { recursive: true });
|
||||||
|
for (const resource of resources) {
|
||||||
|
const key = `${resource.entry.resourceVersion}\u0000${resource.entry.assetId}`;
|
||||||
|
const entry = entries.get(key);
|
||||||
|
if (!entry || JSON.stringify(entry) !== JSON.stringify({ ...resource.entry, sha256: resource.entry.sha256.toLowerCase() })) {
|
||||||
|
throw new Error("asset_resource_plan_mismatch");
|
||||||
|
}
|
||||||
|
if (!existsSync(resource.sourcePath) || !statSync(resource.sourcePath).isFile() || lstatSync(resource.sourcePath).isSymbolicLink()) {
|
||||||
|
throw new Error("asset_source_invalid");
|
||||||
|
}
|
||||||
|
if (fileSha256(resource.sourcePath) !== entry.sha256) throw new Error("asset_source_hash_invalid");
|
||||||
|
const targetPath = targetWithinRoot(assetRoot, entry.relativePath);
|
||||||
|
mkdirSync(dirname(targetPath), { recursive: true });
|
||||||
|
if (existsSync(targetPath)) {
|
||||||
|
if (fileSha256(targetPath) !== entry.sha256) throw new Error("asset_target_conflict");
|
||||||
|
if (!sameFile(resource.sourcePath, targetPath)) throw new Error("asset_target_not_hardlink");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
linkSync(resource.sourcePath, targetPath);
|
||||||
|
} catch (error) {
|
||||||
|
if (error && typeof error === "object" && "code" in error && error.code === "EXDEV") {
|
||||||
|
throw new Error("asset_hardlink_volume_mismatch");
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
if (!sameFile(resource.sourcePath, targetPath)) throw new Error("asset_hardlink_verification_failed");
|
||||||
|
}
|
||||||
|
writeRuntimeAssetManifest(join(assetRoot, "manifest.json"), normalizedManifest);
|
||||||
|
return { linked_files: resources.length, manifest: normalizedManifest, status: "ready" };
|
||||||
|
}
|
||||||
|
|
||||||
|
function oneDirectoryWithPrefix(root, prefix) {
|
||||||
|
const matches = readdirSync(root, { withFileTypes: true })
|
||||||
|
.filter((entry) => entry.isDirectory() && entry.name.startsWith(`${prefix}_`));
|
||||||
|
if (matches.length !== 1) throw new Error(`runtime_asset_source_directory_invalid:${prefix}`);
|
||||||
|
return join(root, matches[0].name);
|
||||||
|
}
|
||||||
|
|
||||||
|
function oneSupportedFont(root) {
|
||||||
|
const matches = readdirSync(root, { withFileTypes: true })
|
||||||
|
.filter((entry) => entry.isFile() && fontMimeTypes.has(extname(entry.name).toLowerCase()));
|
||||||
|
if (matches.length !== 1) throw new Error(`runtime_font_source_invalid:${basename(root)}`);
|
||||||
|
return join(root, matches[0].name);
|
||||||
|
}
|
||||||
|
|
||||||
|
function entryFor(sourcePath, assetId, resourceVersion, relativePath, mimeType) {
|
||||||
|
return {
|
||||||
|
assetId,
|
||||||
|
mimeType,
|
||||||
|
relativePath,
|
||||||
|
resourceVersion,
|
||||||
|
rootRef: P0A_RUNTIME_ASSET_ROOT_REF,
|
||||||
|
sha256: fileSha256(sourcePath),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function dynamicMetadata(templateRoot, descriptor, field) {
|
||||||
|
const templateDirectory = join(templateRoot, descriptor.templateId);
|
||||||
|
const metadata = JSON.parse(readFileSync(join(templateDirectory, "metadata.json"), "utf8"));
|
||||||
|
if (!Array.isArray(metadata?.files?.[field]) || !metadata.files[field].includes(descriptor.sourceReference)) {
|
||||||
|
throw new Error(`runtime_dynamic_reference_invalid:${descriptor.assetId}`);
|
||||||
|
}
|
||||||
|
return templateDirectory;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function buildP0aRuntimeAssetPlan({ replicationRoot }) {
|
||||||
|
const [{ compileStaticStickerCatalog }, registry] = await Promise.all([
|
||||||
|
import("../../packages/asset-compiler/dist/index.js"),
|
||||||
|
import("../../packages/template-registry/dist/index.js"),
|
||||||
|
]);
|
||||||
|
const compilerOutput = mkdtempSync(join(tmpdir(), "dada-runtime-asset-plan-"));
|
||||||
|
try {
|
||||||
|
const staticSourceRoot = join(replicationRoot, "sticker_normal");
|
||||||
|
const staticResult = compileStaticStickerCatalog({
|
||||||
|
outputDirectory: compilerOutput,
|
||||||
|
releaseVersion: registry.P0A_STATIC_STICKER_RELEASE_VERSION,
|
||||||
|
sourceRoot: staticSourceRoot,
|
||||||
|
});
|
||||||
|
const resources = staticResult.catalog.items.map((item) => {
|
||||||
|
const sourcePath = join(staticSourceRoot, ...item.relative_path.split("/"));
|
||||||
|
const entry = entryFor(
|
||||||
|
sourcePath,
|
||||||
|
item.stable_id,
|
||||||
|
registry.P0A_STATIC_STICKER_RELEASE_VERSION,
|
||||||
|
`${registry.P0A_STATIC_STICKER_RELEASE_VERSION}/${item.stable_id}.png`,
|
||||||
|
"image/png",
|
||||||
|
);
|
||||||
|
if (entry.sha256 !== item.sha256.toLowerCase()) throw new Error(`static_sticker_hash_invalid:${item.stable_id}`);
|
||||||
|
return { entry, sourcePath };
|
||||||
|
});
|
||||||
|
|
||||||
|
const fontPackagesRoot = join(
|
||||||
|
replicationRoot,
|
||||||
|
"sticker_text",
|
||||||
|
"字体",
|
||||||
|
"面板全量采集",
|
||||||
|
"font_panel_full_20260722",
|
||||||
|
"resources",
|
||||||
|
"font_packages",
|
||||||
|
);
|
||||||
|
for (const assetId of registry.P0A_REQUIRED_FONT_PANEL_IDS) {
|
||||||
|
const packageDirectory = oneDirectoryWithPrefix(fontPackagesRoot, assetId);
|
||||||
|
const sourcePath = oneSupportedFont(join(packageDirectory, "font_files"));
|
||||||
|
const extension = extname(sourcePath).toLowerCase();
|
||||||
|
resources.push({
|
||||||
|
entry: entryFor(
|
||||||
|
sourcePath,
|
||||||
|
assetId,
|
||||||
|
registry.P0A_COMPLEX_RELEASE_VERSION,
|
||||||
|
`${registry.P0A_COMPLEX_RELEASE_VERSION}/${assetId}${extension}`,
|
||||||
|
fontMimeTypes.get(extension),
|
||||||
|
),
|
||||||
|
sourcePath,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const templateRoot = join(replicationRoot, "sticker_interactive", "单模板归档", "templates");
|
||||||
|
for (const descriptor of registry.P0A_DYNAMIC_RUNTIME_FONT_SOURCES) {
|
||||||
|
const templateDirectory = dynamicMetadata(templateRoot, descriptor, "fonts");
|
||||||
|
const sourcePath = oneSupportedFont(join(templateDirectory, ...descriptor.sourceReference.split("/")));
|
||||||
|
const extension = extname(sourcePath).toLowerCase();
|
||||||
|
resources.push({
|
||||||
|
entry: entryFor(
|
||||||
|
sourcePath,
|
||||||
|
descriptor.assetId,
|
||||||
|
registry.P0A_COMPLEX_RELEASE_VERSION,
|
||||||
|
`${registry.P0A_COMPLEX_RELEASE_VERSION}/${descriptor.assetId}${extension}`,
|
||||||
|
fontMimeTypes.get(extension),
|
||||||
|
),
|
||||||
|
sourcePath,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
for (const descriptor of registry.P0A_DYNAMIC_RUNTIME_IMAGE_SOURCES) {
|
||||||
|
const templateDirectory = dynamicMetadata(templateRoot, descriptor, "images");
|
||||||
|
const sourcePath = join(templateDirectory, ...descriptor.sourceReference.split("/"));
|
||||||
|
if (!existsSync(sourcePath) || extname(sourcePath).toLowerCase() !== ".png") {
|
||||||
|
throw new Error(`runtime_dynamic_image_invalid:${descriptor.assetId}`);
|
||||||
|
}
|
||||||
|
resources.push({
|
||||||
|
entry: entryFor(
|
||||||
|
sourcePath,
|
||||||
|
descriptor.assetId,
|
||||||
|
registry.P0A_COMPLEX_RELEASE_VERSION,
|
||||||
|
`${registry.P0A_COMPLEX_RELEASE_VERSION}/${descriptor.assetId}.png`,
|
||||||
|
"image/png",
|
||||||
|
),
|
||||||
|
sourcePath,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const manifestPath = join(replicationRoot, "sticker_web_handoff", "sticker_web_catalog_manifest.json");
|
||||||
|
const manifest = createRuntimeAssetManifest({
|
||||||
|
counts: {
|
||||||
|
dynamic_fonts: registry.P0A_DYNAMIC_RUNTIME_FONT_SOURCES.length,
|
||||||
|
dynamic_images: registry.P0A_DYNAMIC_RUNTIME_IMAGE_SOURCES.length,
|
||||||
|
font_panel_items: registry.P0A_REQUIRED_FONT_PANEL_IDS.length,
|
||||||
|
static_stickers: staticResult.catalog.count,
|
||||||
|
},
|
||||||
|
entries: resources.map((resource) => resource.entry),
|
||||||
|
sourceManifestSha256: fileSha256(manifestPath),
|
||||||
|
});
|
||||||
|
const resourcesByKey = new Map(resources.map((resource) => [`${resource.entry.resourceVersion}\u0000${resource.entry.assetId}`, resource]));
|
||||||
|
return {
|
||||||
|
manifest,
|
||||||
|
resources: manifest.entries.map((entry) => resourcesByKey.get(`${entry.resourceVersion}\u0000${entry.assetId}`)),
|
||||||
|
};
|
||||||
|
} finally {
|
||||||
|
rmSync(compilerOutput, { force: true, recursive: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function defaultReplicationRoot(environment = process.env) {
|
||||||
|
if (!environment.USERPROFILE || !isAbsolute(environment.USERPROFILE)) throw new Error("user_profile_unavailable");
|
||||||
|
return join(environment.USERPROFILE, "Desktop", "sticker_web_replication_assets");
|
||||||
|
}
|
||||||
@@ -40,6 +40,7 @@ internal static class Program
|
|||||||
var supervisor = await TestSupervisorLifecycleAsync();
|
var supervisor = await TestSupervisorLifecycleAsync();
|
||||||
await TestAmapProbeSecurityAsync();
|
await TestAmapProbeSecurityAsync();
|
||||||
TestSecureConfigurationPersistence();
|
TestSecureConfigurationPersistence();
|
||||||
|
TestRuntimeDirectoryBootstrap();
|
||||||
TestStructuredLogging();
|
TestStructuredLogging();
|
||||||
WriteEvidence(Environment.GetEnvironmentVariable("DADA_EVIDENCE_DIR_SEC"), security);
|
WriteEvidence(Environment.GetEnvironmentVariable("DADA_EVIDENCE_DIR_SEC"), security);
|
||||||
WriteEvidence(Environment.GetEnvironmentVariable("DADA_EVIDENCE_DIR_SUP"), supervisor);
|
WriteEvidence(Environment.GetEnvironmentVariable("DADA_EVIDENCE_DIR_SUP"), supervisor);
|
||||||
@@ -125,6 +126,29 @@ internal static class Program
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static void TestRuntimeDirectoryBootstrap()
|
||||||
|
{
|
||||||
|
var root = Path.Combine(Path.GetTempPath(), $"dada-runtime-root-{Guid.NewGuid():N}");
|
||||||
|
try
|
||||||
|
{
|
||||||
|
Directory.CreateDirectory(root);
|
||||||
|
SupervisorRuntime.EnsureRuntimeDirectories(root);
|
||||||
|
foreach (var relativePath in new[]
|
||||||
|
{
|
||||||
|
"db", "content/references", "content/generated", "content/exports",
|
||||||
|
"managed-assets", "derived-assets", "staging",
|
||||||
|
"logs/api", "logs/worker", "logs/supervisor",
|
||||||
|
})
|
||||||
|
{
|
||||||
|
True(Directory.Exists(Path.Combine(root, relativePath)), $"runtime directory missing: {relativePath}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
if (Directory.Exists(root)) Directory.Delete(root, recursive: true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private static async Task<object> TestCredentialBoundaryAsync()
|
private static async Task<object> TestCredentialBoundaryAsync()
|
||||||
{
|
{
|
||||||
var store = new TestCredentialStore();
|
var store = new TestCredentialStore();
|
||||||
@@ -146,6 +170,8 @@ internal static class Program
|
|||||||
True(leakProbe.SensitiveOutputDetected, "credential echo must be detected");
|
True(leakProbe.SensitiveOutputDetected, "credential echo must be detected");
|
||||||
Equal(string.Empty, leakProbe.StandardOutput, "credential echo output discarded");
|
Equal(string.Empty, leakProbe.StandardOutput, "credential echo output discarded");
|
||||||
Equal(string.Empty, leakProbe.StandardError, "credential echo error discarded");
|
Equal(string.Empty, leakProbe.StandardError, "credential echo error discarded");
|
||||||
|
True(AiGatewayProbe.TryValidateOutput("{\"code\":\"ai_probe_passed\",\"mime_type\":\"image/png\",\"pixel_height\":1080,\"pixel_width\":1080,\"real_calls\":1,\"success\":true}", out _), "AI probe success output accepted");
|
||||||
|
False(AiGatewayProbe.TryValidateOutput("{\"code\":\"ai_probe_passed\",\"raw_body\":\"private\",\"real_calls\":1,\"success\":true}", out _), "AI probe private output rejected");
|
||||||
|
|
||||||
var externalArguments = new[]
|
var externalArguments = new[]
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -0,0 +1,74 @@
|
|||||||
|
using System.Diagnostics;
|
||||||
|
using System.Text.Json;
|
||||||
|
|
||||||
|
namespace Dada.Supervisor;
|
||||||
|
|
||||||
|
internal static class AiGatewayProbe
|
||||||
|
{
|
||||||
|
internal static async Task<int> RunAsync(ICredentialStore credentials, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
var node = Path.Combine(AppContext.BaseDirectory, "runtime", "node.exe");
|
||||||
|
var worker = Path.Combine(AppContext.BaseDirectory, "server", "worker.mjs");
|
||||||
|
if (!File.Exists(node) || !File.Exists(worker)) return WriteFailure("ai_probe_runtime_missing", 0);
|
||||||
|
var startInfo = new ProcessStartInfo(node) { WorkingDirectory = AppContext.BaseDirectory };
|
||||||
|
startInfo.Environment["DADA_SQLITE_NATIVE_BINDING"] = Path.Combine(AppContext.BaseDirectory, "server", "native", "better_sqlite3.node");
|
||||||
|
startInfo.ArgumentList.Add(worker);
|
||||||
|
startInfo.ArgumentList.Add("--dada-ai-probe");
|
||||||
|
startInfo.ArgumentList.Add("--dada-credential-stdin");
|
||||||
|
var result = await CredentialProcessLauncher.RunToCompletionAsync(startInfo, ChildRole.Worker, credentials, cancellationToken);
|
||||||
|
if (result.SensitiveOutputDetected || result.StandardError.Length > 0 || !TryValidateOutput(result.StandardOutput, out var sanitized))
|
||||||
|
{
|
||||||
|
return WriteFailure("ai_probe_runtime_failed", 0);
|
||||||
|
}
|
||||||
|
Console.WriteLine(sanitized);
|
||||||
|
return result.ExitCode;
|
||||||
|
}
|
||||||
|
|
||||||
|
internal static bool TryValidateOutput(string output, out string sanitized)
|
||||||
|
{
|
||||||
|
sanitized = string.Empty;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var document = JsonDocument.Parse(output);
|
||||||
|
var root = document.RootElement;
|
||||||
|
if (root.ValueKind != JsonValueKind.Object) return false;
|
||||||
|
var allowed = new HashSet<string>(StringComparer.Ordinal)
|
||||||
|
{
|
||||||
|
"code", "error_category", "mime_type", "pixel_height", "pixel_width", "real_calls", "success",
|
||||||
|
};
|
||||||
|
if (root.EnumerateObject().Any(property => !allowed.Contains(property.Name))) return false;
|
||||||
|
if (!root.TryGetProperty("success", out var success) || success.ValueKind is not (JsonValueKind.True or JsonValueKind.False)) return false;
|
||||||
|
if (!root.TryGetProperty("real_calls", out var realCalls) || realCalls.ValueKind != JsonValueKind.Number || !realCalls.TryGetInt32(out var count) || count is < 0 or > 1) return false;
|
||||||
|
var passed = success.GetBoolean();
|
||||||
|
var code = root.GetProperty("code").GetString();
|
||||||
|
if (passed)
|
||||||
|
{
|
||||||
|
if (code != "ai_probe_passed" || count != 1) return false;
|
||||||
|
var mime = root.GetProperty("mime_type").GetString();
|
||||||
|
if (mime is not ("image/jpeg" or "image/png" or "image/webp")) return false;
|
||||||
|
if (!PositiveDimension(root, "pixel_width") || !PositiveDimension(root, "pixel_height")) return false;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
if (code != "ai_probe_failed" || !root.TryGetProperty("error_category", out var category)
|
||||||
|
|| category.ValueKind != JsonValueKind.String || (category.GetString()?.Length ?? 0) is < 1 or > 64) return false;
|
||||||
|
}
|
||||||
|
sanitized = JsonSerializer.Serialize(root);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
catch (Exception exception) when (exception is JsonException or InvalidOperationException or KeyNotFoundException)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool PositiveDimension(JsonElement root, string name) =>
|
||||||
|
root.TryGetProperty(name, out var value) && value.ValueKind == JsonValueKind.Number
|
||||||
|
&& value.TryGetInt32(out var dimension) && dimension is > 0 and <= 4096;
|
||||||
|
|
||||||
|
private static int WriteFailure(string code, int realCalls)
|
||||||
|
{
|
||||||
|
Console.WriteLine(JsonSerializer.Serialize(new { code, real_calls = realCalls, success = false }));
|
||||||
|
return 2;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -50,7 +50,9 @@ internal static class CredentialProcessLauncher
|
|||||||
var credentials = new Dictionary<string, string>(StringComparer.Ordinal);
|
var credentials = new Dictionary<string, string>(StringComparer.Ordinal);
|
||||||
foreach (var target in CredentialCatalog.RequiredFor(role))
|
foreach (var target in CredentialCatalog.RequiredFor(role))
|
||||||
{
|
{
|
||||||
credentials[target] = store.Read(target) ?? throw new MissingCredentialException(target);
|
var value = store.Read(target);
|
||||||
|
if (role == ChildRole.Worker && string.IsNullOrWhiteSpace(value)) throw new MissingCredentialException(target);
|
||||||
|
credentials[target] = value ?? string.Empty;
|
||||||
}
|
}
|
||||||
|
|
||||||
startInfo.UseShellExecute = false;
|
startInfo.UseShellExecute = false;
|
||||||
@@ -98,7 +100,9 @@ internal static class CredentialProcessLauncher
|
|||||||
var credentials = new Dictionary<string, string>(StringComparer.Ordinal);
|
var credentials = new Dictionary<string, string>(StringComparer.Ordinal);
|
||||||
foreach (var target in CredentialCatalog.RequiredFor(role))
|
foreach (var target in CredentialCatalog.RequiredFor(role))
|
||||||
{
|
{
|
||||||
credentials[target] = store.Read(target) ?? throw new MissingCredentialException(target);
|
var value = store.Read(target);
|
||||||
|
if (role == ChildRole.Worker && string.IsNullOrWhiteSpace(value)) throw new MissingCredentialException(target);
|
||||||
|
credentials[target] = value ?? string.Empty;
|
||||||
}
|
}
|
||||||
|
|
||||||
startInfo.UseShellExecute = false;
|
startInfo.UseShellExecute = false;
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ internal static class OfflineCommandRouter
|
|||||||
return args[0] switch
|
return args[0] switch
|
||||||
{
|
{
|
||||||
"configure" => RunConfigure(args.Skip(1).ToArray()),
|
"configure" => RunConfigure(args.Skip(1).ToArray()),
|
||||||
"secrets" => RunSecrets(args.Skip(1).ToArray(), credentials),
|
"secrets" => await RunSecretsAsync(args.Skip(1).ToArray(), credentials),
|
||||||
"admin-allowlist" => RunAdminAllowlist(args.Skip(1).ToArray(), credentials),
|
"admin-allowlist" => RunAdminAllowlist(args.Skip(1).ToArray(), credentials),
|
||||||
"doctor" when args.Length == 1 => RunDoctor(credentials),
|
"doctor" when args.Length == 1 => RunDoctor(credentials),
|
||||||
"validate-external" => await ControlledExternalValidationLauncher.RunAsync(args.Skip(1).ToArray(), credentials),
|
"validate-external" => await ControlledExternalValidationLauncher.RunAsync(args.Skip(1).ToArray(), credentials),
|
||||||
@@ -66,7 +66,7 @@ internal static class OfflineCommandRouter
|
|||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static int RunSecrets(string[] args, ICredentialStore store)
|
private static async Task<int> RunSecretsAsync(string[] args, ICredentialStore store)
|
||||||
{
|
{
|
||||||
if (args.Length != 2 || !TryResolveCredential(args[1], out var target)) return Usage();
|
if (args.Length != 2 || !TryResolveCredential(args[1], out var target)) return Usage();
|
||||||
switch (args[0])
|
switch (args[0])
|
||||||
@@ -86,6 +86,8 @@ internal static class OfflineCommandRouter
|
|||||||
return 0;
|
return 0;
|
||||||
case "probe" when target == CredentialCatalog.ApiAmap:
|
case "probe" when target == CredentialCatalog.ApiAmap:
|
||||||
return AmapProbe.Run(store.Read(target));
|
return AmapProbe.Run(store.Read(target));
|
||||||
|
case "probe" when target == CredentialCatalog.WorkerAiGateway:
|
||||||
|
return await AiGatewayProbe.RunAsync(store);
|
||||||
default:
|
default:
|
||||||
return Usage();
|
return Usage();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,7 +30,10 @@ internal static class Program
|
|||||||
};
|
};
|
||||||
var state = await runtime.StartAsync();
|
var state = await runtime.StartAsync();
|
||||||
if (!form.IsDisposed) form.SetState(state);
|
if (!form.IsDisposed) form.SetState(state);
|
||||||
if (state == SupervisorState.Ready) SupervisorForm.OpenProductInSupportedBrowser();
|
if (state == SupervisorState.Ready && !SupervisorForm.OpenProductInSupportedBrowser())
|
||||||
|
{
|
||||||
|
form.SetBrowserLaunchFailure();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
form.Shown += async (_, _) => await StartRuntimeAsync();
|
form.Shown += async (_, _) => await StartRuntimeAsync();
|
||||||
form.RestartRequested += async () => await StartRuntimeAsync();
|
form.RestartRequested += async () => await StartRuntimeAsync();
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ internal sealed class SupervisorForm : Form
|
|||||||
Font = new Font("Segoe UI", 9F);
|
Font = new Font("Segoe UI", 9F);
|
||||||
FormBorderStyle = FormBorderStyle.FixedDialog;
|
FormBorderStyle = FormBorderStyle.FixedDialog;
|
||||||
MaximizeBox = false;
|
MaximizeBox = false;
|
||||||
|
ShowInTaskbar = true;
|
||||||
StartPosition = FormStartPosition.CenterScreen;
|
StartPosition = FormStartPosition.CenterScreen;
|
||||||
Text = "Dada";
|
Text = "Dada";
|
||||||
|
|
||||||
@@ -90,10 +91,6 @@ internal sealed class SupervisorForm : Form
|
|||||||
trayIcon.DoubleClick += (_, _) => RestoreWindow();
|
trayIcon.DoubleClick += (_, _) => RestoreWindow();
|
||||||
|
|
||||||
FormClosing += (_, _) => trayIcon.Visible = false;
|
FormClosing += (_, _) => trayIcon.Visible = false;
|
||||||
Resize += (_, _) =>
|
|
||||||
{
|
|
||||||
if (WindowState == FormWindowState.Minimized) Hide();
|
|
||||||
};
|
|
||||||
SetState(initialState);
|
SetState(initialState);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -139,6 +136,14 @@ internal sealed class SupervisorForm : Form
|
|||||||
Activate();
|
Activate();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
internal void SetBrowserLaunchFailure()
|
||||||
|
{
|
||||||
|
if (state == SupervisorState.Ready)
|
||||||
|
{
|
||||||
|
statusDetail.Text = "本机服务运行正常,但未能自动打开浏览器;请点击“打开 Dada”或选择浏览器。";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
protected override void Dispose(bool disposing)
|
protected override void Dispose(bool disposing)
|
||||||
{
|
{
|
||||||
if (disposing) trayIcon.Dispose();
|
if (disposing) trayIcon.Dispose();
|
||||||
|
|||||||
@@ -1,9 +1,23 @@
|
|||||||
using System.Diagnostics;
|
using System.Diagnostics;
|
||||||
|
using System.Security.Cryptography;
|
||||||
|
|
||||||
namespace Dada.Supervisor;
|
namespace Dada.Supervisor;
|
||||||
|
|
||||||
internal sealed class SupervisorRuntime : IAsyncDisposable
|
internal sealed class SupervisorRuntime : IAsyncDisposable
|
||||||
{
|
{
|
||||||
|
private static readonly string[] RequiredDataDirectories =
|
||||||
|
[
|
||||||
|
"db",
|
||||||
|
Path.Combine("content", "references"),
|
||||||
|
Path.Combine("content", "generated"),
|
||||||
|
Path.Combine("content", "exports"),
|
||||||
|
"managed-assets",
|
||||||
|
"derived-assets",
|
||||||
|
"staging",
|
||||||
|
Path.Combine("logs", "api"),
|
||||||
|
Path.Combine("logs", "worker"),
|
||||||
|
Path.Combine("logs", "supervisor"),
|
||||||
|
];
|
||||||
private readonly ICredentialStore credentials;
|
private readonly ICredentialStore credentials;
|
||||||
private ManagedComponentSupervisor? api;
|
private ManagedComponentSupervisor? api;
|
||||||
private ManagedComponentSupervisor? worker;
|
private ManagedComponentSupervisor? worker;
|
||||||
@@ -25,6 +39,7 @@ internal sealed class SupervisorRuntime : IAsyncDisposable
|
|||||||
{
|
{
|
||||||
return SupervisorState.StartupFailed;
|
return SupervisorState.StartupFailed;
|
||||||
}
|
}
|
||||||
|
EnsureRuntimeDirectories(configuration.LocalDataRoot);
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
logger = new StructuredJsonlLogger(Path.Combine(configuration.LocalDataRoot, "logs", "supervisor"), "supervisor");
|
logger = new StructuredJsonlLogger(Path.Combine(configuration.LocalDataRoot, "logs", "supervisor"), "supervisor");
|
||||||
@@ -34,22 +49,45 @@ internal sealed class SupervisorRuntime : IAsyncDisposable
|
|||||||
{
|
{
|
||||||
return SupervisorState.StorageUnavailable;
|
return SupervisorState.StorageUnavailable;
|
||||||
}
|
}
|
||||||
if (CredentialCatalog.RequiredFor(ChildRole.Api).Concat(CredentialCatalog.RequiredFor(ChildRole.Worker)).Any(target => !credentials.IsConfigured(target)))
|
EnsureAdminPepper();
|
||||||
{
|
|
||||||
return SupervisorState.StartupFailed;
|
|
||||||
}
|
|
||||||
|
|
||||||
var node = Path.Combine(AppContext.BaseDirectory, "runtime", "node.exe");
|
var node = Path.Combine(AppContext.BaseDirectory, "runtime", "node.exe");
|
||||||
var apiEntry = Path.Combine(AppContext.BaseDirectory, "server", "api.mjs");
|
var apiEntry = Path.Combine(AppContext.BaseDirectory, "server", "api.mjs");
|
||||||
var workerEntry = Path.Combine(AppContext.BaseDirectory, "server", "worker.mjs");
|
var workerEntry = Path.Combine(AppContext.BaseDirectory, "server", "worker.mjs");
|
||||||
if (!File.Exists(node) || !File.Exists(apiEntry) || !File.Exists(workerEntry)) return SupervisorState.StartupFailed;
|
if (!File.Exists(node) || !File.Exists(apiEntry) || !File.Exists(workerEntry)) return SupervisorState.StartupFailed;
|
||||||
|
|
||||||
api = CreateComponent(node, apiEntry, ChildRole.Api, SupervisorState.ApiDegraded);
|
try
|
||||||
await api.StartAsync(cancellationToken);
|
{
|
||||||
|
api = CreateComponent(node, apiEntry, ChildRole.Api, SupervisorState.ApiDegraded);
|
||||||
|
await api.StartAsync(cancellationToken);
|
||||||
|
|
||||||
worker = CreateComponent(node, workerEntry, ChildRole.Worker, SupervisorState.WorkerDegraded);
|
worker = CreateComponent(node, workerEntry, ChildRole.Worker, SupervisorState.WorkerDegraded);
|
||||||
await worker.StartAsync(cancellationToken);
|
await worker.StartAsync(cancellationToken);
|
||||||
return TryLog(new StructuredLogEvent("ready", ErrorCategory: "none")) ? SupervisorState.Ready : SupervisorState.StorageUnavailable;
|
return TryLog(new StructuredLogEvent("ready", ErrorCategory: "none")) ? SupervisorState.Ready : SupervisorState.StorageUnavailable;
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
await StopComponentsAsync();
|
||||||
|
return TryLog(new StructuredLogEvent("failed", ErrorCategory: "service_unavailable"))
|
||||||
|
? SupervisorState.StartupFailed
|
||||||
|
: SupervisorState.StorageUnavailable;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal static void EnsureRuntimeDirectories(string dataRoot)
|
||||||
|
{
|
||||||
|
foreach (var directory in RequiredDataDirectories)
|
||||||
|
{
|
||||||
|
Directory.CreateDirectory(Path.Combine(dataRoot, directory));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
private ManagedComponentSupervisor CreateComponent(string node, string entry, ChildRole role, SupervisorState degradedState)
|
||||||
@@ -60,6 +98,7 @@ internal sealed class SupervisorRuntime : IAsyncDisposable
|
|||||||
startInfo.WorkingDirectory = AppContext.BaseDirectory;
|
startInfo.WorkingDirectory = AppContext.BaseDirectory;
|
||||||
startInfo.Environment["DADA_SQLITE_NATIVE_BINDING"] = Path.Combine(AppContext.BaseDirectory, "server", "native", "better_sqlite3.node");
|
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_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.Environment["DADA_INSTANCE_CONFIG_PATH"] = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "Dada", "P0A", "config", "instance.json");
|
||||||
startInfo.ArgumentList.Add(entry);
|
startInfo.ArgumentList.Add(entry);
|
||||||
var child = await ManagedChildProcess.StartAsync(startInfo, role, credentials, cancellationToken);
|
var child = await ManagedChildProcess.StartAsync(startInfo, role, credentials, cancellationToken);
|
||||||
@@ -95,10 +134,26 @@ internal sealed class SupervisorRuntime : IAsyncDisposable
|
|||||||
}
|
}
|
||||||
|
|
||||||
public async ValueTask DisposeAsync()
|
public async ValueTask DisposeAsync()
|
||||||
|
{
|
||||||
|
await StopComponentsAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task StopComponentsAsync()
|
||||||
{
|
{
|
||||||
var stops = new List<Task>();
|
var stops = new List<Task>();
|
||||||
if (worker is not null) stops.Add(worker.DisposeAsync().AsTask());
|
if (worker is not null) stops.Add(worker.DisposeAsync().AsTask());
|
||||||
if (api is not null) stops.Add(api.DisposeAsync().AsTask());
|
if (api is not null) stops.Add(api.DisposeAsync().AsTask());
|
||||||
await Task.WhenAll(stops);
|
try
|
||||||
|
{
|
||||||
|
await Task.WhenAll(stops);
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
worker = null;
|
||||||
|
api = null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
using System.ComponentModel;
|
||||||
using System.Diagnostics;
|
using System.Diagnostics;
|
||||||
using Microsoft.Win32;
|
using Microsoft.Win32;
|
||||||
|
|
||||||
@@ -13,10 +14,20 @@ internal static class SupportedBrowserLauncher
|
|||||||
{
|
{
|
||||||
var executable = FindExecutable(executableName);
|
var executable = FindExecutable(executableName);
|
||||||
if (executable is null) return false;
|
if (executable is null) return false;
|
||||||
var startInfo = new ProcessStartInfo(executable) { UseShellExecute = false };
|
try
|
||||||
startInfo.ArgumentList.Add(uri.AbsoluteUri);
|
{
|
||||||
Process.Start(startInfo);
|
var startInfo = new ProcessStartInfo(executable) { UseShellExecute = false };
|
||||||
return true;
|
startInfo.ArgumentList.Add(uri.AbsoluteUri);
|
||||||
|
return Process.Start(startInfo) is not null;
|
||||||
|
}
|
||||||
|
catch (Win32Exception)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
catch (InvalidOperationException)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private static string? FindExecutable(string executableName)
|
private static string? FindExecutable(string executableName)
|
||||||
|
|||||||
@@ -0,0 +1,100 @@
|
|||||||
|
import { readFileSync } from "node:fs";
|
||||||
|
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
import { GenerationModelConfigurationCatalog, type ModelConfigurationView } from "../../apps/api/src/model-configuration.js";
|
||||||
|
|
||||||
|
describe("POSTV1-04 generation runtime wiring", () => {
|
||||||
|
it("constructs, injects and closes the production generation submission service", () => {
|
||||||
|
const main = readFileSync("apps/api/src/main.ts", "utf8");
|
||||||
|
|
||||||
|
expect(main).toContain('import { GenerationSubmissionService } from "./generation-submission.js";');
|
||||||
|
expect(main).toContain("let generations: GenerationSubmissionService | undefined;");
|
||||||
|
expect(main).toContain("generations = new GenerationSubmissionService({");
|
||||||
|
expect(main).toContain("models: new GenerationModelConfigurationCatalog(models),");
|
||||||
|
expect(main).toContain("...(generations ? { generations } : {}),");
|
||||||
|
expect(main).toContain("generations?.close();");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("maps the current model configuration into the generation submission contract", () => {
|
||||||
|
const configuration: ModelConfigurationView = {
|
||||||
|
config_set_version: 7,
|
||||||
|
configured_default_model_id: "gemini-3.1-flash-image-preview",
|
||||||
|
recommended_model_id: "gemini-3.1-flash-image-preview",
|
||||||
|
models: [{
|
||||||
|
config_version: 3,
|
||||||
|
contract_evidence_ref: "fixture-contract",
|
||||||
|
contract_validation_status: "verified",
|
||||||
|
credit_cost: 2,
|
||||||
|
display_name: "Fixture model",
|
||||||
|
enabled: true,
|
||||||
|
error_mapping_profile: {},
|
||||||
|
gateway_account_ref: "fixture-gateway",
|
||||||
|
is_default: true,
|
||||||
|
model_id: "gemini-3.1-flash-image-preview",
|
||||||
|
prompt_max_length: 1_000,
|
||||||
|
recommendation_priority: 1,
|
||||||
|
reference_limits: { max_file_bytes: 10, max_files: 2, max_total_bytes: 20 },
|
||||||
|
route_profile: {},
|
||||||
|
runtime_availability: { available_for_new_jobs: true, checked_at: "2026-08-05T00:00:00.000Z", reason: "available" },
|
||||||
|
safety_source: "provider",
|
||||||
|
supported_ratios: ["3:4", "invalid"],
|
||||||
|
}],
|
||||||
|
};
|
||||||
|
const catalog = new GenerationModelConfigurationCatalog({ read: () => configuration });
|
||||||
|
|
||||||
|
expect(catalog.readModel("gemini-3.1-flash-image-preview")).toEqual({
|
||||||
|
configSetVersion: 7,
|
||||||
|
configVersion: 3,
|
||||||
|
contractValidationStatus: "verified",
|
||||||
|
creditCost: 2,
|
||||||
|
enabled: true,
|
||||||
|
modelId: "gemini-3.1-flash-image-preview",
|
||||||
|
promptMaxLength: 1_000,
|
||||||
|
referenceLimits: { maxFileBytes: 10, maxFiles: 2, maxTotalBytes: 20 },
|
||||||
|
runtimeAvailability: { availableForNewJobs: true, reason: null },
|
||||||
|
supportedRatios: ["3:4"],
|
||||||
|
});
|
||||||
|
expect(catalog.readModel("missing")).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("maps model and contract blocks into generation error categories", () => {
|
||||||
|
const configuration = {
|
||||||
|
config_set_version: 1,
|
||||||
|
configured_default_model_id: "gemini-3.1-flash-image-preview",
|
||||||
|
recommended_model_id: null,
|
||||||
|
models: [],
|
||||||
|
} satisfies ModelConfigurationView;
|
||||||
|
const catalog = new GenerationModelConfigurationCatalog({ read: () => configuration });
|
||||||
|
const base = {
|
||||||
|
config_version: 1,
|
||||||
|
contract_evidence_ref: null,
|
||||||
|
contract_validation_status: "verified" as const,
|
||||||
|
credit_cost: 1,
|
||||||
|
display_name: "Fixture model",
|
||||||
|
enabled: true,
|
||||||
|
error_mapping_profile: {},
|
||||||
|
gateway_account_ref: "fixture-gateway",
|
||||||
|
is_default: true,
|
||||||
|
model_id: "gemini-3.1-flash-image-preview" as const,
|
||||||
|
prompt_max_length: 1_000,
|
||||||
|
recommendation_priority: 1,
|
||||||
|
reference_limits: { max_file_bytes: 10, max_files: 2, max_total_bytes: 20 },
|
||||||
|
route_profile: {},
|
||||||
|
safety_source: "provider",
|
||||||
|
supported_ratios: ["3:4"],
|
||||||
|
};
|
||||||
|
|
||||||
|
configuration.models = [{
|
||||||
|
...base,
|
||||||
|
runtime_availability: { available_for_new_jobs: false, checked_at: "2026-08-05T00:00:00.000Z", reason: "contract_blocked" },
|
||||||
|
}];
|
||||||
|
expect(catalog.readModel(base.model_id)?.runtimeAvailability.reason).toBe("gateway_contract_invalid");
|
||||||
|
|
||||||
|
configuration.models = [{
|
||||||
|
...base,
|
||||||
|
runtime_availability: { available_for_new_jobs: false, checked_at: "2026-08-05T00:00:00.000Z", reason: "worker_degraded" },
|
||||||
|
}];
|
||||||
|
expect(catalog.readModel(base.model_id)?.runtimeAvailability.reason).toBe("model_disabled");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
import { mkdtempSync, rmSync } from "node:fs";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import { join } from "node:path";
|
||||||
|
|
||||||
|
import { afterEach, describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
import { createApp } from "../../apps/api/src/app.js";
|
||||||
|
import { RegistrationService } from "../../apps/api/src/registration.js";
|
||||||
|
import { MockResendAdapter } from "../../apps/api/src/resend-adapter.js";
|
||||||
|
|
||||||
|
const fixedNow = Date.parse("2026-08-05T06:00:00.000Z");
|
||||||
|
const writeHeaders = { host: "127.0.0.1:43121", origin: "http://127.0.0.1:43121" };
|
||||||
|
const roots: string[] = [];
|
||||||
|
const services: RegistrationService[] = [];
|
||||||
|
|
||||||
|
function createRegistrationService() {
|
||||||
|
const root = mkdtempSync(join(tmpdir(), "dada-local-test-session-"));
|
||||||
|
roots.push(root);
|
||||||
|
const registration = new RegistrationService({
|
||||||
|
challengePepper: Buffer.alloc(32, 0x51),
|
||||||
|
clock: () => fixedNow,
|
||||||
|
currentPrivacyNoticeVersion: "p0a-notice-v1",
|
||||||
|
databasePath: join(root, "dada.sqlite3"),
|
||||||
|
invitePepper: Buffer.alloc(32, 0x52),
|
||||||
|
resend: new MockResendAdapter(),
|
||||||
|
sessionPepper: Buffer.alloc(32, 0x53),
|
||||||
|
});
|
||||||
|
services.push(registration);
|
||||||
|
return registration;
|
||||||
|
}
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
for (const service of services.splice(0)) {
|
||||||
|
try { service.close(); } catch { /* already closed by the test */ }
|
||||||
|
}
|
||||||
|
for (const root of roots.splice(0)) rmSync(root, { force: true, recursive: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("POSTV1-03 local test session", () => {
|
||||||
|
it("does not expose the local test route unless explicitly enabled", async () => {
|
||||||
|
const registration = createRegistrationService();
|
||||||
|
const app = await createApp({ browserGate: false, networkBoundary: { allowTestPort: true }, registration });
|
||||||
|
|
||||||
|
const status = await app.inject({ headers: writeHeaders, method: "GET", url: "/api/v1/auth/local-test" });
|
||||||
|
const created = await app.inject({ headers: writeHeaders, method: "POST", url: "/api/v1/auth/local-test" });
|
||||||
|
|
||||||
|
expect(status.statusCode).toBe(404);
|
||||||
|
expect(created.statusCode).toBe(404);
|
||||||
|
await app.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("creates one isolated fixture account and restores it without duplicate credits", async () => {
|
||||||
|
const registration = createRegistrationService();
|
||||||
|
const app = await createApp({
|
||||||
|
browserGate: false,
|
||||||
|
localTestAuth: true,
|
||||||
|
networkBoundary: { allowTestPort: true },
|
||||||
|
registration,
|
||||||
|
});
|
||||||
|
|
||||||
|
const status = await app.inject({ headers: writeHeaders, method: "GET", url: "/api/v1/auth/local-test" });
|
||||||
|
expect(status.statusCode).toBe(200);
|
||||||
|
expect(status.json()).toEqual({ available: true });
|
||||||
|
|
||||||
|
const first = await app.inject({ headers: writeHeaders, method: "POST", url: "/api/v1/auth/local-test" });
|
||||||
|
expect(first.statusCode).toBe(200);
|
||||||
|
expect(first.json()).toMatchObject({
|
||||||
|
audience: "user",
|
||||||
|
credits: { available_balance: 10, reserved_balance: 0 },
|
||||||
|
status: "authenticated",
|
||||||
|
user: { creator_name: "本机测试用户", role: "user", social_id: "@dada_local_test", status: "active" },
|
||||||
|
});
|
||||||
|
expect(first.headers["set-cookie"]).toContain("dada_session=");
|
||||||
|
|
||||||
|
const session = await app.inject({
|
||||||
|
headers: { cookie: first.headers["set-cookie"], host: "127.0.0.1:43121" },
|
||||||
|
method: "GET",
|
||||||
|
url: "/api/v1/auth/session",
|
||||||
|
});
|
||||||
|
expect(session.statusCode).toBe(200);
|
||||||
|
expect(session.json()).toMatchObject({ authenticated: true, credits: { available_balance: 10 } });
|
||||||
|
|
||||||
|
const second = await app.inject({ headers: writeHeaders, method: "POST", url: "/api/v1/auth/local-test" });
|
||||||
|
expect(second.statusCode).toBe(200);
|
||||||
|
expect(second.json().user.user_id).toBe(first.json().user.user_id);
|
||||||
|
expect(registration.database.prepare("SELECT COUNT(*) AS count FROM users").get()).toEqual({ count: 1 });
|
||||||
|
expect(registration.database.prepare("SELECT COUNT(*) AS count FROM credit_ledger").get()).toEqual({ count: 1 });
|
||||||
|
expect(registration.database.prepare("SELECT counts_toward_stage_limit FROM users").get()).toEqual({ counts_toward_stage_limit: 0 });
|
||||||
|
|
||||||
|
const openapi = JSON.stringify(app.swagger());
|
||||||
|
expect(openapi).not.toContain("/api/v1/auth/local-test");
|
||||||
|
expect(openapi).not.toContain("local-test-user");
|
||||||
|
await app.close();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
import { createHash } from "node:crypto";
|
||||||
|
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import { join, resolve } from "node:path";
|
||||||
|
|
||||||
|
import { afterEach, describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
import { loadConfiguredRuntimeAssets } from "../../apps/api/src/runtime-assets.js";
|
||||||
|
|
||||||
|
const temporaryDirectories: string[] = [];
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
for (const directory of temporaryDirectories.splice(0)) {
|
||||||
|
if (resolve(directory).startsWith(resolve(tmpdir()))) rmSync(directory, { force: true, recursive: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function fixture() {
|
||||||
|
const base = mkdtempSync(join(tmpdir(), "dada-postv1-assets-"));
|
||||||
|
temporaryDirectories.push(base);
|
||||||
|
const assetRoot = join(base, "assets");
|
||||||
|
const dataRoot = join(base, "data");
|
||||||
|
const configFile = join(base, "instance.json");
|
||||||
|
const trustedManifestPath = join(base, "trusted-manifest.json");
|
||||||
|
const bytes = Buffer.from("synthetic sticker bytes");
|
||||||
|
const entry = {
|
||||||
|
assetId: "STK001",
|
||||||
|
mimeType: "image/png",
|
||||||
|
relativePath: "p0a-static-v1/STK001.png",
|
||||||
|
resourceVersion: "p0a-static-v1",
|
||||||
|
rootRef: "p0a_runtime_assets",
|
||||||
|
sha256: createHash("sha256").update(bytes).digest("hex"),
|
||||||
|
};
|
||||||
|
const manifest = {
|
||||||
|
counts: { dynamic_fonts: 0, dynamic_images: 0, font_panel_items: 0, static_stickers: 1 },
|
||||||
|
entries: [entry],
|
||||||
|
root_ref: "p0a_runtime_assets",
|
||||||
|
schema_version: "DadaRuntimeAssets/v1",
|
||||||
|
source: "external_read_only",
|
||||||
|
};
|
||||||
|
mkdirSync(join(assetRoot, "p0a-static-v1"), { recursive: true });
|
||||||
|
writeFileSync(join(assetRoot, entry.relativePath), bytes);
|
||||||
|
writeFileSync(join(assetRoot, "manifest.json"), `${JSON.stringify(manifest, null, 2)}\n`);
|
||||||
|
writeFileSync(trustedManifestPath, `${JSON.stringify(manifest, null, 2)}\n`);
|
||||||
|
writeFileSync(configFile, JSON.stringify({ asset_root: assetRoot }));
|
||||||
|
return { assetRoot, configFile, dataRoot, trustedManifestPath };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("POSTV1-06 portable runtime assets", () => {
|
||||||
|
it("activates a validated external asset root without exposing its path", () => {
|
||||||
|
const input = fixture();
|
||||||
|
const loaded = loadConfiguredRuntimeAssets(input);
|
||||||
|
|
||||||
|
expect(loaded.state).toMatchObject({ configured: true, pause_reason: null, status: "active" });
|
||||||
|
expect(loaded.publicAssets?.read("p0a-static-v1", "STK001")?.bytes.toString()).toBe("synthetic sticker bytes");
|
||||||
|
expect(JSON.stringify(loaded.state)).not.toContain(input.assetRoot);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects a changed external manifest and leaves unrelated API features available", () => {
|
||||||
|
const input = fixture();
|
||||||
|
writeFileSync(join(input.assetRoot, "manifest.json"), "{}\n");
|
||||||
|
const loaded = loadConfiguredRuntimeAssets(input);
|
||||||
|
|
||||||
|
expect(loaded.publicAssets).toBeUndefined();
|
||||||
|
expect(loaded.state).toMatchObject({ configured: true, pause_reason: "asset_manifest_invalid", status: "unavailable" });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import { createRequire } from "node:module";
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
import {
|
||||||
|
ModelConfigurationService,
|
||||||
|
portableRuntimeModelCandidates,
|
||||||
|
} from "../../apps/api/src/model-configuration.js";
|
||||||
|
|
||||||
|
const requireFromApi = createRequire(new URL("../../apps/api/package.json", import.meta.url));
|
||||||
|
const Database = requireFromApi("better-sqlite3") as new (path: string) => {
|
||||||
|
close(): void;
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("POSTV1-02 portable runtime model seed", () => {
|
||||||
|
it("enables only models backed by the real OneAPI contract", () => {
|
||||||
|
const database = new Database(":memory:");
|
||||||
|
try {
|
||||||
|
const models = new ModelConfigurationService({ database, seedCandidates: portableRuntimeModelCandidates }).read();
|
||||||
|
const flash = models.models.find((model) => model.model_id === "gemini-3.1-flash-image-preview");
|
||||||
|
const pro = models.models.find((model) => model.model_id === "gemini-3-pro-image-preview");
|
||||||
|
const gpt = models.models.find((model) => model.model_id === "gpt-image-2");
|
||||||
|
|
||||||
|
expect(models.configured_default_model_id).toBe("gemini-3.1-flash-image-preview");
|
||||||
|
expect(flash).toMatchObject({
|
||||||
|
contract_validation_status: "verified",
|
||||||
|
enabled: true,
|
||||||
|
runtime_availability: { available_for_new_jobs: true, reason: "available" },
|
||||||
|
});
|
||||||
|
expect(flash?.route_profile).toMatchObject({ endpoint: "https://oneapi.intelligrow.cn/v1/chat/completions" });
|
||||||
|
expect(pro).toMatchObject({
|
||||||
|
contract_validation_status: "unverified",
|
||||||
|
enabled: false,
|
||||||
|
runtime_availability: { available_for_new_jobs: false, reason: "configured_disabled" },
|
||||||
|
});
|
||||||
|
expect(gpt).toMatchObject({
|
||||||
|
contract_validation_status: "verified",
|
||||||
|
enabled: true,
|
||||||
|
runtime_availability: { available_for_new_jobs: true, reason: "available" },
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
database.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -125,7 +125,12 @@ afterAll(async () => {
|
|||||||
|
|
||||||
describe("TDD-WP0-BRW-001 supported browser contract", () => {
|
describe("TDD-WP0-BRW-001 supported browser contract", () => {
|
||||||
it("cross-checks UA-CH and issues only a short-lived signed support cookie", async () => {
|
it("cross-checks UA-CH and issues only a short-lived signed support cookie", async () => {
|
||||||
const app = await createApp({ browserSupportRelease: testBrowserSupportRelease } as never);
|
const app = await createApp({
|
||||||
|
browserSupportRelease: testBrowserSupportRelease,
|
||||||
|
productIndexHtml: "<!doctype html><title>Dada product test</title><div id=\"root\"></div>",
|
||||||
|
} as never);
|
||||||
|
const gate = await app.inject({ headers: { host: "127.0.0.1:43121" }, method: "GET", url: "/" });
|
||||||
|
expect(gate.body).toContain("当前浏览器无法使用 Dada");
|
||||||
const checked = await app.inject({
|
const checked = await app.inject({
|
||||||
headers: supportedEdge.headers,
|
headers: supportedEdge.headers,
|
||||||
method: "POST",
|
method: "POST",
|
||||||
@@ -150,6 +155,14 @@ describe("TDD-WP0-BRW-001 supported browser contract", () => {
|
|||||||
|
|
||||||
const cookie = supportCookie(checked);
|
const cookie = supportCookie(checked);
|
||||||
expect(cookie).toBeDefined();
|
expect(cookie).toBeDefined();
|
||||||
|
const productHtml = await app.inject({
|
||||||
|
headers: { cookie, host: "127.0.0.1:43121", "sec-ch-ua": supportedEdge.headers["sec-ch-ua"] },
|
||||||
|
method: "GET",
|
||||||
|
url: "/app",
|
||||||
|
});
|
||||||
|
expect(productHtml.statusCode).toBe(200);
|
||||||
|
expect(productHtml.body).toContain("Dada product test");
|
||||||
|
expect(productHtml.body).not.toContain("当前浏览器无法使用 Dada");
|
||||||
const product = await app.inject({
|
const product = await app.inject({
|
||||||
headers: {
|
headers: {
|
||||||
cookie,
|
cookie,
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ describe("TDD-WP0-DATA-001-root-validation resource boundary", () => {
|
|||||||
const assetRoot = join(base, "read-only-assets");
|
const assetRoot = join(base, "read-only-assets");
|
||||||
const relativePath = "images/source.png";
|
const relativePath = "images/source.png";
|
||||||
const bytes = Buffer.from("synthetic png fixture");
|
const bytes = Buffer.from("synthetic png fixture");
|
||||||
const assetId = randomUUID();
|
const assetId = "STK001";
|
||||||
mkdirSync(join(assetRoot, "images"), { recursive: true });
|
mkdirSync(join(assetRoot, "images"), { recursive: true });
|
||||||
writeFileSync(join(assetRoot, relativePath), bytes);
|
writeFileSync(join(assetRoot, relativePath), bytes);
|
||||||
const manifest = JSON.stringify({ assets: [{ asset_id: assetId, relative_path: relativePath }] });
|
const manifest = JSON.stringify({ assets: [{ asset_id: assetId, relative_path: relativePath }] });
|
||||||
|
|||||||
@@ -40,8 +40,23 @@ describe("TDD-WP7-EXT-003 production Amap adapter", () => {
|
|||||||
|
|
||||||
const clients = initializeApiCredentialClients(credentials);
|
const clients = initializeApiCredentialClients(credentials);
|
||||||
expect(clients.amap).toBeInstanceOf(RealAmapAdapter);
|
expect(clients.amap).toBeInstanceOf(RealAmapAdapter);
|
||||||
|
expect(clients.resendConfigured).toBe(true);
|
||||||
expect(Object.values(credentials)).toEqual(["", "", ""]);
|
expect(Object.values(credentials)).toEqual(["", "", ""]);
|
||||||
clients.amap.dispose();
|
clients.amap.dispose?.();
|
||||||
|
clients.adminAllowlistPepper.fill(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reports an empty Resend credential without retaining its value", () => {
|
||||||
|
const credentials = {
|
||||||
|
"Dada/P0A/admin/pepper": "fixture-admin-value",
|
||||||
|
"Dada/P0A/api/amap": "",
|
||||||
|
"Dada/P0A/api/resend": "",
|
||||||
|
};
|
||||||
|
|
||||||
|
const clients = initializeApiCredentialClients(credentials);
|
||||||
|
expect(clients.resendConfigured).toBe(false);
|
||||||
|
expect(Object.values(credentials)).toEqual(["", "", ""]);
|
||||||
|
clients.amap.dispose?.();
|
||||||
clients.adminAllowlistPepper.fill(0);
|
clients.adminAllowlistPepper.fill(0);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -23,6 +23,29 @@ test.beforeAll(async () => {
|
|||||||
|
|
||||||
test.afterAll(async () => vite.close());
|
test.afterAll(async () => vite.close());
|
||||||
|
|
||||||
|
test("POSTV1-03 enters the workspace through the local test session", async ({ page }) => {
|
||||||
|
await page.route("**/api/v1/auth/local-test", (route) => {
|
||||||
|
if (route.request().method() === "GET") {
|
||||||
|
return route.fulfill({ contentType: "application/json", status: 200, body: JSON.stringify({ available: true }) });
|
||||||
|
}
|
||||||
|
expect(route.request().postData()).toBeNull();
|
||||||
|
return route.fulfill({ contentType: "application/json", status: 200, body: JSON.stringify({ status: "authenticated" }) });
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.goto(webUrl);
|
||||||
|
const button = page.getByRole("button", { name: "直接进入本机测试" });
|
||||||
|
await expect(button).toBeVisible();
|
||||||
|
await button.click();
|
||||||
|
|
||||||
|
await expect(page).toHaveURL(`${webUrl}/app`);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("POSTV1-03 hides the local test entry when the API does not enable it", async ({ page }) => {
|
||||||
|
await page.route("**/api/v1/auth/local-test", (route) => route.fulfill({ status: 404, body: "" }));
|
||||||
|
await page.goto(webUrl);
|
||||||
|
await expect(page.getByRole("button", { name: "直接进入本机测试" })).toHaveCount(0);
|
||||||
|
});
|
||||||
|
|
||||||
test("TDD-WP1-NOTICE-001 expands DVPM8 only after successful code delivery", async ({ page }) => {
|
test("TDD-WP1-NOTICE-001 expands DVPM8 only after successful code delivery", async ({ page }) => {
|
||||||
await page.route("**/api/v1/auth/register/send", (route) => route.fulfill({
|
await page.route("**/api/v1/auth/register/send", (route) => route.fulfill({
|
||||||
contentType: "application/json",
|
contentType: "application/json",
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import {
|
|||||||
defaultLocalDataRoot,
|
defaultLocalDataRoot,
|
||||||
initializeLocalDataRoot,
|
initializeLocalDataRoot,
|
||||||
inspectInitializedLocalDataRoot,
|
inspectInitializedLocalDataRoot,
|
||||||
|
readConfiguredAssetRoot,
|
||||||
resolvePathWithinRoot,
|
resolvePathWithinRoot,
|
||||||
validateLocalDataRoot,
|
validateLocalDataRoot,
|
||||||
validateReadOnlyAssetRoot,
|
validateReadOnlyAssetRoot,
|
||||||
@@ -66,6 +67,17 @@ afterEach(() => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("TDD-WP0-DATA-001-root-validation", () => {
|
describe("TDD-WP0-DATA-001-root-validation", () => {
|
||||||
|
it("reads only an absolute configured read-only asset root", () => {
|
||||||
|
const base = temporaryDirectory();
|
||||||
|
const configFile = join(base, "instance.json");
|
||||||
|
const assetRoot = join(base, "runtime-assets");
|
||||||
|
writeFileSync(configFile, JSON.stringify({ asset_root: assetRoot }));
|
||||||
|
|
||||||
|
expect(readConfiguredAssetRoot(configFile)).toBe(resolve(assetRoot));
|
||||||
|
writeFileSync(configFile, JSON.stringify({ asset_root: "relative-assets" }));
|
||||||
|
expect(() => readConfiguredAssetRoot(configFile)).toThrow("asset_root_configuration_invalid");
|
||||||
|
});
|
||||||
|
|
||||||
it("derives default data and configuration paths from LOCALAPPDATA without a hardcoded user", () => {
|
it("derives default data and configuration paths from LOCALAPPDATA without a hardcoded user", () => {
|
||||||
const localAppData = join(temporaryDirectory(), "LocalAppData");
|
const localAppData = join(temporaryDirectory(), "LocalAppData");
|
||||||
expect(defaultLocalDataRoot({ LOCALAPPDATA: localAppData })).toBe(join(localAppData, "Dada", "P0A", "data"));
|
expect(defaultLocalDataRoot({ LOCALAPPDATA: localAppData })).toBe(join(localAppData, "Dada", "P0A", "data"));
|
||||||
|
|||||||
@@ -0,0 +1,86 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { createHash } from "node:crypto";
|
||||||
|
import { mkdir, mkdtemp, readFile, rm, stat, writeFile } from "node:fs/promises";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import { join } from "node:path";
|
||||||
|
import test from "node:test";
|
||||||
|
|
||||||
|
import {
|
||||||
|
createRuntimeAssetManifest,
|
||||||
|
deployRuntimeAssetPlan,
|
||||||
|
readRuntimeAssetManifest,
|
||||||
|
serializeRuntimeAssetManifest,
|
||||||
|
} from "../../scripts/lib/runtime-assets.mjs";
|
||||||
|
|
||||||
|
test("committed P0-A runtime manifest covers the frozen first-version binary assets", () => {
|
||||||
|
const manifest = readRuntimeAssetManifest("config/runtime-assets-manifest.json");
|
||||||
|
assert.deepEqual(manifest.counts, {
|
||||||
|
dynamic_fonts: 7,
|
||||||
|
dynamic_images: 8,
|
||||||
|
font_panel_items: 11,
|
||||||
|
static_stickers: 1407,
|
||||||
|
});
|
||||||
|
assert.equal(manifest.entries.length, 1433);
|
||||||
|
assert.doesNotMatch(serializeRuntimeAssetManifest(manifest), /[A-Za-z]:[\\/]/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("runtime asset deployment creates verified hardlinks and a path-free manifest", async (t) => {
|
||||||
|
const root = await mkdtemp(join(tmpdir(), "dada-runtime-assets-"));
|
||||||
|
t.after(() => rm(root, { force: true, recursive: true }));
|
||||||
|
const sourceRoot = join(root, "source");
|
||||||
|
const assetRoot = join(root, "assets");
|
||||||
|
const sourcePath = join(sourceRoot, "sticker.png");
|
||||||
|
const bytes = Buffer.from("runtime asset fixture");
|
||||||
|
await mkdir(sourceRoot);
|
||||||
|
await writeFile(sourcePath, bytes);
|
||||||
|
const entry = {
|
||||||
|
assetId: "STK001",
|
||||||
|
mimeType: "image/png",
|
||||||
|
relativePath: "p0a-static-v1/STK001.png",
|
||||||
|
resourceVersion: "p0a-static-v1",
|
||||||
|
rootRef: "p0a_runtime_assets",
|
||||||
|
sha256: createHash("sha256").update(bytes).digest("hex"),
|
||||||
|
};
|
||||||
|
const manifest = createRuntimeAssetManifest({
|
||||||
|
counts: { dynamic_fonts: 0, dynamic_images: 0, font_panel_items: 0, static_stickers: 1 },
|
||||||
|
entries: [entry],
|
||||||
|
});
|
||||||
|
|
||||||
|
await deployRuntimeAssetPlan({ assetRoot, manifest, resources: [{ entry, sourcePath }] });
|
||||||
|
|
||||||
|
const targetPath = join(assetRoot, entry.relativePath);
|
||||||
|
const [sourceStat, targetStat] = await Promise.all([stat(sourcePath), stat(targetPath)]);
|
||||||
|
assert.equal(sourceStat.ino, targetStat.ino);
|
||||||
|
assert.deepEqual(await readFile(targetPath), bytes);
|
||||||
|
const writtenManifest = await readFile(join(assetRoot, "manifest.json"), "utf8");
|
||||||
|
assert.deepEqual(JSON.parse(writtenManifest), manifest);
|
||||||
|
assert.doesNotMatch(writtenManifest, /[A-Za-z]:[\\/]/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("runtime asset deployment refuses a mismatched existing target", async (t) => {
|
||||||
|
const root = await mkdtemp(join(tmpdir(), "dada-runtime-assets-conflict-"));
|
||||||
|
t.after(() => rm(root, { force: true, recursive: true }));
|
||||||
|
const sourcePath = join(root, "source.png");
|
||||||
|
const assetRoot = join(root, "assets");
|
||||||
|
const targetPath = join(assetRoot, "p0a-static-v1", "STK001.png");
|
||||||
|
await mkdir(join(assetRoot, "p0a-static-v1"), { recursive: true });
|
||||||
|
await writeFile(sourcePath, "expected");
|
||||||
|
await writeFile(targetPath, "unexpected");
|
||||||
|
const entry = {
|
||||||
|
assetId: "STK001",
|
||||||
|
mimeType: "image/png",
|
||||||
|
relativePath: "p0a-static-v1/STK001.png",
|
||||||
|
resourceVersion: "p0a-static-v1",
|
||||||
|
rootRef: "p0a_runtime_assets",
|
||||||
|
sha256: createHash("sha256").update("expected").digest("hex"),
|
||||||
|
};
|
||||||
|
const manifest = createRuntimeAssetManifest({
|
||||||
|
counts: { dynamic_fonts: 0, dynamic_images: 0, font_panel_items: 0, static_stickers: 1 },
|
||||||
|
entries: [entry],
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.throws(
|
||||||
|
() => deployRuntimeAssetPlan({ assetRoot, manifest, resources: [{ entry, sourcePath }] }),
|
||||||
|
/asset_target_conflict/,
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -0,0 +1,175 @@
|
|||||||
|
import test from "node:test";
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { mkdtemp, 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";
|
||||||
|
import { createServer } from "node:net";
|
||||||
|
|
||||||
|
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));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function verifyWorkerStartup(configPath) {
|
||||||
|
const pipeName = `Dada.P0A.PostV1.${process.pid}.${Date.now()}`;
|
||||||
|
const pipePath = `\\\\.\\pipe\\${pipeName}`;
|
||||||
|
const server = createServer();
|
||||||
|
await new Promise((resolveListen, rejectListen) => {
|
||||||
|
server.once("error", rejectListen);
|
||||||
|
server.listen(pipePath, resolveListen);
|
||||||
|
});
|
||||||
|
const child = spawn(join(packageRoot, "runtime", "node.exe"), [
|
||||||
|
join(packageRoot, "server", "worker.mjs"),
|
||||||
|
"--dada-control-pipe", pipeName,
|
||||||
|
"--dada-credential-stdin",
|
||||||
|
], {
|
||||||
|
cwd: packageRoot,
|
||||||
|
env: {
|
||||||
|
...process.env,
|
||||||
|
DADA_INSTANCE_CONFIG_PATH: configPath,
|
||||||
|
DADA_SQLITE_NATIVE_BINDING: join(packageRoot, "server", "native", "better_sqlite3.node"),
|
||||||
|
},
|
||||||
|
stdio: ["pipe", "ignore", "ignore"],
|
||||||
|
windowsHide: true,
|
||||||
|
});
|
||||||
|
child.stdin.end(JSON.stringify({ "Dada/P0A/worker/ai-gateway": "synthetic-runtime-token" }));
|
||||||
|
try {
|
||||||
|
await new Promise((resolveReady, rejectReady) => {
|
||||||
|
let settled = false;
|
||||||
|
const finish = (error) => {
|
||||||
|
if (settled) return;
|
||||||
|
settled = true;
|
||||||
|
clearTimeout(deadline);
|
||||||
|
child.off("exit", onExit);
|
||||||
|
if (error) rejectReady(error); else resolveReady();
|
||||||
|
};
|
||||||
|
const deadline = setTimeout(() => finish(new Error("packaged worker ready timeout")), 15_000);
|
||||||
|
const onExit = (code) => finish(new Error(`packaged worker exited before ready: ${code}`));
|
||||||
|
child.once("exit", onExit);
|
||||||
|
server.once("connection", (connection) => {
|
||||||
|
connection.setEncoding("utf8");
|
||||||
|
let pending = "";
|
||||||
|
connection.on("data", (chunk) => {
|
||||||
|
pending += chunk;
|
||||||
|
while (pending.includes("\n")) {
|
||||||
|
const newline = pending.indexOf("\n");
|
||||||
|
const status = pending.slice(0, newline).trim();
|
||||||
|
pending = pending.slice(newline + 1);
|
||||||
|
if (status === "storage_unavailable") finish(new Error("packaged worker reported storage_unavailable"));
|
||||||
|
if (status === "ready") {
|
||||||
|
setTimeout(() => {
|
||||||
|
if (settled) return;
|
||||||
|
connection.write("shutdown\n");
|
||||||
|
finish();
|
||||||
|
}, 500);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
const exitCode = await new Promise((resolveExit) => child.once("exit", resolveExit));
|
||||||
|
assert.equal(exitCode, 0);
|
||||||
|
} finally {
|
||||||
|
if (child.exitCode === null) child.kill();
|
||||||
|
await new Promise((resolveClose) => server.close(resolveClose));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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")));
|
||||||
|
const runtimeAssetManifest = JSON.parse(await readFile(join(packageRoot, "asset-metadata", "manifest.json"), "utf8"));
|
||||||
|
assert.deepEqual(runtimeAssetManifest.counts, {
|
||||||
|
dynamic_fonts: 7,
|
||||||
|
dynamic_images: 8,
|
||||||
|
font_panel_items: 11,
|
||||||
|
static_stickers: 1407,
|
||||||
|
});
|
||||||
|
assert.equal(runtimeAssetManifest.entries.length, 1433);
|
||||||
|
const packagedWorker = await readFile(join(packageRoot, "server", "worker", "dist", "worker.js"), "utf8");
|
||||||
|
const packagedOneApiAdapter = await readFile(join(packageRoot, "server", "worker", "dist", "oneapi-generation-adapter.js"), "utf8");
|
||||||
|
assert.match(packagedWorker, /GenerationProcessor/);
|
||||||
|
assert.match(packagedWorker, /OneApiGenerationAdapter/);
|
||||||
|
assert.match(packagedOneApiAdapter, /oneapi\.intelligrow\.cn/);
|
||||||
|
assert.doesNotMatch(packagedWorker, /portable-mock-worker/);
|
||||||
|
const root = await mkdtemp(join(tmpdir(), "dada-postv1-"));
|
||||||
|
const dataRoot = join(root, "data");
|
||||||
|
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 initialPage = await fetch(`http://127.0.0.1:${port}/`, { headers: { host: `127.0.0.1:${port}` } });
|
||||||
|
assert.equal(initialPage.status, 200);
|
||||||
|
assert.match(await initialPage.text(), /当前浏览器无法使用 Dada/);
|
||||||
|
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.equal(support.status, 200);
|
||||||
|
const cookie = support.headers.get("set-cookie")?.split(";", 1)[0];
|
||||||
|
assert.ok(cookie);
|
||||||
|
const page = await fetch(`http://127.0.0.1:${port}/app`, { headers: { host: `127.0.0.1:${port}`, cookie, "sec-ch-ua": '"Google Chrome";v="150"' } });
|
||||||
|
assert.equal(page.status, 200);
|
||||||
|
const pageHtml = await page.text();
|
||||||
|
assert.match(pageHtml, /<div id="root"><\/div>/);
|
||||||
|
const scriptPath = pageHtml.match(/<script[^>]+src="([^"]+)"/)?.[1];
|
||||||
|
const stylesheetPath = pageHtml.match(/<link[^>]+href="([^"]+)"/)?.[1];
|
||||||
|
assert.ok(scriptPath);
|
||||||
|
assert.ok(stylesheetPath);
|
||||||
|
const assetHeaders = { host: `127.0.0.1:${port}`, cookie, "sec-ch-ua": '"Google Chrome";v="150"' };
|
||||||
|
const script = await fetch(`http://127.0.0.1:${port}${scriptPath}`, { headers: assetHeaders });
|
||||||
|
const stylesheet = await fetch(`http://127.0.0.1:${port}${stylesheetPath}`, { headers: assetHeaders });
|
||||||
|
assert.equal(script.status, 200);
|
||||||
|
assert.match(script.headers.get("content-type") ?? "", /^(?:application|text)\/javascript\b/);
|
||||||
|
assert.equal(stylesheet.status, 200);
|
||||||
|
assert.match(stylesheet.headers.get("content-type") ?? "", /^text\/css\b/);
|
||||||
|
assert.ok(existsSync(join(dataRoot, "db", "dada.sqlite3")));
|
||||||
|
await verifyWorkerStartup(configPath);
|
||||||
|
} 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 });
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
import { GenerationPollingLoop } from "../../apps/worker/src/generation-polling-loop.js";
|
||||||
|
|
||||||
|
describe("POSTV1-05 generation polling loop", () => {
|
||||||
|
afterEach(() => {
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not start another processor call while the current call is unresolved", async () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
let finishCurrentCall: (() => void) | undefined;
|
||||||
|
const processNext = vi.fn(() => new Promise<void>((resolve) => {
|
||||||
|
finishCurrentCall = resolve;
|
||||||
|
}));
|
||||||
|
const loop = new GenerationPollingLoop({ processNext }, 250);
|
||||||
|
|
||||||
|
await vi.advanceTimersByTimeAsync(1_000);
|
||||||
|
expect(processNext).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
finishCurrentCall?.();
|
||||||
|
await Promise.resolve();
|
||||||
|
await vi.advanceTimersByTimeAsync(250);
|
||||||
|
expect(processNext).toHaveBeenCalledTimes(2);
|
||||||
|
|
||||||
|
loop.close();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
import type { GenerationAdapterRequest } from "../../apps/worker/src/ai-adapter-contract.js";
|
||||||
|
import { runAiRuntimeProbe } from "../../apps/worker/src/ai-runtime-probe.js";
|
||||||
|
import { OneApiGenerationAdapter } from "../../apps/worker/src/oneapi-generation-adapter.js";
|
||||||
|
|
||||||
|
function request(overrides: Partial<GenerationAdapterRequest> = {}): GenerationAdapterRequest {
|
||||||
|
return {
|
||||||
|
configSnapshot: {},
|
||||||
|
generationId: "00000000-0000-4000-8000-000000000001",
|
||||||
|
modelId: "gemini-3.1-flash-image-preview",
|
||||||
|
prompt: "一张用于本机验收的抽象色彩图",
|
||||||
|
ratio: "1:1",
|
||||||
|
referenceAssetIds: [],
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("POSTV1-02 OneAPI runtime adapter", () => {
|
||||||
|
it("uses the fixed Gemini gateway and normalizes one real-shaped response", async () => {
|
||||||
|
const source = Buffer.from(
|
||||||
|
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=",
|
||||||
|
"base64",
|
||||||
|
);
|
||||||
|
const gateway = vi.fn(async (_url: string | URL | Request, init?: RequestInit) => {
|
||||||
|
const headers = new Headers(init?.headers);
|
||||||
|
expect(headers.get("authorization")).toBe("Bearer synthetic-runtime-token");
|
||||||
|
expect(init?.redirect).toBe("error");
|
||||||
|
const payload = JSON.parse(String(init?.body)) as { messages: Array<{ content: unknown; role: string }> };
|
||||||
|
expect(payload.messages).toEqual([
|
||||||
|
{
|
||||||
|
content: "Generate exactly one image from the user's description. Return the generated image and do not answer with text only.",
|
||||||
|
role: "system",
|
||||||
|
},
|
||||||
|
{ content: "一张用于本机验收的抽象色彩图", role: "user" },
|
||||||
|
]);
|
||||||
|
return new Response(JSON.stringify({
|
||||||
|
choices: [{ message: { content: `})` } }],
|
||||||
|
}), { headers: { "content-type": "application/json" }, status: 200 });
|
||||||
|
});
|
||||||
|
const credential = Buffer.from("synthetic-runtime-token");
|
||||||
|
const adapter = new OneApiGenerationAdapter({ credential, fetch: gateway as typeof fetch });
|
||||||
|
const result = await adapter.start(request());
|
||||||
|
|
||||||
|
expect(gateway).toHaveBeenCalledOnce();
|
||||||
|
expect(gateway.mock.calls[0]?.[0]).toBe("https://oneapi.intelligrow.cn/v1/chat/completions");
|
||||||
|
expect(result.status === "failed" ? result.sourceCategory : "completed").toBe("completed");
|
||||||
|
if (result.status === "completed") {
|
||||||
|
expect(result.outputs).toHaveLength(1);
|
||||||
|
expect(result.outputs[0]).toMatchObject({ mimeType: "image/png", pixelHeight: 1080, pixelWidth: 1080 });
|
||||||
|
expect(result.outputs[0]?.bytes.length).toBeGreaterThan(0);
|
||||||
|
}
|
||||||
|
expect(JSON.stringify(result)).not.toContain("synthetic-runtime-token");
|
||||||
|
adapter.dispose();
|
||||||
|
credential.fill(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("fails closed instead of returning a mock image", async () => {
|
||||||
|
const adapter = new OneApiGenerationAdapter({
|
||||||
|
credential: Buffer.from("synthetic-runtime-token"),
|
||||||
|
fetch: vi.fn(async () => new Response(null, { status: 503 })) as typeof fetch,
|
||||||
|
});
|
||||||
|
await expect(adapter.start(request())).resolves.toEqual({
|
||||||
|
category: "upstream_failed",
|
||||||
|
sourceCategory: "upstream_http_503",
|
||||||
|
status: "failed",
|
||||||
|
});
|
||||||
|
adapter.dispose();
|
||||||
|
await expect(adapter.start(request())).resolves.toEqual({
|
||||||
|
category: "upstream_failed",
|
||||||
|
sourceCategory: "adapter_disposed",
|
||||||
|
status: "failed",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns only a bounded probe summary and wipes generated bytes", async () => {
|
||||||
|
const bytes = Buffer.from("probe-output");
|
||||||
|
const result = await runAiRuntimeProbe({
|
||||||
|
async start() {
|
||||||
|
return { outputs: [{ bytes, mimeType: "image/png", pixelHeight: 1080, pixelWidth: 1080 }], status: "completed" };
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(result).toEqual({
|
||||||
|
code: "ai_probe_passed",
|
||||||
|
mime_type: "image/png",
|
||||||
|
pixel_height: 1080,
|
||||||
|
pixel_width: 1080,
|
||||||
|
real_calls: 1,
|
||||||
|
success: true,
|
||||||
|
});
|
||||||
|
expect(bytes.every((value) => value === 0)).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user