FN-8851: attribute settings writes by request source

Record auditable provenance for every configuration mutation.

- Add system and verified/unverified API configuration actors.
- Propagate request provenance through settings, workflow, sync, and portability routes.
- Cover mutation attribution across core persistence and dashboard routes.

Files changed:
 .changeset/fn-8851-settings-attribution.md         |   7 +
 .../postgres/settings-persistence.pg.test.ts       |  17 +++
 .../settings-revision-attribution.test.ts          | 102 ++++++++++++++
 packages/core/src/automation/automation-store.ts   |  11 +-
 packages/core/src/automation/routine-store.ts      |   9 +-
 packages/core/src/config/global-settings.ts        |   5 +-
 packages/core/src/index.gate.ts                    |   1 +
 packages/core/src/index.ts                         |   1 +
 packages/core/src/task-store/settings-ops.ts       |   5 +-
 packages/core/src/task-store/task-mutation-ops.ts  |   5 +-
 packages/core/src/types.ts                         |   2 +
 packages/core/src/types/agents/agents.ts           |  12 ++
 .../src/__tests__/auth-middleware.test.ts          |  29 +++-
 .../dashboard/src/__tests__/request-actor.test.ts  |  46 ++++++
 packages/dashboard/src/auth-middleware.ts          |   8 ++
 packages/dashboard/src/request-actor.ts            |  13 ++
 .../register-org-portability-routes.test.ts        |  17 ++-
 .../register-settings-memory-worktrunk.test.ts     |  45 +++++-
 .../__tests__/settings-sync-attribution.test.ts    | 156 +++++++++++++++++++++
 .../src/routes/__tests__/workflow-setting-attribution.test.ts |  73 ++++++++++
 .../src/routes/register-org-portability-routes.ts  |   3 +-
 .../src/routes/register-settings-memory-routes.ts  |   3 +-
 .../src/routes/register-settings-sync-inbound-routes.ts |  18 ++-
 .../src/routes/register-settings-sync-routes.ts    |  18 ++-
 .../src/routes/register-workflow-routes.ts         |   3 +
 25 files changed, 576 insertions(+), 33 deletions(-)

Fusion-Task-Id: FN-8851
Fusion-Task-Lineage: 27a51666-f9ed-4395-99dc-d7ae47f119e1
Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-08-08 22:55:38 -07:00
parent 75796ebe6f
commit 53416f6535
25 changed files with 576 additions and 33 deletions

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": patch
---
summary: Record truthful provenance for settings revisions from API and system writes.
category: fix
dev: Adds api provenance actors for verified daemon tokens, unverified HTTP calls, and verified node keys.

View File

