Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,20 @@
フォーマットは [Keep a Changelog](https://keepachangelog.com/ja/1.1.0/) に基づいており、
[セマンティックバージョニング](https://semver.org/lang/ja/) に準拠しています。

## [2.3.2] - 2026-05-23

### 修正

- macOS デスクトップ版の Auto-Updater 用 artifact(`.app.tar.gz`)が生成されず、`latest.json` に macOS platform が含まれない問題を修正 (#161)
- 依存関係の脆弱性を解消し、root / web / server の audit が 0 vulnerabilities になるよう更新 (#169)
- プロトタイプ汚染、HTTP Request ノードの SSRF/DoS、workflow import バリデーション、機密フィールド除去漏れなどのセキュリティ問題を修正 (#153, #148)
- ワークフロー実行中の graceful shutdown、DB close、workflow start の不正 JSON handling、非同期実行エラーの可視化を改善 (#154, #149)
- デスクトップ関連ファイルのバージョン更新漏れを防ぐため、リリース対象ファイルのバージョン整合性を改善 (#162)

### 注意

- v2.3.1 以前の macOS ユーザーは、v2.3.1 の `latest.json` に macOS updater artifact が含まれていないため、このリリースへの更新時のみ手動再ダウンロードが必要になる可能性があります。v2.3.2 以降をインストールした後は、次回以降の自動アップデートを受け取れる想定です。

## [2.3.1] - 2026-04-21

### 修正
Expand Down
4 changes: 2 additions & 2 deletions apps/desktop/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion apps/desktop/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@aituber-flow/desktop",
"version": "2.3.1",
"version": "2.3.2",
"private": true,
"scripts": {
"prepare-runtime": "cd ../web && npm run build:desktop && cd ../desktop && node scripts/build-sidecar.js && node scripts/copy-resources.js",
Expand Down
2 changes: 1 addition & 1 deletion apps/desktop/src-tauri/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion apps/desktop/src-tauri/Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "aituber-flow"
version = "2.3.1"
version = "2.3.2"
description = "AITuberFlow Desktop - Visual workflow editor for AI VTuber streaming"
authors = ["AITuberFlow"]
edition = "2021"
Expand Down
6 changes: 4 additions & 2 deletions apps/desktop/src-tauri/tauri.conf.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"productName": "AITuberFlow",
"version": "2.3.1",
"version": "2.3.2",
"identifier": "com.aituberflow.app",
"build": {
"beforeBuildCommand": "",
Expand All @@ -9,7 +9,7 @@
},
"app": {
"security": {
"csp": null
"csp": "default-src 'self' asset: http://asset.localhost; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: asset: http://asset.localhost https:; media-src 'self' blob: asset: http://asset.localhost; font-src 'self' data:; connect-src 'self' ipc: http://ipc.localhost ws://localhost:* wss://localhost:* ws://127.0.0.1:* wss://127.0.0.1:* http://localhost:* http://127.0.0.1:* https://api.openai.com https://api.anthropic.com https://generativelanguage.googleapis.com https://api.groq.com https://api.mistral.ai https://www.googleapis.com https://*.youtube.com; worker-src 'self' blob:; object-src 'none'; base-uri 'self'; frame-ancestors 'none'"
},
"windows": [
{
Expand Down Expand Up @@ -59,3 +59,5 @@
}
}
}


238 changes: 63 additions & 175 deletions apps/server-ts/bun.lock

Large diffs are not rendered by default.

20 changes: 13 additions & 7 deletions apps/server-ts/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@aituber-flow/server",
"version": "2.3.1",
"version": "2.3.2",
"type": "module",
"scripts": {
"dev": "bun run --hot src/index.ts",
Expand All @@ -19,18 +19,24 @@
"@anthropic-ai/sdk": "^0.74.0",
"@google/generative-ai": "^0.24.1",
"@hono/zod-validator": "^0.4.0",
"discord.js": "^14.25.1",
"drizzle-orm": "^0.38.0",
"hono": "^4.7.0",
"obs-websocket-js": "^5.0.7",
"openai": "^6.21.0",
"discord.js": "^14.26.4",
"drizzle-orm": "^0.45.2",
"hono": "^4.12.22",
"obs-websocket-js": "^5.0.8",
"openai": "^6.39.0",
"tmi.js": "^1.8.5",
"zod": "^3.24.0"
},
"devDependencies": {
"@biomejs/biome": "^1.9.0",
"bun-types": "^1.3.9",
"drizzle-kit": "^0.30.0",
"drizzle-kit": "^0.31.10",
"typescript": "^5.7.0"
},
"overrides": {
"ws": "^8.21.0",
"undici": "^6.24.1",
"lodash": "^4.18.1",
"esbuild": "^0.28.0"
}
}
13 changes: 13 additions & 0 deletions apps/server-ts/src/db/database.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,19 @@ sqlite.exec("PRAGMA journal_mode = WAL");

export const db = drizzle(sqlite, { schema });

/**
* Close the underlying SQLite handle. Call during graceful shutdown to flush
* the WAL and release file locks.
*/
export function closeDb(): void {
try {
sqlite.exec("PRAGMA wal_checkpoint(TRUNCATE)");
} catch {
// ignore — the close below still completes
}
sqlite.close();
}

const CREATE_TABLE_SQL = `
CREATE TABLE IF NOT EXISTS workflows (
id TEXT PRIMARY KEY,
Expand Down
20 changes: 18 additions & 2 deletions apps/server-ts/src/engine/executor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -147,7 +147,12 @@ export class NodeContext {
}

async updateCharacter(updates: Record<string, any>): Promise<void> {
Object.assign(this.character, updates);
// Guard against prototype pollution: never copy __proto__/constructor/prototype
// keys from user-controllable updates into the character state.
for (const [key, value] of Object.entries(updates)) {
if (key === "__proto__" || key === "constructor" || key === "prototype") continue;
this.character[key] = value;
}
}

getCharacterName(): string {
Expand Down Expand Up @@ -285,6 +290,10 @@ export class WorkflowExecutor {

// ─── Status ───────────────────────

getRunningWorkflowIds(): string[] {
return [...this.runningWorkflows.keys()];
}

getStatus(workflowId: string): Record<string, unknown> {
const status = this.runningWorkflows.get(workflowId);
if (!status) return { status: "idle" };
Expand Down Expand Up @@ -542,9 +551,16 @@ export class WorkflowExecutor {
workflow_data: data,
});

// Start execution in background (non-blocking)
// Start execution in background (non-blocking). Errors here reach the
// top-level promise; record them on the workflow status so the HTTP
// /status endpoint and WebSocket clients can observe the failure.
this.executeWorkflow(workflowId, data).catch((err) => {
console.error("Workflow execution error:", err);
const status = this.runningWorkflows.get(workflowId);
if (status) {
status.status = "error";
status.error = err instanceof Error ? err.message : String(err);
}
});

console.log(`Started workflow: ${workflowId}`);
Expand Down
45 changes: 40 additions & 5 deletions apps/server-ts/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { createBunWebSocket } from "hono/bun";
import { serveStatic } from "hono/bun";
import { cors } from "hono/cors";
import { logger } from "hono/logger";
import { initDb } from "./db/database";
import { closeDb, initDb } from "./db/database";
import { WorkflowExecutor } from "./engine/executor";
import { integrationRoutes } from "./routes/integrations";
import { pluginRoutes } from "./routes/plugins";
Expand Down Expand Up @@ -112,13 +112,48 @@ const port = Number.isFinite(parsedPort) ? parsedPort : 8001;
console.log(`Started development server: http://localhost:${port}`);

// Graceful shutdown handler
process.on("SIGTERM", () => {
console.log("Received SIGTERM, shutting down gracefully...");
const SHUTDOWN_TIMEOUT_MS = 10_000;
let isShuttingDown = false;

async function gracefulShutdown(signal: string): Promise<void> {
if (isShuttingDown) return;
isShuttingDown = true;
console.log(`Received ${signal}, shutting down gracefully...`);

const timeoutHandle = setTimeout(() => {
console.warn(`Shutdown timed out after ${SHUTDOWN_TIMEOUT_MS}ms, forcing exit`);
process.exit(1);
}, SHUTDOWN_TIMEOUT_MS);
// Don't let the timeout keep the process alive if everything finishes fast.
if (typeof timeoutHandle === "object" && "unref" in timeoutHandle) {
timeoutHandle.unref();
}

try {
const runningIds = executor.getRunningWorkflowIds();
if (runningIds.length > 0) {
console.log(`Stopping ${runningIds.length} running workflow(s)...`);
await Promise.allSettled(runningIds.map((id) => executor.stopWorkflow(id)));
}
} catch (err) {
console.error("Error stopping workflows on shutdown:", err);
}

try {
closeDb();
} catch (err) {
console.error("Error closing database on shutdown:", err);
}

clearTimeout(timeoutHandle);
process.exit(0);
}

process.on("SIGTERM", () => {
void gracefulShutdown("SIGTERM");
});
process.on("SIGINT", () => {
console.log("Received SIGINT, shutting down gracefully...");
process.exit(0);
void gracefulShutdown("SIGINT");
});

// Parent process monitoring: prevents orphan sidecar when the desktop main
Expand Down
88 changes: 67 additions & 21 deletions apps/server-ts/src/routes/workflows.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,19 +47,35 @@ const createWorkflowBody = z.object({
// ─── Helpers ──────────────────────────────

const SENSITIVE_KEYS = ["apiKey", "api_key", "password", "secret", "token", "apiSecret"];
const SENSITIVE_KEYS_LOWER = new Set(SENSITIVE_KEYS.map((k) => k.toLowerCase()));
const MAX_STRIP_DEPTH = 20;

function stripApiKeys(nodes: Record<string, unknown>[]): Record<string, unknown>[] {
return nodes.map((node) => {
const copy = { ...node };
if (copy.config && typeof copy.config === "object") {
const configCopy = { ...(copy.config as Record<string, unknown>) };
for (const key of SENSITIVE_KEYS) {
if (key in configCopy) configCopy[key] = "";
/**
* Recursively strip sensitive fields (api keys, tokens, passwords) from a
* value. Walks nested objects and arrays up to MAX_STRIP_DEPTH to guard against
* pathological inputs. Returns a new structure; input is not mutated.
*/
function deepStripSensitive(value: unknown, depth = 0): unknown {
if (depth >= MAX_STRIP_DEPTH) return value;
if (Array.isArray(value)) {
return value.map((item) => deepStripSensitive(item, depth + 1));
}
if (value && typeof value === "object") {
const out: Record<string, unknown> = {};
for (const [key, inner] of Object.entries(value as Record<string, unknown>)) {
if (SENSITIVE_KEYS_LOWER.has(key.toLowerCase())) {
out[key] = "";
} else {
out[key] = deepStripSensitive(inner, depth + 1);
}
copy.config = configCopy;
}
return copy;
});
return out;
}
return value;
}

function stripApiKeys(nodes: Record<string, unknown>[]): Record<string, unknown>[] {
return nodes.map((node) => deepStripSensitive(node) as Record<string, unknown>);
}

type WorkflowRow = typeof workflows.$inferSelect;
Expand Down Expand Up @@ -215,13 +231,28 @@ app.get("/:id/export", async (c) => {
});

// Import workflow
const importWorkflowBody = z.object({
name: z.string().max(256).optional(),
description: z.string().max(2048).optional().nullable(),
nodes: z.array(z.record(z.string(), z.unknown())).max(500).optional(),
connections: z.array(z.record(z.string(), z.unknown())).max(2000).optional(),
character: z.record(z.string(), z.unknown()).optional(),
});

app.post("/import", async (c) => {
let data;
let raw;
try {
data = await c.req.json();
raw = await c.req.json();
} catch {
return c.json({ error: "Invalid JSON in request body" }, 400);
}

const parsed = importWorkflowBody.safeParse(raw);
if (!parsed.success) {
return c.json({ error: "Invalid workflow payload", details: parsed.error.format() }, 400);
}
const data = parsed.data;

const id = generateId();
const now = nowISO();

Expand Down Expand Up @@ -291,17 +322,32 @@ app.post("/:id/start", async (c) => {
const [existing] = await _db.select().from(workflows).where(eq(workflows.id, id));
if (!existing) return c.json({ detail: "Workflow not found" }, 404);

let body: any = {};
try {
body = await c.req.json();
} catch {
// No body provided
// Distinguish "no body" (fine) from "malformed body" (reject).
// Hono's c.req.text() resolves to "" when no body was sent, letting us
// skip the JSON parse in that case. Malformed JSON still gets a 400.
let body: Record<string, unknown> = {};
const rawBody = await c.req.text();
if (rawBody.trim().length > 0) {
try {
const parsed = JSON.parse(rawBody) as unknown;
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
body = parsed as Record<string, unknown>;
} else {
return c.json({ error: "Request body must be a JSON object" }, 400);
}
} catch {
return c.json({ error: "Invalid JSON in request body" }, 400);
}
}

const nodes = body.nodes ?? JSON.parse(existing.nodesJson || "[]");
const connections = body.connections ?? JSON.parse(existing.connectionsJson || "[]");
const character = body.character ?? JSON.parse(existing.characterJson || "{}");
const startNodeId = body.startNodeId ?? null;
const nodes = (body.nodes as unknown[] | undefined) ?? JSON.parse(existing.nodesJson || "[]");
const connections =
(body.connections as unknown[] | undefined) ??
JSON.parse(existing.connectionsJson || "[]");
const character =
(body.character as Record<string, unknown> | undefined) ??
JSON.parse(existing.characterJson || "{}");
const startNodeId = typeof body.startNodeId === "string" ? body.startNodeId : null;

const workflowData = {
id: existing.id,
Expand Down
Loading
Loading