@@ -12,6 +12,7 @@ import {
type SharedPgTaskStoreHarness, type SharedPgTaskStoreHarness,
} from "../../__test-utils__/pg-test-harness.js"; } from "../../__test-utils__/pg-test-harness.js";
import { GLOBAL_SETTINGS_KEYS, PROJECT_SETTINGS_KEYS } from "../../config/settings-schema.js"; import { GLOBAL_SETTINGS_KEYS, PROJECT_SETTINGS_KEYS } from "../../config/settings-schema.js";
import { sql } from "drizzle-orm";
const credentialLaneKeys = [ const credentialLaneKeys = [
["defaultProvider", "defaultCredentialInstanceId"], ["defaultProvider", "defaultCredentialInstanceId"],
@@ -56,6 +57,22 @@ pgTest("VAL-CROSS-004: Settings persistence (PostgreSQL)", () => {
expect(settings.autoMerge).toBe(false); expect(settings.autoMerge).toBe(false);
}); });
it("records omitted settings actors as the honest system fallback", async () => {
const store = h.store();
await store.updateSettings({ taskPrefix: "ATTR" });
await store.updateGlobalSettings({ defaultModelId: "attribution-model" });
const revisions = await h.adminDb().execute(sql`
SELECT changed_by AS "changedBy"
FROM project.configuration_revisions
ORDER BY sequence ASC
`);
const actors = revisions.map((row) => row.changedBy);
expect(actors).toContainEqual({ kind: "system", id: "fusion-system" });
expect(actors).not.toContainEqual(expect.objectContaining({ kind: "human" }));
});
it("discards retired ephemeral compatibility patches while preserving active project settings", async () => { it("discards retired ephemeral compatibility patches while preserving active project settings", async () => {
const store = h.store(); const store = h.store();
await store.updateSettings({ await store.updateSettings({

View File

@@ -0,0 +1,102 @@
import { afterAll, afterEach, beforeAll, beforeEach, expect, it } from "vitest";
import { sql } from "drizzle-orm";
import { AutomationStore } from "../automation/automation-store.js";
import { RoutineStore } from "../automation/routine-store.js";
import type { ConfigChangedBy } from "../types.js";
import {
createSharedPgTaskStoreTestHarness,
pgDescribe,
type SharedPgTaskStoreHarness,
} from "../__test-utils__/pg-test-harness.js";
/*
FNXC:ConfigVersioning 2026-08-09-04:06:
Configuration provenance is an immutable persisted audit record, so default
attribution tests read JSONB revisions back instead of only inspecting callers.
*/
pgDescribe("settings revision attribution", () => {
const h: SharedPgTaskStoreHarness = createSharedPgTaskStoreTestHarness({ prefix: "fusion_settings_attribution" });
beforeAll(h.beforeAll);
beforeEach(h.beforeEach);
afterEach(h.afterEach);
afterAll(h.afterAll);
async function revisions(): Promise<Array<{ id: string; configKind: string; changedBy: ConfigChangedBy }>> {
return await h.adminDb().execute(sql`
SELECT id, config_kind AS "configKind", changed_by AS "changedBy"
FROM project.configuration_revisions
ORDER BY sequence ASC
`) as Array<{ id: string; configKind: string; changedBy: ConfigChangedBy }>;
}
async function revisionActors(): Promise<ConfigChangedBy[]> {
return (await revisions()).map((row) => row.changedBy);
}
it("persists system for every omitted-actor configuration writer", async () => {
const store = h.store();
const layer = { ...store.getAsyncLayer()!, projectId: store.getWorkflowSettingsProjectId() };
const automationStore = new AutomationStore(h.rootDir, { asyncLayer: layer });
const routineStore = new RoutineStore(h.rootDir, { asyncLayer: layer });
const before = await revisions();
await store.updateSettings({ taskPrefix: "ATR" });
await store.updateGlobalSettings({ defaultModelId: "attribution-model" });
const directGlobal = await store.globalSettingsStore.updateSettings({ defaultModelId: "direct-attribution-model" });
await store.updateWorkflowSettingValues("builtin:coding", store.getWorkflowSettingsProjectId(), { workflowStepTimeoutMs: 1_000 });
const schedule = await automationStore.createSchedule({
name: "Attribution schedule", scheduleType: "daily", command: "",
steps: [
{ id: "first", type: "command", name: "First", command: "echo first" },
{ id: "second", type: "command", name: "Second", command: "echo second" },
],
});
await automationStore.updateSchedule(schedule.id, { name: "Updated attribution schedule" });
await automationStore.reorderSteps(schedule.id, ["second", "first"]);
const routine = await routineStore.createRoutine({
agentId: "attribution-agent", name: "Attribution routine", trigger: { type: "manual" }, command: "echo attribution",
});
await routineStore.updateRoutine(routine.id, { name: "Updated attribution routine" });
const created = (await revisions()).slice(before.length);
const globalRevision = created.find((revision) => revision.configKind === "global-settings");
expect(globalRevision).toBeDefined();
await store.globalSettingsStore.rollbackConfiguration(globalRevision!.id);
const automationRevision = created.find((revision) => revision.configKind === "automation" && revision.id !== created.find((candidate) => candidate.configKind === "automation")?.id);
expect(automationRevision).toBeDefined();
await automationStore.rollbackConfiguration(automationRevision!.id);
await automationStore.deleteSchedule(schedule.id);
const routineRevision = created.find((revision) => revision.configKind === "routine" && revision.id !== created.find((candidate) => candidate.configKind === "routine")?.id);
expect(routineRevision).toBeDefined();
await routineStore.rollbackConfiguration(routineRevision!.id);
await routineStore.deleteRoutine(routine.id);
const actors = (await revisions()).slice(before.length).map((revision) => revision.changedBy);
expect(actors).toHaveLength(14);
expect(actors).toEqual(Array.from({ length: 14 }, () => ({ kind: "system", id: "fusion-system" })));
expect(actors).not.toContainEqual(expect.objectContaining({ kind: "human" }));
expect(directGlobal.defaultModelId).toBe("direct-attribution-model");
});
it("round-trips every explicit provenance variant through committed JSONB revisions", async () => {
const store = h.store();
const before = await revisionActors();
const actors: ConfigChangedBy[] = [
{ kind: "human", id: "future-auth-user" },
{ kind: "agent", id: "agent-1" },
{ kind: "system", id: "system-test" },
{ kind: "api", id: "http:test-verified" },
{ kind: "rollback", id: "rollback-test" },
];
for (const [index, actor] of actors.entries()) {
await store.updateSettings({ taskPrefix: `ATR${index}` }, actor);
}
expect((await revisionActors()).slice(before.length)).toEqual(actors);
});
});

View File

@@ -14,6 +14,7 @@ import { assertProjectRootDir } from "../central/project-root-guard.js";
import type { AsyncDataLayer } from "../postgres/data-layer.js"; import type { AsyncDataLayer } from "../postgres/data-layer.js";
import { appendConfigurationRevision, createConfigurationRevision, getConfigurationRevision, rollbackConfiguration } from "../async-stores/async-configuration-revision-store.js"; import { appendConfigurationRevision, createConfigurationRevision, getConfigurationRevision, rollbackConfiguration } from "../async-stores/async-configuration-revision-store.js";
import type { ConfigChangedBy, ConfigurationRevision } from "../types.js"; import type { ConfigChangedBy, ConfigurationRevision } from "../types.js";
import { CONFIG_CHANGED_BY_SYSTEM } from "../types.js";
/* /*
* FNXC:PhysicalDeleteSqliteClass 2026-06-26-14:00: * FNXC:PhysicalDeleteSqliteClass 2026-06-26-14:00:
* Async Drizzle helpers for backend-mode (PostgreSQL) AutomationStore operations. * Async Drizzle helpers for backend-mode (PostgreSQL) AutomationStore operations.
@@ -261,7 +262,7 @@ export class AutomationStore extends EventEmitter<AutomationStoreEvents> {
* callers must not lose their pre-existing ability to manage automations. * callers must not lose their pre-existing ability to manage automations.
*/ */
async createSchedule(input: ScheduledTaskCreateInput, changedBy: ConfigChangedBy = { kind: "human", id: "local-user" }): Promise<ScheduledTask> { async createSchedule(input: ScheduledTaskCreateInput, changedBy: ConfigChangedBy = CONFIG_CHANGED_BY_SYSTEM): Promise<ScheduledTask> {
this.requireVersionedConfigurationBackend(); this.requireVersionedConfigurationBackend();
if (!input.name?.trim()) { if (!input.name?.trim()) {
throw new Error("Name is required and cannot be empty"); throw new Error("Name is required and cannot be empty");
@@ -323,7 +324,7 @@ export class AutomationStore extends EventEmitter<AutomationStoreEvents> {
} }
/** Restore an automation snapshot by stable id and append one rollback revision. */ /** Restore an automation snapshot by stable id and append one rollback revision. */
async rollbackConfiguration(revisionId: string, changedBy: ConfigChangedBy = { kind: "human", id: "local-user" }): Promise<ConfigurationRevision> { async rollbackConfiguration(revisionId: string, changedBy: ConfigChangedBy = CONFIG_CHANGED_BY_SYSTEM): Promise<ConfigurationRevision> {
if (!this.backendMode) throw new Error("Configuration rollback requires the PostgreSQL revision store"); if (!this.backendMode) throw new Error("Configuration rollback requires the PostgreSQL revision store");
const layer = this.asyncLayer!; const layer = this.asyncLayer!;
return layer.transactionImmediate((tx) => rollbackConfiguration(tx, layer.projectId ?? "", revisionId, changedBy, { return layer.transactionImmediate((tx) => rollbackConfiguration(tx, layer.projectId ?? "", revisionId, changedBy, {
@@ -353,7 +354,7 @@ export class AutomationStore extends EventEmitter<AutomationStoreEvents> {
return listSchedulesAsync(this.asyncLayer!); return listSchedulesAsync(this.asyncLayer!);
} }
async updateSchedule(id: string, updates: ScheduledTaskUpdateInput, changedBy: ConfigChangedBy = { kind: "human", id: "local-user" }): Promise<ScheduledTask> { async updateSchedule(id: string, updates: ScheduledTaskUpdateInput, changedBy: ConfigChangedBy = CONFIG_CHANGED_BY_SYSTEM): Promise<ScheduledTask> {
this.requireVersionedConfigurationBackend(); this.requireVersionedConfigurationBackend();
return this.withScheduleLock(id, async () => { return this.withScheduleLock(id, async () => {
const schedule = await this.getSchedule(id); const schedule = await this.getSchedule(id);
@@ -437,7 +438,7 @@ export class AutomationStore extends EventEmitter<AutomationStoreEvents> {
* Reorder the steps of a schedule by providing the step IDs in the desired order. * Reorder the steps of a schedule by providing the step IDs in the desired order.
* The `stepIds` array must contain exactly the same IDs as the current steps. * The `stepIds` array must contain exactly the same IDs as the current steps.
*/ */
async reorderSteps(scheduleId: string, stepIds: string[], changedBy: ConfigChangedBy = { kind: "human", id: "local-user" }): Promise<ScheduledTask> { async reorderSteps(scheduleId: string, stepIds: string[], changedBy: ConfigChangedBy = CONFIG_CHANGED_BY_SYSTEM): Promise<ScheduledTask> {
this.requireVersionedConfigurationBackend(); this.requireVersionedConfigurationBackend();
return this.withScheduleLock(scheduleId, async () => { return this.withScheduleLock(scheduleId, async () => {
const schedule = await this.getSchedule(scheduleId); const schedule = await this.getSchedule(scheduleId);
@@ -474,7 +475,7 @@ export class AutomationStore extends EventEmitter<AutomationStoreEvents> {
}); });
} }
async deleteSchedule(id: string, changedBy: ConfigChangedBy = { kind: "human", id: "local-user" }): Promise<ScheduledTask> { async deleteSchedule(id: string, changedBy: ConfigChangedBy = CONFIG_CHANGED_BY_SYSTEM): Promise<ScheduledTask> {
this.requireVersionedConfigurationBackend(); this.requireVersionedConfigurationBackend();
return this.withScheduleLock(id, async () => { return this.withScheduleLock(id, async () => {
const schedule = await this.getSchedule(id); const schedule = await this.getSchedule(id);

View File

@@ -29,6 +29,7 @@ import { assertProjectRootDir } from "../central/project-root-guard.js";
import type { AsyncDataLayer } from "../postgres/data-layer.js"; import type { AsyncDataLayer } from "../postgres/data-layer.js";
import { appendConfigurationRevision, createConfigurationRevision, getConfigurationRevision, rollbackConfiguration } from "../async-stores/async-configuration-revision-store.js"; import { appendConfigurationRevision, createConfigurationRevision, getConfigurationRevision, rollbackConfiguration } from "../async-stores/async-configuration-revision-store.js";
import type { ConfigChangedBy, ConfigurationRevision } from "../types.js"; import type { ConfigChangedBy, ConfigurationRevision } from "../types.js";
import { CONFIG_CHANGED_BY_SYSTEM } from "../types.js";
/* /*
* FNXC:SqliteFinalRemoval 2026-06-26-10:30: * FNXC:SqliteFinalRemoval 2026-06-26-10:30:
* Async Drizzle helpers for backend-mode (PostgreSQL) RoutineStore operations. * Async Drizzle helpers for backend-mode (PostgreSQL) RoutineStore operations.
@@ -276,7 +277,7 @@ export class RoutineStore extends EventEmitter<RoutineStoreEvents> {
/** /**
* Create a new routine. * Create a new routine.
*/ */
async createRoutine(input: RoutineCreateInput, changedBy: ConfigChangedBy = { kind: "human", id: "local-user" }): Promise<Routine> { async createRoutine(input: RoutineCreateInput, changedBy: ConfigChangedBy = CONFIG_CHANGED_BY_SYSTEM): Promise<Routine> {
this.requireVersionedConfigurationBackend(); this.requireVersionedConfigurationBackend();
if (!input.name?.trim()) { if (!input.name?.trim()) {
throw new Error("Name is required and cannot be empty"); throw new Error("Name is required and cannot be empty");
@@ -366,7 +367,7 @@ export class RoutineStore extends EventEmitter<RoutineStoreEvents> {
/** /**
* Update an existing routine. * Update an existing routine.
*/ */
async updateRoutine(id: string, updates: RoutineUpdateInput, changedBy: ConfigChangedBy = { kind: "human", id: "local-user" }): Promise<Routine> { async updateRoutine(id: string, updates: RoutineUpdateInput, changedBy: ConfigChangedBy = CONFIG_CHANGED_BY_SYSTEM): Promise<Routine> {
this.requireVersionedConfigurationBackend(); this.requireVersionedConfigurationBackend();
return this.withRoutineLock(id, async () => { return this.withRoutineLock(id, async () => {
const routine = await this.getRoutine(id); const routine = await this.getRoutine(id);
@@ -439,7 +440,7 @@ export class RoutineStore extends EventEmitter<RoutineStoreEvents> {
* selected revision predates creation. The replacement and forward revision * selected revision predates creation. The replacement and forward revision
* share one backend transaction. * share one backend transaction.
*/ */
async rollbackConfiguration(revisionId: string, changedBy: ConfigChangedBy = { kind: "human", id: "local-user" }): Promise<ConfigurationRevision> { async rollbackConfiguration(revisionId: string, changedBy: ConfigChangedBy = CONFIG_CHANGED_BY_SYSTEM): Promise<ConfigurationRevision> {
if (!this.backendMode) throw new Error("Configuration rollback requires the PostgreSQL revision store"); if (!this.backendMode) throw new Error("Configuration rollback requires the PostgreSQL revision store");
const layer = this.asyncLayer!; const layer = this.asyncLayer!;
return layer.transactionImmediate((tx) => rollbackConfiguration(tx, layer.projectId ?? "", revisionId, changedBy, { return layer.transactionImmediate((tx) => rollbackConfiguration(tx, layer.projectId ?? "", revisionId, changedBy, {
@@ -460,7 +461,7 @@ export class RoutineStore extends EventEmitter<RoutineStoreEvents> {
/** /**
* Delete a routine. * Delete a routine.
*/ */
async deleteRoutine(id: string, changedBy: ConfigChangedBy = { kind: "human", id: "local-user" }): Promise<Routine> { async deleteRoutine(id: string, changedBy: ConfigChangedBy = CONFIG_CHANGED_BY_SYSTEM): Promise<Routine> {
this.requireVersionedConfigurationBackend(); this.requireVersionedConfigurationBackend();
return this.withRoutineLock(id, async () => { return this.withRoutineLock(id, async () => {
const routine = await this.getRoutine(id); const routine = await this.getRoutine(id);

View File

@@ -18,6 +18,7 @@ import { basename, dirname, join, resolve } from "node:path";
import { mkdir, readFile, writeFile, rename, chmod, unlink } from "node:fs/promises"; import { mkdir, readFile, writeFile, rename, chmod, unlink } from "node:fs/promises";
import { existsSync, mkdirSync, realpathSync, renameSync } from "node:fs"; import { existsSync, mkdirSync, realpathSync, renameSync } from "node:fs";
import type { ConfigChangedBy, ConfigKind, ConfigurationRevision, ConfigurationTarget, GlobalSettings } from "../types.js"; import type { ConfigChangedBy, ConfigKind, ConfigurationRevision, ConfigurationTarget, GlobalSettings } from "../types.js";
import { CONFIG_CHANGED_BY_SYSTEM } from "../types.js";
import { DEFAULT_GLOBAL_SETTINGS } from "../types.js"; import { DEFAULT_GLOBAL_SETTINGS } from "../types.js";
import { sanitizeCliAgentsSettings } from "./settings-schema.js"; import { sanitizeCliAgentsSettings } from "./settings-schema.js";
import type { AsyncDataLayer } from "../postgres/data-layer.js"; import type { AsyncDataLayer } from "../postgres/data-layer.js";
@@ -273,7 +274,7 @@ export class GlobalSettingsStore {
*/ */
async updateSettings( async updateSettings(
patch: Partial<GlobalSettings> & Record<string, unknown>, patch: Partial<GlobalSettings> & Record<string, unknown>,
changedBy: ConfigChangedBy = { kind: "human", id: "local-user" }, changedBy: ConfigChangedBy = CONFIG_CHANGED_BY_SYSTEM,
): Promise<GlobalSettings> { ): Promise<GlobalSettings> {
return this.withLock(async () => { return this.withLock(async () => {
// Obtain history before changing the file: a failed central bootstrap is // Obtain history before changing the file: a failed central bootstrap is
@@ -349,7 +350,7 @@ export class GlobalSettingsStore {
* Exactly restore a recorded user-global snapshot and record one forward * Exactly restore a recorded user-global snapshot and record one forward
* rollback revision. The filesystem compensation mirrors updateSettings(). * rollback revision. The filesystem compensation mirrors updateSettings().
*/ */
async rollbackConfiguration(revisionId: string, changedBy: ConfigChangedBy = { kind: "human", id: "local-user" }): Promise<ConfigurationRevision> { async rollbackConfiguration(revisionId: string, changedBy: ConfigChangedBy = CONFIG_CHANGED_BY_SYSTEM): Promise<ConfigurationRevision> {
const layer = await this.getRevisionLayer(); const layer = await this.getRevisionLayer();
if (!layer) throw new Error("Configuration rollback requires the PostgreSQL revision store"); if (!layer) throw new Error("Configuration rollback requires the PostgreSQL revision store");
return this.withLock(async () => { return this.withLock(async () => {

View File

@@ -982,6 +982,7 @@ export { GlobalSettingsStore, resolveGlobalDir, resolveGlobalDirForHome } from "
export { ConfigurationRevisionStore, GLOBAL_CONFIGURATION_OWNER_ID } from "./config/configuration-revision-store.js"; export { ConfigurationRevisionStore, GLOBAL_CONFIGURATION_OWNER_ID } from "./config/configuration-revision-store.js";
export { configurationTargetKey, createConfigurationRevision, diffConfigurationSnapshots, appendConfigurationRevision, appendGlobalConfigurationRevision, listConfigurationRevisions, listGlobalConfigurationRevisions, getConfigurationRevision, getGlobalConfigurationRevision, rollbackConfiguration } from "./async-stores/async-configuration-revision-store.js"; export { configurationTargetKey, createConfigurationRevision, diffConfigurationSnapshots, appendConfigurationRevision, appendGlobalConfigurationRevision, listConfigurationRevisions, listGlobalConfigurationRevisions, getConfigurationRevision, getGlobalConfigurationRevision, rollbackConfiguration } from "./async-stores/async-configuration-revision-store.js";
export type { ConfigKind, ConfigChangedBy, ConfigurationOwnerScope, ConfigurationTarget, ConfigurationRevision } from "./types.js"; export type { ConfigKind, ConfigChangedBy, ConfigurationOwnerScope, ConfigurationTarget, ConfigurationRevision } from "./types.js";
export { CONFIG_CHANGED_BY_SYSTEM, CONFIG_CHANGED_BY_API_VERIFIED_TOKEN, CONFIG_CHANGED_BY_API_UNVERIFIED, CONFIG_CHANGED_BY_API_VERIFIED_NODE_KEY } from "./types.js";
export { isValidSqliteDatabaseFile } from "./db/sqlite-validation.js"; export { isValidSqliteDatabaseFile } from "./db/sqlite-validation.js";
export { DaemonTokenManager, DAEMON_TOKEN_PREFIX, DAEMON_TOKEN_HEX_LENGTH, isDaemonTokenFormat } from "./cli/daemon-token.js"; export { DaemonTokenManager, DAEMON_TOKEN_PREFIX, DAEMON_TOKEN_HEX_LENGTH, isDaemonTokenFormat } from "./cli/daemon-token.js";
export { export {

View File

@@ -1123,6 +1123,7 @@ export { GlobalSettingsStore, resolveGlobalDir, resolveGlobalDirForHome } from "
export { ConfigurationRevisionStore, GLOBAL_CONFIGURATION_OWNER_ID } from "./config/configuration-revision-store.js"; export { ConfigurationRevisionStore, GLOBAL_CONFIGURATION_OWNER_ID } from "./config/configuration-revision-store.js";
export { configurationTargetKey, createConfigurationRevision, diffConfigurationSnapshots, appendConfigurationRevision, appendGlobalConfigurationRevision, listConfigurationRevisions, listGlobalConfigurationRevisions, getConfigurationRevision, getGlobalConfigurationRevision, rollbackConfiguration } from "./async-stores/async-configuration-revision-store.js"; export { configurationTargetKey, createConfigurationRevision, diffConfigurationSnapshots, appendConfigurationRevision, appendGlobalConfigurationRevision, listConfigurationRevisions, listGlobalConfigurationRevisions, getConfigurationRevision, getGlobalConfigurationRevision, rollbackConfiguration } from "./async-stores/async-configuration-revision-store.js";
export type { ConfigKind, ConfigChangedBy, ConfigurationOwnerScope, ConfigurationTarget, ConfigurationRevision } from "./types.js"; export type { ConfigKind, ConfigChangedBy, ConfigurationOwnerScope, ConfigurationTarget, ConfigurationRevision } from "./types.js";
export { CONFIG_CHANGED_BY_SYSTEM, CONFIG_CHANGED_BY_API_VERIFIED_TOKEN, CONFIG_CHANGED_BY_API_UNVERIFIED, CONFIG_CHANGED_BY_API_VERIFIED_NODE_KEY } from "./types.js";
export { isValidSqliteDatabaseFile } from "./db/sqlite-validation.js"; export { isValidSqliteDatabaseFile } from "./db/sqlite-validation.js";
export { DaemonTokenManager, DAEMON_TOKEN_PREFIX, DAEMON_TOKEN_HEX_LENGTH, isDaemonTokenFormat } from "./cli/daemon-token.js"; export { DaemonTokenManager, DAEMON_TOKEN_PREFIX, DAEMON_TOKEN_HEX_LENGTH, isDaemonTokenFormat } from "./cli/daemon-token.js";
export { export {

View File

@@ -8,6 +8,7 @@
*/ */
import {TaskStore, storeLog} from "../store.js"; import {TaskStore, storeLog} from "../store.js";
import type {BoardConfig, Settings, GlobalSettings, ConfigChangedBy} from "../types.js"; import type {BoardConfig, Settings, GlobalSettings, ConfigChangedBy} from "../types.js";
import { CONFIG_CHANGED_BY_SYSTEM } from "../types.js";
import {DEFAULT_SETTINGS, isGlobalOnlySettingsKey} from "../types.js"; import {DEFAULT_SETTINGS, isGlobalOnlySettingsKey} from "../types.js";
import {MOVED_SETTINGS_KEYS, stripMovedSettingsKeys, patchContainsMovedKey} from "../config/moved-settings.js"; import {MOVED_SETTINGS_KEYS, stripMovedSettingsKeys, patchContainsMovedKey} from "../config/moved-settings.js";
import "../builtin-traits.js"; import "../builtin-traits.js";
@@ -71,7 +72,7 @@ export async function publishSettingsUpdated(store: TaskStore, previous: Setting
} }
} }
export async function updateSettingsImpl(store: TaskStore, patch: Partial<Settings>, changedBy: ConfigChangedBy = { kind: "human", id: "local-user" }): Promise<Settings> { export async function updateSettingsImpl(store: TaskStore, patch: Partial<Settings>, changedBy: ConfigChangedBy = CONFIG_CHANGED_BY_SYSTEM): Promise<Settings> {
assertValidRecommendationSettingsPatch(patch as Record<string, unknown>); assertValidRecommendationSettingsPatch(patch as Record<string, unknown>);
assertValidCredentialInstanceSettingsPatch(patch as Record<string, unknown>); assertValidCredentialInstanceSettingsPatch(patch as Record<string, unknown>);
/* /*
@@ -213,7 +214,7 @@ export async function updateSettingsImpl(store: TaskStore, patch: Partial<Settin
}); });
} }
export async function updateGlobalSettingsImpl(store: TaskStore, patch: Partial<GlobalSettings>, changedBy: ConfigChangedBy = { kind: "human", id: "local-user" }): Promise<Settings> { export async function updateGlobalSettingsImpl(store: TaskStore, patch: Partial<GlobalSettings>, changedBy: ConfigChangedBy = CONFIG_CHANGED_BY_SYSTEM): Promise<Settings> {
assertValidCredentialInstanceSettingsPatch(patch as Record<string, unknown>); assertValidCredentialInstanceSettingsPatch(patch as Record<string, unknown>);
// Read previous state BEFORE writing so the diff is correct // Read previous state BEFORE writing so the diff is correct
const previousGlobal = await store.globalSettingsStore.getSettings(); const previousGlobal = await store.globalSettingsStore.getSettings();

View File

@@ -20,6 +20,7 @@ import {mkdir, readFile, writeFile, rename, unlink} from "node:fs/promises";
import {join} from "node:path"; import {join} from "node:path";
import {existsSync} from "node:fs"; import {existsSync} from "node:fs";
import type {Task, TaskCreateInput, TaskAttachment, BoardConfig, ActivityLogEntry, ActivityEventType, Artifact, ArtifactCreateInput, RunMutationContext, MergeQueueEntry, BranchGroup, BranchGroupUpdate, CompletionHandoffMarker, WorkflowWorkItem, WorkflowWorkItemKind, PrEntity, PrEntityUpdate, TaskRecommendation} from "../types.js"; import type {Task, TaskCreateInput, TaskAttachment, BoardConfig, ActivityLogEntry, ActivityEventType, Artifact, ArtifactCreateInput, RunMutationContext, MergeQueueEntry, BranchGroup, BranchGroupUpdate, CompletionHandoffMarker, WorkflowWorkItem, WorkflowWorkItemKind, PrEntity, PrEntityUpdate, TaskRecommendation} from "../types.js";
import { CONFIG_CHANGED_BY_SYSTEM } from "../types.js";
import {validateSettingValuePatch, WorkflowSettingRejectionError} from "../workflows/workflow-settings.js"; import {validateSettingValuePatch, WorkflowSettingRejectionError} from "../workflows/workflow-settings.js";
import "../builtin-traits.js"; import "../builtin-traits.js";
import {toJson} from "../db/db.js"; import {toJson} from "../db/db.js";
@@ -523,7 +524,7 @@ export function getWorkflowPromptOverridesImpl(_store: TaskStore, _workflowId: s
return {}; return {};
} }
export async function updateWorkflowSettingValuesImpl(store: TaskStore, workflowId: string, projectId: string, patch: Record<string, unknown>, changedBy: ConfigChangedBy = { kind: "human", id: "local-user" },): Promise<Record<string, unknown>> { export async function updateWorkflowSettingValuesImpl(store: TaskStore, workflowId: string, projectId: string, patch: Record<string, unknown>, changedBy: ConfigChangedBy = CONFIG_CHANGED_BY_SYSTEM,): Promise<Record<string, unknown>> {
/* /*
FNXC:ConfigVersioning 2026-07-18-19:10: FNXC:ConfigVersioning 2026-07-18-19:10:
Workflow values are rollbackable only with the PostgreSQL target mutation Workflow values are rollbackable only with the PostgreSQL target mutation
@@ -617,7 +618,7 @@ export async function updateWorkflowSettingValuesImpl(store: TaskStore, workflow
return committed.next; return committed.next;
} }
export async function rollbackConfigurationImpl(store: TaskStore, revisionId: string, changedBy: ConfigChangedBy = {kind: "human", id: "local-user"}): Promise<ConfigurationRevision> { export async function rollbackConfigurationImpl(store: TaskStore, revisionId: string, changedBy: ConfigChangedBy = CONFIG_CHANGED_BY_SYSTEM): Promise<ConfigurationRevision> {
if (!store.backendMode) throw new Error("Configuration rollback requires the PostgreSQL revision store"); if (!store.backendMode) throw new Error("Configuration rollback requires the PostgreSQL revision store");
const layer = store.asyncLayer!; const layer = store.asyncLayer!;
// First resolve project ownership without a bypass. The selected snapshot and // First resolve project ownership without a bypass. The selected snapshot and

View File

@@ -1539,3 +1539,5 @@ The sorter and its transitive role/merge/priority helpers are browser-safe.
*/ */
export { sortTasksForDisplayColumn } from "./tasks/task-priority.js"; export { sortTasksForDisplayColumn } from "./tasks/task-priority.js";
export type { DoneColumnSortMode, DisplayColumnSortOptions } from "./tasks/task-priority.js"; export type { DoneColumnSortMode, DisplayColumnSortOptions } from "./tasks/task-priority.js";
export { CONFIG_CHANGED_BY_SYSTEM, CONFIG_CHANGED_BY_API_VERIFIED_TOKEN, CONFIG_CHANGED_BY_API_UNVERIFIED, CONFIG_CHANGED_BY_API_VERIFIED_NODE_KEY } from "./types/agents/agents.js";

View File

@@ -975,7 +975,19 @@ export type ConfigChangedBy =
| { kind: "human"; id: string } | { kind: "human"; id: string }
| { kind: "agent"; id: string } | { kind: "agent"; id: string }
| { kind: "system"; id: string } | { kind: "system"; id: string }
| { kind: "api"; id: string }
| { kind: "rollback"; id: string }; | { kind: "rollback"; id: string };
/*
FNXC:ConfigVersioning 2026-08-09-04:06:
HTTP settings writes have no person identity: the daemon credential is shared.
Only server-side verification determines whether an API request records verified,
unverified, or node-key provenance; omitted internal callers record system.
*/
export const CONFIG_CHANGED_BY_SYSTEM: ConfigChangedBy = { kind: "system", id: "fusion-system" };
export const CONFIG_CHANGED_BY_API_VERIFIED_TOKEN: ConfigChangedBy = { kind: "api", id: "http:verified-token" };
export const CONFIG_CHANGED_BY_API_UNVERIFIED: ConfigChangedBy = { kind: "api", id: "http:unverified" };
export const CONFIG_CHANGED_BY_API_VERIFIED_NODE_KEY: ConfigChangedBy = { kind: "api", id: "http:verified-node-key" };
export type ConfigurationOwnerScope = "project" | "global"; export type ConfigurationOwnerScope = "project" | "global";
export type ConfigurationTarget = Readonly<Record<string, string>>; export type ConfigurationTarget = Readonly<Record<string, string>>;
export interface ConfigurationRevision { export interface ConfigurationRevision {

View File

@@ -2,7 +2,7 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import type { Request, Response, NextFunction } from "express"; import type { Request, Response, NextFunction } from "express";
import { createAuthMiddleware, isDaemonAuthActive } from "../auth-middleware.js"; import { createAuthMiddleware, hasVerifiedDaemonRequest, isDaemonAuthActive } from "../auth-middleware.js";
describe("createAuthMiddleware", () => { describe("createAuthMiddleware", () => {
let mockReq: Partial<Request>; let mockReq: Partial<Request>;
@@ -72,6 +72,33 @@ describe("createAuthMiddleware", () => {
expect(nextFn).toHaveBeenCalled(); expect(nextFn).toHaveBeenCalled();
expect(mockRes.status).not.toHaveBeenCalled(); expect(mockRes.status).not.toHaveBeenCalled();
expect(hasVerifiedDaemonRequest(mockReq as Request)).toBe(true);
});
it("marks a valid fn_token query request as verified", () => {
mockReq.url = "/api/tasks?fn_token=fn_abc123def456789";
const middleware = createAuthMiddleware("fn_abc123def456789");
middleware(mockReq as Request, mockRes as Response, nextFn);
expect(nextFn).toHaveBeenCalled();
expect(hasVerifiedDaemonRequest(mockReq as Request)).toBe(true);
});
it("does not mark exempt, SPA, or rejected requests as verified", () => {
const middleware = createAuthMiddleware("fn_abc123def456789");
mockReq.path = "/api/health";
middleware(mockReq as Request, mockRes as Response, nextFn);
expect(hasVerifiedDaemonRequest(mockReq as Request)).toBe(false);
mockReq.path = "/";
mockReq.headers = { authorization: "Bearer fn_abc123def456789" };
middleware(mockReq as Request, mockRes as Response, nextFn);
expect(hasVerifiedDaemonRequest(mockReq as Request)).toBe(false);
mockReq.path = "/api/tasks";
mockReq.headers = { authorization: "Bearer wrong" };
middleware(mockReq as Request, mockRes as Response, nextFn);
expect(hasVerifiedDaemonRequest(mockReq as Request)).toBe(false);
}); });
it("exempts /api/health path without token", () => { it("exempts /api/health path without token", () => {

View File

@@ -0,0 +1,46 @@
// @vitest-environment node
import express, { type Request } from "express";
import { describe, expect, it } from "vitest";
import { createAuthMiddleware } from "../auth-middleware.js";
import { resolveRequestActor } from "../request-actor.js";
import { request } from "../test-request.js";
function createApp(token?: string) {
const app = express();
app.use(express.json());
if (token) app.use(createAuthMiddleware(token));
app.post("/api/actor", (req, res) => res.json(resolveRequestActor(req as Request)));
return app;
}
describe("resolveRequestActor", () => {
it("reports only middleware-verified daemon credentials", async () => {
const app = createApp("shared-token");
const header = await request(app, "POST", "/api/actor", "{}", { authorization: "Bearer shared-token", "Content-Type": "application/json" });
const query = await request(app, "POST", "/api/actor?fn_token=shared-token", "{}", { "Content-Type": "application/json" });
const rejected = await request(app, "POST", "/api/actor", "{}", { authorization: "Bearer invalid", "Content-Type": "application/json" });
expect(header.body).toEqual({ kind: "api", id: "http:verified-token" });
expect(query.body).toEqual({ kind: "api", id: "http:verified-token" });
expect(rejected.status).toBe(401);
});
it("does not upgrade attribution from unverified client input", async () => {
const app = createApp();
const arbitraryHeader = "Bearer attacker-controlled-token";
const noCredential = await request(app, "POST", "/api/actor", "{}", { "Content-Type": "application/json" });
const header = await request(app, "POST", "/api/actor", JSON.stringify({ verifiedDaemonRequest: true }), { authorization: arbitraryHeader, "Content-Type": "application/json", "x-verified-daemon-request": "true" });
const query = await request(app, "POST", "/api/actor?fn_token=attacker-controlled-token", "{}", { "Content-Type": "application/json" });
expect(noCredential.body).toEqual({ kind: "api", id: "http:unverified" });
expect(header.body).toEqual({ kind: "api", id: "http:unverified" });
expect(query.body).toEqual({ kind: "api", id: "http:unverified" });
for (const actor of [noCredential.body, header.body, query.body]) {
expect(actor.kind).not.toBe("human");
expect(JSON.stringify(actor)).not.toContain("attacker-controlled-token");
}
});
});

View File

@@ -15,6 +15,12 @@ import type { IncomingMessage } from "node:http";
* `?fn_token=<token>` on those URLs. * `?fn_token=<token>` on those URLs.
*/ */
export const TOKEN_QUERY_PARAM = "fn_token"; export const TOKEN_QUERY_PARAM = "fn_token";
const verifiedDaemonRequest = Symbol("verifiedDaemonRequest");
/** True only when createAuthMiddleware completed a daemon-token check. */
export function hasVerifiedDaemonRequest(req: Request): boolean {
return (req as Request & { [verifiedDaemonRequest]?: boolean })[verifiedDaemonRequest] === true;
}
/** /**
* Paths exempt from the daemon bearer-token middleware. * Paths exempt from the daemon bearer-token middleware.
@@ -183,6 +189,8 @@ export function createAuthMiddleware(token: string) {
return; return;
} }
/* FNXC:ConfigVersioning 2026-08-09-04:06: only a successful constant-time daemon token check may establish verified API provenance. */
(req as Request & { [verifiedDaemonRequest]?: boolean })[verifiedDaemonRequest] = true;
next(); next();
}; };
} }

View File

@@ -0,0 +1,13 @@
import type { ConfigChangedBy } from "@fusion/core";
import { CONFIG_CHANGED_BY_API_UNVERIFIED, CONFIG_CHANGED_BY_API_VERIFIED_TOKEN } from "@fusion/core";
import type { Request } from "express";
import { hasVerifiedDaemonRequest } from "./auth-middleware.js";
/*
FNXC:ConfigVersioning 2026-08-09-04:06:
A shared daemon token never identifies a human. This helper reads only the
middleware verification marker, never client credentials or request input.
*/
export function resolveRequestActor(req: Request): ConfigChangedBy {
return hasVerifiedDaemonRequest(req) ? CONFIG_CHANGED_BY_API_VERIFIED_TOKEN : CONFIG_CHANGED_BY_API_UNVERIFIED;
}

View File

@@ -2,6 +2,7 @@
import express from "express"; import express from "express";
import { beforeEach, describe, expect, it, vi } from "vitest"; import { beforeEach, describe, expect, it, vi } from "vitest";
import { createAuthMiddleware } from "../../auth-middleware.js";
import { request } from "../../test-request.js"; import { request } from "../../test-request.js";
import { registerOrgPortabilityRoutes } from "../register-org-portability-routes.js"; import { registerOrgPortabilityRoutes } from "../register-org-portability-routes.js";
@@ -12,11 +13,13 @@ const core = vi.hoisted(() => ({
assembleOrgBundle: vi.fn(), assembleOrgBundle: vi.fn(),
materializeOrgBundle: vi.fn(), materializeOrgBundle: vi.fn(),
ConfigurationRevisionStore: vi.fn(), ConfigurationRevisionStore: vi.fn(),
CONFIG_CHANGED_BY_API_UNVERIFIED: { kind: "api", id: "http:unverified" },
CONFIG_CHANGED_BY_API_VERIFIED_TOKEN: { kind: "api", id: "http:verified-token" },
})); }));
vi.mock("@fusion/core", () => core); vi.mock("@fusion/core", () => core);
function createApp() { function createApp(token?: string) {
const store = { const store = {
getAsyncLayer: vi.fn(() => ({ projectId: "project-1" })), getAsyncLayer: vi.fn(() => ({ projectId: "project-1" })),
getFusionDir: vi.fn(() => "/project/.fusion"), getFusionDir: vi.fn(() => "/project/.fusion"),
@@ -37,6 +40,7 @@ function createApp() {
}); });
const app = express(); const app = express();
app.use(express.json()); app.use(express.json());
if (token) app.use(createAuthMiddleware(token));
app.use("/api", router); app.use("/api", router);
app.use((error: { statusCode?: number; message?: string }, _req: express.Request, res: express.Response, _next: express.NextFunction) => { app.use((error: { statusCode?: number; message?: string }, _req: express.Request, res: express.Response, _next: express.NextFunction) => {
res.status(error.statusCode ?? 500).json({ error: error.message }); res.status(error.statusCode ?? 500).json({ error: error.message });
@@ -95,7 +99,16 @@ describe("register-org-portability-routes", () => {
expect(response.status).toBe(200); expect(response.status).toBe(200);
expect(response.body.revision).toMatchObject({ id: "forward-revision", source: "rollback" }); expect(response.body.revision).toMatchObject({ id: "forward-revision", source: "rollback" });
expect(store.rollbackConfiguration).toHaveBeenCalledWith("prior", { kind: "human", id: "dashboard-operator" }); expect(store.rollbackConfiguration).toHaveBeenCalledWith("prior", { kind: "api", id: "http:unverified" });
});
it("uses verified API provenance when daemon auth accepted the rollback request", async () => {
const { app, store } = createApp("shared-token");
store.rollbackConfiguration.mockResolvedValue({ id: "forward-revision", source: "rollback" });
const response = await request(app, "POST", "/api/config/revisions/prior/rollback", "{}", { authorization: "Bearer shared-token", "Content-Type": "application/json" });
expect(response.status).toBe(200);
expect(store.rollbackConfiguration).toHaveBeenCalledWith("prior", { kind: "api", id: "http:verified-token" });
}); });
it("rejects malformed imports and unsupported revision filters", async () => { it("rejects malformed imports and unsupported revision filters", async () => {

View File

@@ -2,6 +2,7 @@
import express from "express"; import express from "express";
import { beforeEach, describe, expect, it, vi } from "vitest"; import { beforeEach, describe, expect, it, vi } from "vitest";
import { createAuthMiddleware } from "../../auth-middleware.js";
import { import {
__resetCreateFnAgentForInsights, __resetCreateFnAgentForInsights,
__setCreateFnAgentForInsights, __setCreateFnAgentForInsights,
@@ -58,7 +59,7 @@ vi.mock("@fusion/engine", async () => {
}; };
}); });
function createApp(pluginRunner?: Record<string, unknown>) { function createApp(pluginRunner?: Record<string, unknown>, daemonToken?: string) {
const router = express.Router(); const router = express.Router();
const scopedStore = { const scopedStore = {
getSettings: vi.fn(async () => ({ worktrunk: { enabled: false }, memoryDreamsEnabled: true })), getSettings: vi.fn(async () => ({ worktrunk: { enabled: false }, memoryDreamsEnabled: true })),
@@ -103,6 +104,7 @@ function createApp(pluginRunner?: Record<string, unknown>) {
const app = express(); const app = express();
app.use(express.json()); app.use(express.json());
if (daemonToken) app.use(createAuthMiddleware(daemonToken));
app.use("/api", router); app.use("/api", router);
app.use((err: any, _req: express.Request, res: express.Response, _next: express.NextFunction) => { app.use((err: any, _req: express.Request, res: express.Response, _next: express.NextFunction) => {
res.status(err?.statusCode ?? 500).json({ error: err?.message ?? String(err) }); res.status(err?.statusCode ?? 500).json({ error: err?.message ?? String(err) });
@@ -152,7 +154,10 @@ describe("register-settings-memory-routes worktrunk gate", () => {
expect(res.status).toBe(200); expect(res.status).toBe(200);
expect(scopedStore.updateSettings).toHaveBeenCalledTimes(1); expect(scopedStore.updateSettings).toHaveBeenCalledTimes(1);
expect(scopedStore.updateSettings).toHaveBeenCalledWith({ worktrunk: { enabled: true } }); expect(scopedStore.updateSettings).toHaveBeenCalledWith(
{ worktrunk: { enabled: true } },
{ kind: "api", id: "http:unverified" },
);
}); });
it("accepts worktrunk.enabled=false without verification", async () => { it("accepts worktrunk.enabled=false without verification", async () => {
@@ -205,7 +210,41 @@ describe("register-settings-memory-routes worktrunk gate", () => {
const res = await patchSettings(app, { worktreeNaming: "task-id" }); const res = await patchSettings(app, { worktreeNaming: "task-id" });
expect(res.status).toBe(200); expect(res.status).toBe(200);
expect(scopedStore.updateSettings).toHaveBeenCalledWith({ worktreeNaming: "task-id" }); expect(scopedStore.updateSettings).toHaveBeenCalledWith(
{ worktreeNaming: "task-id" },
{ kind: "api", id: "http:unverified" },
);
});
it("records unverified API provenance for populated and null-delete patches", async () => {
const { app, scopedStore } = createApp();
await patchSettings(app, { autoMerge: true });
await patchSettings(app, { autoMerge: null, changedBy: { kind: "human", id: "forged" } });
expect(scopedStore.updateSettings).toHaveBeenNthCalledWith(
1,
{ autoMerge: true },
{ kind: "api", id: "http:unverified" },
);
expect(scopedStore.updateSettings).toHaveBeenNthCalledWith(
2,
{ autoMerge: null, changedBy: { kind: "human", id: "forged" } },
{ kind: "api", id: "http:unverified" },
);
});
it("records verified API provenance only after daemon authentication", async () => {
const { app, scopedStore } = createApp(undefined, "shared-token");
const response = await performRequest(app, "PUT", "/api/settings", JSON.stringify({ autoMerge: true }), {
authorization: "Bearer shared-token", "Content-Type": "application/json",
});
expect(response.status).toBe(200);
expect(scopedStore.updateSettings).toHaveBeenCalledWith(
{ autoMerge: true },
{ kind: "api", id: "http:verified-token" },
);
}); });
it("maps the store backstop 'mutually exclusive' error to 400 (not 500)", async () => { it("maps the store backstop 'mutually exclusive' error to 400 (not 500)", async () => {

View File

@@ -0,0 +1,156 @@
// @vitest-environment node
import express from "express";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { createAuthMiddleware } from "../../auth-middleware.js";
import { request } from "../../test-request.js";
const core = vi.hoisted(() => ({
CentralCore: vi.fn(),
isMovedSettingsKey: vi.fn(() => false),
CONFIG_CHANGED_BY_API_VERIFIED_NODE_KEY: { kind: "api", id: "http:verified-node-key" },
}));
const helpers = vi.hoisted(() => ({ fetchFromRemoteNode: vi.fn() }));
vi.mock("@fusion/core", async () => ({
...(await vi.importActual<typeof import("@fusion/core")>("@fusion/core")),
...core,
}));
vi.mock("../register-settings-sync-helpers.js", async () => ({
...(await vi.importActual<typeof import("../register-settings-sync-helpers.js")>("../register-settings-sync-helpers.js")),
fetchFromRemoteNode: helpers.fetchFromRemoteNode,
}));
import { registerSettingsSyncRoutes } from "../register-settings-sync-routes.js";
import { registerSettingsSyncInboundRoutes } from "../register-settings-sync-inbound-routes.js";
function makeContext() {
const store = {
backendMode: false,
getGlobalSettingsDir: vi.fn(() => "/global"),
getGlobalSettingsStore: vi.fn(() => ({ getSettings: vi.fn(async () => ({})) })),
updateGlobalSettings: vi.fn(async () => undefined),
getWorkflowSettingsProjectId: vi.fn(() => "project-1"),
updateWorkflowSettingValues: vi.fn(async () => ({})),
};
const router = express.Router();
const context = {
router, store,
emitAuthSyncAuditLog: vi.fn(),
rethrowAsApiError: (error: unknown) => { throw error; },
} as never;
return { router, store, context };
}
function appForPull(token?: string) {
const { router, store, context } = makeContext();
registerSettingsSyncRoutes(context);
const app = express();
app.use(express.json());
if (token) app.use(createAuthMiddleware(token));
app.use("/api", router);
app.use((error: { statusCode?: number; message?: string }, _req: express.Request, res: express.Response, _next: express.NextFunction) => {
res.status(error.statusCode ?? 500).json({ error: error.message });
});
return { app, store };
}
function appForInbound() {
const { router, store, context } = makeContext();
registerSettingsSyncInboundRoutes(context);
const app = express();
app.use(express.json());
app.use("/api", router);
app.use((error: { statusCode?: number; message?: string }, _req: express.Request, res: express.Response, _next: express.NextFunction) => {
res.status(error.statusCode ?? 500).json({ error: error.message });
});
return { app, store };
}
describe("settings sync revision attribution", () => {
beforeEach(() => {
vi.clearAllMocks();
helpers.fetchFromRemoteNode.mockResolvedValue({ global: { defaultModelId: "remote" }, project: {}, workflowSettings: { "builtin:coding": { workflowStepTimeoutMs: 1 } } });
core.CentralCore.mockImplementation(function CentralCore() {
return {
init: vi.fn(), close: vi.fn(), getNode: vi.fn(async () => ({ id: "remote", type: "remote" })),
applyRemoteSettings: vi.fn(async () => ({ success: true })), updateSettingsSyncState: vi.fn(),
listNodes: vi.fn(async () => [{ id: "local", type: "local", apiKey: "node-key" }]),
};
});
});
it("uses the daemon verification result for both pull writes", async () => {
const { app, store } = appForPull("daemon-token");
const response = await request(app, "POST", "/api/nodes/remote/settings/pull", JSON.stringify({}), { authorization: "Bearer daemon-token", "content-type": "application/json" });
expect(response.status).toBe(200);
expect(store.updateGlobalSettings).toHaveBeenCalledWith({ defaultModelId: "remote" }, { kind: "api", id: "http:verified-token" });
expect(store.updateWorkflowSettingValues).toHaveBeenCalledWith("builtin:coding", "project-1", { workflowStepTimeoutMs: 1 }, { kind: "api", id: "http:verified-token" });
});
it("does not trust an arbitrary bearer header when daemon auth is inactive", async () => {
const { app, store } = appForPull();
const response = await request(app, "POST", "/api/nodes/remote/settings/pull", JSON.stringify({}), { authorization: "Bearer forged", "content-type": "application/json" });
expect(response.status).toBe(200);
expect(store.updateGlobalSettings).toHaveBeenCalledWith(expect.any(Object), { kind: "api", id: "http:unverified" });
expect(store.updateWorkflowSettingValues).toHaveBeenCalledWith(expect.any(String), expect.any(String), expect.any(Object), { kind: "api", id: "http:unverified" });
});
it("preserves the daemon-derived actor across rejected workflow-setting retries", async () => {
helpers.fetchFromRemoteNode.mockResolvedValueOnce({
global: {}, project: {}, workflowSettings: {
"builtin:coding": { workflowStepScopeEnforcement: "warn", workflowStepTimeoutMs: 1 },
},
});
const { app, store } = appForPull("daemon-token");
store.updateWorkflowSettingValues
.mockRejectedValueOnce({ rejections: [{ settingId: "workflowStepScopeEnforcement" }] })
.mockResolvedValueOnce({ workflowStepTimeoutMs: 1 });
const response = await request(app, "POST", "/api/nodes/remote/settings/pull", JSON.stringify({}), { authorization: "Bearer daemon-token", "content-type": "application/json" });
expect(response.status).toBe(200);
expect(store.updateWorkflowSettingValues).toHaveBeenCalledTimes(2);
expect(store.updateWorkflowSettingValues.mock.calls.map(([, , , actor]) => actor)).toEqual([
{ kind: "api", id: "http:verified-token" },
{ kind: "api", id: "http:verified-token" },
]);
});
it("uses node-key provenance only after the inbound apiKey check passes", async () => {
const { app, store } = appForInbound();
const body = { sourceNodeId: "remote", exportedAt: "2026-08-09T00:00:00.000Z", global: { defaultModelId: "remote" }, workflowSettings: { "builtin:coding": { workflowStepTimeoutMs: 1 } } };
const accepted = await request(app, "POST", "/api/settings/sync-receive", JSON.stringify(body), { authorization: "Bearer node-key", "content-type": "application/json" });
const rejected = await request(app, "POST", "/api/settings/sync-receive", JSON.stringify(body), { authorization: "Bearer wrong", "content-type": "application/json" });
expect(accepted.status).toBe(200);
expect(store.updateGlobalSettings).toHaveBeenCalledWith({ defaultModelId: "remote" }, { kind: "api", id: "http:verified-node-key" });
expect(store.updateWorkflowSettingValues).toHaveBeenCalledWith("builtin:coding", "project-1", { workflowStepTimeoutMs: 1 }, { kind: "api", id: "http:verified-node-key" });
expect(rejected.status).toBe(401);
expect(store.updateGlobalSettings).toHaveBeenCalledTimes(1);
});
it("preserves node-key provenance across inbound workflow-setting retries and skips empty sections", async () => {
const { app, store } = appForInbound();
store.updateWorkflowSettingValues
.mockRejectedValueOnce({ rejections: [{ settingId: "workflowStepScopeEnforcement" }] })
.mockResolvedValueOnce({ workflowStepTimeoutMs: 1 });
const body = {
sourceNodeId: "remote", exportedAt: "2026-08-09T00:00:00.000Z", global: {},
workflowSettings: { "builtin:coding": { workflowStepScopeEnforcement: "warn", workflowStepTimeoutMs: 1 } },
};
const accepted = await request(app, "POST", "/api/settings/sync-receive", JSON.stringify(body), { authorization: "Bearer node-key", "content-type": "application/json" });
expect(accepted.status).toBe(200);
expect(store.updateWorkflowSettingValues).toHaveBeenCalledTimes(2);
expect(store.updateWorkflowSettingValues.mock.calls.map(([, , , actor]) => actor)).toEqual([
{ kind: "api", id: "http:verified-node-key" },
{ kind: "api", id: "http:verified-node-key" },
]);
const empty = await request(app, "POST", "/api/settings/sync-receive", JSON.stringify({ ...body, workflowSettings: {} }), { authorization: "Bearer node-key", "content-type": "application/json" });
expect(empty.status).toBe(200);
expect(store.updateWorkflowSettingValues).toHaveBeenCalledTimes(2);
});
});

View File

@@ -0,0 +1,73 @@
// @vitest-environment node
import express from "express";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { createAuthMiddleware } from "../../auth-middleware.js";
import { ApiError, sendErrorResponse } from "../../api-error.js";
import { request } from "../../test-request.js";
import { registerWorkflowRoutes } from "../register-workflow-routes.js";
import { createTaskStoreForTest, pgDescribe, type PgTestHarness } from "../../../../core/src/__test-utils__/pg-test-harness.js";
import { SCHEMA_VERSION, type TaskStore } from "@fusion/core";
const pgTest = pgDescribe;
pgTest("workflow setting revision attribution", () => {
let harness: PgTestHarness;
let store: TaskStore;
beforeEach(async () => {
harness = await createTaskStoreForTest();
store = harness.store;
});
afterEach(async () => { await harness.teardown(); });
function appFor(token?: string) {
const app = express();
app.use(express.json());
if (token) app.use(createAuthMiddleware(token));
const router = express.Router();
registerWorkflowRoutes({
router,
getProjectContext: async () => ({ store, engine: undefined, projectId: undefined }),
rethrowAsApiError: (error: unknown) => { throw error instanceof ApiError ? error : new ApiError(500, String(error)); },
options: {},
} as unknown as Parameters<typeof registerWorkflowRoutes>[0]);
app.use("/api", router);
app.use((error: unknown, _req: express.Request, res: express.Response, _next: express.NextFunction) => {
if (error instanceof ApiError) sendErrorResponse(res, error.statusCode, error.message, { details: error.details });
else sendErrorResponse(res, 500, String(error));
});
return app;
}
it("forwards only daemon-verified or unverified API actors for workflow setting patches", async () => {
const spy = vi.spyOn(store, "updateWorkflowSettingValues");
const app = appFor("shared-token");
const verified = await request(app, "PATCH", "/api/workflows/builtin:coding/setting-values", JSON.stringify({ values: { workflowStepTimeoutMs: 1000 } }), { authorization: "Bearer shared-token", "content-type": "application/json" });
const unverifiedApp = appFor();
const unverified = await request(unverifiedApp, "PATCH", "/api/workflows/builtin:coding/setting-values", JSON.stringify({ values: { workflowStepTimeoutMs: null } }), { authorization: "Bearer forged", "content-type": "application/json" });
expect(verified.status).toBe(200);
expect(unverified.status).toBe(200);
expect(spy).toHaveBeenNthCalledWith(1, "builtin:coding", expect.any(String), { workflowStepTimeoutMs: 1000 }, { kind: "api", id: "http:verified-token" });
expect(spy).toHaveBeenNthCalledWith(2, "builtin:coding", expect.any(String), expect.objectContaining({ workflowStepTimeoutMs: null }), { kind: "api", id: "http:unverified" });
});
it("forwards API provenance while restoring imported workflow settings", async () => {
const spy = vi.spyOn(store, "updateWorkflowSettingValues");
const source = await store.getWorkflowDefinition("builtin:coding");
expect(source).toBeDefined();
const response = await request(appFor("shared-token"), "POST", "/api/workflows/import", JSON.stringify({
fusionWorkflowExport: 1,
schemaVersion: SCHEMA_VERSION,
name: "Imported attribution workflow",
kind: "workflow",
ir: source!.ir,
layout: source!.layout,
settingValues: { workflowStepTimeoutMs: 1000 },
}), { authorization: "Bearer shared-token", "content-type": "application/json" });
expect(response.status).toBe(201);
expect(spy).toHaveBeenCalledWith(response.body.workflow.id, expect.any(String), { workflowStepTimeoutMs: 1000 }, { kind: "api", id: "http:verified-token" });
});
});

View File

@@ -1,4 +1,5 @@
import { ApiError, badRequest } from "../api-error.js"; import { ApiError, badRequest } from "../api-error.js";
import { resolveRequestActor } from "../request-actor.js";
import type { ApiRoutesContext } from "./types.js"; import type { ApiRoutesContext } from "./types.js";
/** /**
@@ -119,7 +120,7 @@ export function registerOrgPortabilityRoutes(ctx: ApiRoutesContext): void {
const revisionId = req.params.revisionId?.trim(); const revisionId = req.params.revisionId?.trim();
if (!revisionId) throw badRequest("revisionId is required"); if (!revisionId) throw badRequest("revisionId is required");
const { store: scopedStore } = await getProjectContext(req); const { store: scopedStore } = await getProjectContext(req);
const revision = await (scopedStore as unknown as { rollbackConfiguration(id: string, changedBy: { kind: "human"; id: string }): Promise<unknown> }).rollbackConfiguration(revisionId, { kind: "human", id: "dashboard-operator" }); const revision = await scopedStore.rollbackConfiguration(revisionId, resolveRequestActor(req));
res.json({ revision }); res.json({ revision });
} catch (error: unknown) { } catch (error: unknown) {
if (error instanceof ApiError) throw error; if (error instanceof ApiError) throw error;

View File

@@ -1,4 +1,5 @@
import { createLogger } from "@fusion/core"; import { createLogger } from "@fusion/core";
import { resolveRequestActor } from "../request-actor.js";
const severityAuditLog = createLogger("dashboard-register-settings-memory-routes"); const severityAuditLog = createLogger("dashboard-register-settings-memory-routes");
import { import {
@@ -738,7 +739,7 @@ export function registerSettingsMemoryRoutes(ctx: ApiRoutesContext, deps: Settin
} }
} }
const settings = await scopedStore.updateSettings(clientSettings); const settings = await scopedStore.updateSettings(clientSettings, resolveRequestActor(req));
res.json(settings); res.json(settings);
} catch (err: unknown) { } catch (err: unknown) {

View File

@@ -1,14 +1,21 @@
import { isMovedSettingsKey } from "@fusion/core"; import { isMovedSettingsKey, CONFIG_CHANGED_BY_API_VERIFIED_NODE_KEY } from "@fusion/core";
import { createFusionAuthStorage } from "@fusion/engine"; import { createFusionAuthStorage } from "@fusion/engine";
import { ApiError, badRequest } from "../api-error.js"; import { ApiError, badRequest } from "../api-error.js";
import { invalidateAllGlobalSettingsCaches } from "../project-store-resolver.js"; import { invalidateAllGlobalSettingsCaches } from "../project-store-resolver.js";
import { readStoredAuthProvidersFromDisk, toProviderAuthEntries } from "./register-settings-sync-helpers.js"; import { readStoredAuthProvidersFromDisk, toProviderAuthEntries } from "./register-settings-sync-helpers.js";
import type { ConfigChangedBy } from "@fusion/core";
import type { ApiRouteRegistrar } from "./types.js"; import type { ApiRouteRegistrar } from "./types.js";
/*
FNXC:ConfigVersioning 2026-08-09-04:06:
Settings sync preserves the actor derived before each write so retrying rejected
keys cannot create mixed provenance. Inbound node-key validation supplies its
verified actor only after its existing server-side comparison succeeds.
*/
type WorkflowSettingsSyncSection = Record<string, Record<string, unknown>>; type WorkflowSettingsSyncSection = Record<string, Record<string, unknown>>;
type WorkflowSettingsSyncStore = { type WorkflowSettingsSyncStore = {
getWorkflowSettingsProjectId(): string; getWorkflowSettingsProjectId(): string;
updateWorkflowSettingValues(workflowId: string, projectId: string, patch: Record<string, unknown>): Promise<Record<string, unknown>>; updateWorkflowSettingValues(workflowId: string, projectId: string, patch: Record<string, unknown>, changedBy?: ConfigChangedBy): Promise<Record<string, unknown>>;
}; };
function extractRejectedSettingIds(err: unknown): string[] { function extractRejectedSettingIds(err: unknown): string[] {
@@ -27,6 +34,7 @@ function extractRejectedSettingIds(err: unknown): string[] {
async function applyWorkflowSettingsSection( async function applyWorkflowSettingsSection(
store: WorkflowSettingsSyncStore, store: WorkflowSettingsSyncStore,
section: WorkflowSettingsSyncSection, section: WorkflowSettingsSyncSection,
changedBy: ConfigChangedBy,
): Promise<{ count: number; keys: string[] }> { ): Promise<{ count: number; keys: string[] }> {
const projectId = store.getWorkflowSettingsProjectId(); const projectId = store.getWorkflowSettingsProjectId();
let count = 0; let count = 0;
@@ -38,7 +46,7 @@ async function applyWorkflowSettingsSection(
while (Object.keys(patch).length > 0) { while (Object.keys(patch).length > 0) {
try { try {
await store.updateWorkflowSettingValues(workflowId, projectId, patch); await store.updateWorkflowSettingValues(workflowId, projectId, patch, changedBy);
const appliedKeys = Object.entries(patch) const appliedKeys = Object.entries(patch)
.filter(([, value]) => value !== null) .filter(([, value]) => value !== null)
.map(([key]) => key); .map(([key]) => key);
@@ -144,7 +152,7 @@ export const registerSettingsSyncInboundRoutes: ApiRouteRegistrar = (ctx) => {
.filter(([key, value]) => value !== undefined && localGlobal[key] === undefined && !isMovedSettingsKey(key)), .filter(([key, value]) => value !== undefined && localGlobal[key] === undefined && !isMovedSettingsKey(key)),
); );
if (Object.keys(globalPatch).length > 0) { if (Object.keys(globalPatch).length > 0) {
await store.updateGlobalSettings(globalPatch); await store.updateGlobalSettings(globalPatch, CONFIG_CHANGED_BY_API_VERIFIED_NODE_KEY);
invalidateAllGlobalSettingsCaches(); invalidateAllGlobalSettingsCaches();
} }
} }
@@ -152,7 +160,7 @@ export const registerSettingsSyncInboundRoutes: ApiRouteRegistrar = (ctx) => {
let workflowSettingsCount = 0; let workflowSettingsCount = 0;
let appliedWorkflowSettingKeys: string[] = []; let appliedWorkflowSettingKeys: string[] = [];
if (result.success && payload.workflowSettings && typeof payload.workflowSettings === "object" && !Array.isArray(payload.workflowSettings)) { if (result.success && payload.workflowSettings && typeof payload.workflowSettings === "object" && !Array.isArray(payload.workflowSettings)) {
const workflowApplyResult = await applyWorkflowSettingsSection(store, payload.workflowSettings as WorkflowSettingsSyncSection); const workflowApplyResult = await applyWorkflowSettingsSection(store, payload.workflowSettings as WorkflowSettingsSyncSection, CONFIG_CHANGED_BY_API_VERIFIED_NODE_KEY);
workflowSettingsCount = workflowApplyResult.count; workflowSettingsCount = workflowApplyResult.count;
appliedWorkflowSettingKeys = workflowApplyResult.keys; appliedWorkflowSettingKeys = workflowApplyResult.keys;
} }

View File

@@ -1,4 +1,5 @@
import type { ProjectSettings } from "@fusion/core"; import type { ProjectSettings, ConfigChangedBy } from "@fusion/core";
import { resolveRequestActor } from "../request-actor.js";
import { isMovedSettingsKey } from "@fusion/core"; import { isMovedSettingsKey } from "@fusion/core";
import { createFusionAuthStorage } from "@fusion/engine"; import { createFusionAuthStorage } from "@fusion/engine";
import { basename } from "node:path"; import { basename } from "node:path";
@@ -14,11 +15,17 @@ import {
} from "./register-settings-sync-helpers.js"; } from "./register-settings-sync-helpers.js";
import type { ApiRouteRegistrar } from "./types.js"; import type { ApiRouteRegistrar } from "./types.js";
/*
FNXC:ConfigVersioning 2026-08-09-04:06:
Settings sync preserves the actor derived before each write so retrying rejected
keys cannot create mixed provenance. Inbound node-key validation supplies its
verified actor only after its existing server-side comparison succeeds.
*/
type WorkflowSettingsSyncSection = Record<string, Record<string, unknown>>; type WorkflowSettingsSyncSection = Record<string, Record<string, unknown>>;
type WorkflowSettingsSyncStore = { type WorkflowSettingsSyncStore = {
getWorkflowSettingsProjectId(): string; getWorkflowSettingsProjectId(): string;
updateWorkflowSettingValues(workflowId: string, projectId: string, patch: Record<string, unknown>): Promise<Record<string, unknown>>; updateWorkflowSettingValues(workflowId: string, projectId: string, patch: Record<string, unknown>, changedBy?: ConfigChangedBy): Promise<Record<string, unknown>>;
}; };
export type SettingsDiff = { export type SettingsDiff = {
@@ -43,6 +50,7 @@ function extractRejectedSettingIds(err: unknown): string[] {
async function applyWorkflowSettingsSection( async function applyWorkflowSettingsSection(
store: WorkflowSettingsSyncStore, store: WorkflowSettingsSyncStore,
section: WorkflowSettingsSyncSection | undefined, section: WorkflowSettingsSyncSection | undefined,
changedBy: ConfigChangedBy,
): Promise<{ count: number; keys: string[] }> { ): Promise<{ count: number; keys: string[] }> {
if (!section) return { count: 0, keys: [] }; if (!section) return { count: 0, keys: [] };
const projectId = store.getWorkflowSettingsProjectId(); const projectId = store.getWorkflowSettingsProjectId();
@@ -54,7 +62,7 @@ async function applyWorkflowSettingsSection(
const patch: Record<string, unknown> = { ...rawValues }; const patch: Record<string, unknown> = { ...rawValues };
while (Object.keys(patch).length > 0) { while (Object.keys(patch).length > 0) {
try { try {
await store.updateWorkflowSettingValues(workflowId, projectId, patch); await store.updateWorkflowSettingValues(workflowId, projectId, patch, changedBy);
const appliedKeys = Object.entries(patch) const appliedKeys = Object.entries(patch)
.filter(([, value]) => value !== null) .filter(([, value]) => value !== null)
.map(([key]) => key); .map(([key]) => key);
@@ -354,7 +362,7 @@ export const registerSettingsSyncRoutes: ApiRouteRegistrar = (ctx) => {
checksum, checksum,
}); });
const workflowApplyResult = result.success const workflowApplyResult = result.success
? await applyWorkflowSettingsSection(store, remoteSettings.workflowSettings) ? await applyWorkflowSettingsSection(store, remoteSettings.workflowSettings, resolveRequestActor(req))
: { count: 0, keys: [] }; : { count: 0, keys: [] };
// applyRemoteSettings() only validates/strips the global payload; it does NOT // applyRemoteSettings() only validates/strips the global payload; it does NOT
@@ -365,7 +373,7 @@ export const registerSettingsSyncRoutes: ApiRouteRegistrar = (ctx) => {
// /settings/sync-receive path. The store's updateGlobalSettings() already // /settings/sync-receive path. The store's updateGlobalSettings() already
// strips moved (tombstoned) keys (KTD-8). // strips moved (tombstoned) keys (KTD-8).
if (result.success && remoteSettings.global && typeof remoteSettings.global === "object") { if (result.success && remoteSettings.global && typeof remoteSettings.global === "object") {
await store.updateGlobalSettings(remoteSettings.global); await store.updateGlobalSettings(remoteSettings.global, resolveRequestActor(req));
invalidateAllGlobalSettingsCaches(); invalidateAllGlobalSettingsCaches();
} }

View File

@@ -1,4 +1,5 @@
import type { WorkflowDefinition, WorkflowDefinitionKind, WorkflowIr, WorkflowIrNode, WorkflowSettingDefinition, TaskStore } from "@fusion/core"; import type { WorkflowDefinition, WorkflowDefinitionKind, WorkflowIr, WorkflowIrNode, WorkflowSettingDefinition, TaskStore } from "@fusion/core";
import { resolveRequestActor } from "../request-actor.js";
import { ColumnTraitValidationError, OccupiedColumnsError, InvalidRehomeTargetError, WorkflowIrError, ColumnAgentBindingError, WorkflowSettingRejectionError, SCHEMA_VERSION, assertColumnTraitsValid, layoutForIr, listTraits, listStepParsers, parseWorkflowIr, resolvePlanningSettingsModel, stripApprovalBypassFlags, resolveWorkflowIrById, resolveEffectiveSettingValues, findOrphanedSettingValues, isBuiltinWorkflowId, getBuiltinWorkflow, BUILTIN_WORKFLOW_SETTINGS, AgentStore, validateColumnAgentBindings, resolveWorkflowOptionalSteps, enumeratePromptBearingWorkflowNodes, normalizeWorkflowIcon, WorkflowSwitchRehomeFailedError } from "@fusion/core"; import { ColumnTraitValidationError, OccupiedColumnsError, InvalidRehomeTargetError, WorkflowIrError, ColumnAgentBindingError, WorkflowSettingRejectionError, SCHEMA_VERSION, assertColumnTraitsValid, layoutForIr, listTraits, listStepParsers, parseWorkflowIr, resolvePlanningSettingsModel, stripApprovalBypassFlags, resolveWorkflowIrById, resolveEffectiveSettingValues, findOrphanedSettingValues, isBuiltinWorkflowId, getBuiltinWorkflow, BUILTIN_WORKFLOW_SETTINGS, AgentStore, validateColumnAgentBindings, resolveWorkflowOptionalSteps, enumeratePromptBearingWorkflowNodes, normalizeWorkflowIcon, WorkflowSwitchRehomeFailedError } from "@fusion/core";
import { buildSessionSkillContextSync, createFnAgent as engineCreateFnAgent, validateCodeNodeSources, validateWorkflowIrDryRun } from "@fusion/engine"; import { buildSessionSkillContextSync, createFnAgent as engineCreateFnAgent, validateCodeNodeSources, validateWorkflowIrDryRun } from "@fusion/engine";
import { ApiError, badRequest, conflict, notFound, rateLimited } from "../api-error.js"; import { ApiError, badRequest, conflict, notFound, rateLimited } from "../api-error.js";
@@ -520,6 +521,7 @@ export function registerWorkflowRoutes(ctx: ApiRoutesContext): void {
workflowId, workflowId,
projectId, projectId,
values as Record<string, unknown>, values as Record<string, unknown>,
resolveRequestActor(req),
); );
const declarations = await resolveSettingDeclarations(store, workflowId); const declarations = await resolveSettingDeclarations(store, workflowId);
res.json({ res.json({
@@ -989,6 +991,7 @@ export function registerWorkflowRoutes(ctx: ApiRoutesContext): void {
workflow.id, workflow.id,
workflowProjectId, workflowProjectId,
importedSettingValues, importedSettingValues,
resolveRequestActor(req),
); );
} }
if (Object.keys(importedPromptOverrides).length > 0) { if (Object.keys(importedPromptOverrides).length > 0) {