From 53416f65355ca0912b85d8ccc96f4631684a34d6 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Sat, 8 Aug 2026 22:55:38 -0700 Subject: [PATCH] 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) --- .changeset/fn-8851-settings-attribution.md | 7 + .../postgres/settings-persistence.pg.test.ts | 17 ++ .../settings-revision-attribution.test.ts | 102 ++++++++++++ .../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 +- .../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 +++- .../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 ++++- .../settings-sync-attribution.test.ts | 156 ++++++++++++++++++ .../workflow-setting-attribution.test.ts | 73 ++++++++ .../routes/register-org-portability-routes.ts | 3 +- .../routes/register-settings-memory-routes.ts | 3 +- .../register-settings-sync-inbound-routes.ts | 18 +- .../routes/register-settings-sync-routes.ts | 18 +- .../src/routes/register-workflow-routes.ts | 3 + 25 files changed, 576 insertions(+), 33 deletions(-) create mode 100644 .changeset/fn-8851-settings-attribution.md create mode 100644 packages/core/src/__tests__/settings-revision-attribution.test.ts create mode 100644 packages/dashboard/src/__tests__/request-actor.test.ts create mode 100644 packages/dashboard/src/request-actor.ts create mode 100644 packages/dashboard/src/routes/__tests__/settings-sync-attribution.test.ts create mode 100644 packages/dashboard/src/routes/__tests__/workflow-setting-attribution.test.ts diff --git a/.changeset/fn-8851-settings-attribution.md b/.changeset/fn-8851-settings-attribution.md new file mode 100644 index 0000000000..f74e95c6dc --- /dev/null +++ b/.changeset/fn-8851-settings-attribution.md @@ -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. diff --git a/packages/core/src/__tests__/postgres/settings-persistence.pg.test.ts b/packages/core/src/__tests__/postgres/settings-persistence.pg.test.ts index 14c5bf36c4..ed4e3cf73f 100644 --- a/packages/core/src/__tests__/postgres/settings-persistence.pg.test.ts +++ b/packages/core/src/__tests__/postgres/settings-persistence.pg.test.ts @@ -12,6 +12,7 @@ import { type SharedPgTaskStoreHarness, } from "../../__test-utils__/pg-test-harness.js"; import { GLOBAL_SETTINGS_KEYS, PROJECT_SETTINGS_KEYS } from "../../config/settings-schema.js"; +import { sql } from "drizzle-orm"; const credentialLaneKeys = [ ["defaultProvider", "defaultCredentialInstanceId"], @@ -56,6 +57,22 @@ pgTest("VAL-CROSS-004: Settings persistence (PostgreSQL)", () => { 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 () => { const store = h.store(); await store.updateSettings({ diff --git a/packages/core/src/__tests__/settings-revision-attribution.test.ts b/packages/core/src/__tests__/settings-revision-attribution.test.ts new file mode 100644 index 0000000000..28725f498f --- /dev/null +++ b/packages/core/src/__tests__/settings-revision-attribution.test.ts @@ -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> { + 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 { + 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); + }); +}); diff --git a/packages/core/src/automation/automation-store.ts b/packages/core/src/automation/automation-store.ts index 2794bfb319..99ea084da2 100644 --- a/packages/core/src/automation/automation-store.ts +++ b/packages/core/src/automation/automation-store.ts @@ -14,6 +14,7 @@ import { assertProjectRootDir } from "../central/project-root-guard.js"; import type { AsyncDataLayer } from "../postgres/data-layer.js"; import { appendConfigurationRevision, createConfigurationRevision, getConfigurationRevision, rollbackConfiguration } from "../async-stores/async-configuration-revision-store.js"; import type { ConfigChangedBy, ConfigurationRevision } from "../types.js"; +import { CONFIG_CHANGED_BY_SYSTEM } from "../types.js"; /* * FNXC:PhysicalDeleteSqliteClass 2026-06-26-14:00: * Async Drizzle helpers for backend-mode (PostgreSQL) AutomationStore operations. @@ -261,7 +262,7 @@ export class AutomationStore extends EventEmitter { * callers must not lose their pre-existing ability to manage automations. */ - async createSchedule(input: ScheduledTaskCreateInput, changedBy: ConfigChangedBy = { kind: "human", id: "local-user" }): Promise { + async createSchedule(input: ScheduledTaskCreateInput, changedBy: ConfigChangedBy = CONFIG_CHANGED_BY_SYSTEM): Promise { this.requireVersionedConfigurationBackend(); if (!input.name?.trim()) { throw new Error("Name is required and cannot be empty"); @@ -323,7 +324,7 @@ export class AutomationStore extends EventEmitter { } /** Restore an automation snapshot by stable id and append one rollback revision. */ - async rollbackConfiguration(revisionId: string, changedBy: ConfigChangedBy = { kind: "human", id: "local-user" }): Promise { + async rollbackConfiguration(revisionId: string, changedBy: ConfigChangedBy = CONFIG_CHANGED_BY_SYSTEM): Promise { if (!this.backendMode) throw new Error("Configuration rollback requires the PostgreSQL revision store"); const layer = this.asyncLayer!; return layer.transactionImmediate((tx) => rollbackConfiguration(tx, layer.projectId ?? "", revisionId, changedBy, { @@ -353,7 +354,7 @@ export class AutomationStore extends EventEmitter { return listSchedulesAsync(this.asyncLayer!); } - async updateSchedule(id: string, updates: ScheduledTaskUpdateInput, changedBy: ConfigChangedBy = { kind: "human", id: "local-user" }): Promise { + async updateSchedule(id: string, updates: ScheduledTaskUpdateInput, changedBy: ConfigChangedBy = CONFIG_CHANGED_BY_SYSTEM): Promise { this.requireVersionedConfigurationBackend(); return this.withScheduleLock(id, async () => { const schedule = await this.getSchedule(id); @@ -437,7 +438,7 @@ export class AutomationStore extends EventEmitter { * 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. */ - async reorderSteps(scheduleId: string, stepIds: string[], changedBy: ConfigChangedBy = { kind: "human", id: "local-user" }): Promise { + async reorderSteps(scheduleId: string, stepIds: string[], changedBy: ConfigChangedBy = CONFIG_CHANGED_BY_SYSTEM): Promise { this.requireVersionedConfigurationBackend(); return this.withScheduleLock(scheduleId, async () => { const schedule = await this.getSchedule(scheduleId); @@ -474,7 +475,7 @@ export class AutomationStore extends EventEmitter { }); } - async deleteSchedule(id: string, changedBy: ConfigChangedBy = { kind: "human", id: "local-user" }): Promise { + async deleteSchedule(id: string, changedBy: ConfigChangedBy = CONFIG_CHANGED_BY_SYSTEM): Promise { this.requireVersionedConfigurationBackend(); return this.withScheduleLock(id, async () => { const schedule = await this.getSchedule(id); diff --git a/packages/core/src/automation/routine-store.ts b/packages/core/src/automation/routine-store.ts index f94cbef506..b7a00cade0 100644 --- a/packages/core/src/automation/routine-store.ts +++ b/packages/core/src/automation/routine-store.ts @@ -29,6 +29,7 @@ import { assertProjectRootDir } from "../central/project-root-guard.js"; import type { AsyncDataLayer } from "../postgres/data-layer.js"; import { appendConfigurationRevision, createConfigurationRevision, getConfigurationRevision, rollbackConfiguration } from "../async-stores/async-configuration-revision-store.js"; import type { ConfigChangedBy, ConfigurationRevision } from "../types.js"; +import { CONFIG_CHANGED_BY_SYSTEM } from "../types.js"; /* * FNXC:SqliteFinalRemoval 2026-06-26-10:30: * Async Drizzle helpers for backend-mode (PostgreSQL) RoutineStore operations. @@ -276,7 +277,7 @@ export class RoutineStore extends EventEmitter { /** * Create a new routine. */ - async createRoutine(input: RoutineCreateInput, changedBy: ConfigChangedBy = { kind: "human", id: "local-user" }): Promise { + async createRoutine(input: RoutineCreateInput, changedBy: ConfigChangedBy = CONFIG_CHANGED_BY_SYSTEM): Promise { this.requireVersionedConfigurationBackend(); if (!input.name?.trim()) { throw new Error("Name is required and cannot be empty"); @@ -366,7 +367,7 @@ export class RoutineStore extends EventEmitter { /** * Update an existing routine. */ - async updateRoutine(id: string, updates: RoutineUpdateInput, changedBy: ConfigChangedBy = { kind: "human", id: "local-user" }): Promise { + async updateRoutine(id: string, updates: RoutineUpdateInput, changedBy: ConfigChangedBy = CONFIG_CHANGED_BY_SYSTEM): Promise { this.requireVersionedConfigurationBackend(); return this.withRoutineLock(id, async () => { const routine = await this.getRoutine(id); @@ -439,7 +440,7 @@ export class RoutineStore extends EventEmitter { * selected revision predates creation. The replacement and forward revision * share one backend transaction. */ - async rollbackConfiguration(revisionId: string, changedBy: ConfigChangedBy = { kind: "human", id: "local-user" }): Promise { + async rollbackConfiguration(revisionId: string, changedBy: ConfigChangedBy = CONFIG_CHANGED_BY_SYSTEM): Promise { if (!this.backendMode) throw new Error("Configuration rollback requires the PostgreSQL revision store"); const layer = this.asyncLayer!; return layer.transactionImmediate((tx) => rollbackConfiguration(tx, layer.projectId ?? "", revisionId, changedBy, { @@ -460,7 +461,7 @@ export class RoutineStore extends EventEmitter { /** * Delete a routine. */ - async deleteRoutine(id: string, changedBy: ConfigChangedBy = { kind: "human", id: "local-user" }): Promise { + async deleteRoutine(id: string, changedBy: ConfigChangedBy = CONFIG_CHANGED_BY_SYSTEM): Promise { this.requireVersionedConfigurationBackend(); return this.withRoutineLock(id, async () => { const routine = await this.getRoutine(id); diff --git a/packages/core/src/config/global-settings.ts b/packages/core/src/config/global-settings.ts index 010d04f932..48b8754009 100644 --- a/packages/core/src/config/global-settings.ts +++ b/packages/core/src/config/global-settings.ts @@ -18,6 +18,7 @@ import { basename, dirname, join, resolve } from "node:path"; import { mkdir, readFile, writeFile, rename, chmod, unlink } from "node:fs/promises"; import { existsSync, mkdirSync, realpathSync, renameSync } from "node:fs"; 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 { sanitizeCliAgentsSettings } from "./settings-schema.js"; import type { AsyncDataLayer } from "../postgres/data-layer.js"; @@ -273,7 +274,7 @@ export class GlobalSettingsStore { */ async updateSettings( patch: Partial & Record, - changedBy: ConfigChangedBy = { kind: "human", id: "local-user" }, + changedBy: ConfigChangedBy = CONFIG_CHANGED_BY_SYSTEM, ): Promise { return this.withLock(async () => { // 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 * rollback revision. The filesystem compensation mirrors updateSettings(). */ - async rollbackConfiguration(revisionId: string, changedBy: ConfigChangedBy = { kind: "human", id: "local-user" }): Promise { + async rollbackConfiguration(revisionId: string, changedBy: ConfigChangedBy = CONFIG_CHANGED_BY_SYSTEM): Promise { const layer = await this.getRevisionLayer(); if (!layer) throw new Error("Configuration rollback requires the PostgreSQL revision store"); return this.withLock(async () => { diff --git a/packages/core/src/index.gate.ts b/packages/core/src/index.gate.ts index 7e992675f5..231a447c5d 100644 --- a/packages/core/src/index.gate.ts +++ b/packages/core/src/index.gate.ts @@ -982,6 +982,7 @@ export { GlobalSettingsStore, resolveGlobalDir, resolveGlobalDirForHome } from " 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 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 { DaemonTokenManager, DAEMON_TOKEN_PREFIX, DAEMON_TOKEN_HEX_LENGTH, isDaemonTokenFormat } from "./cli/daemon-token.js"; export { diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 7c53462b98..7fbe7b077a 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -1123,6 +1123,7 @@ export { GlobalSettingsStore, resolveGlobalDir, resolveGlobalDirForHome } from " 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 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 { DaemonTokenManager, DAEMON_TOKEN_PREFIX, DAEMON_TOKEN_HEX_LENGTH, isDaemonTokenFormat } from "./cli/daemon-token.js"; export { diff --git a/packages/core/src/task-store/settings-ops.ts b/packages/core/src/task-store/settings-ops.ts index b686d7138d..254b017a3c 100644 --- a/packages/core/src/task-store/settings-ops.ts +++ b/packages/core/src/task-store/settings-ops.ts @@ -8,6 +8,7 @@ */ import {TaskStore, storeLog} from "../store.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 {MOVED_SETTINGS_KEYS, stripMovedSettingsKeys, patchContainsMovedKey} from "../config/moved-settings.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, changedBy: ConfigChangedBy = { kind: "human", id: "local-user" }): Promise { +export async function updateSettingsImpl(store: TaskStore, patch: Partial, changedBy: ConfigChangedBy = CONFIG_CHANGED_BY_SYSTEM): Promise { assertValidRecommendationSettingsPatch(patch as Record); assertValidCredentialInstanceSettingsPatch(patch as Record); /* @@ -213,7 +214,7 @@ export async function updateSettingsImpl(store: TaskStore, patch: Partial, changedBy: ConfigChangedBy = { kind: "human", id: "local-user" }): Promise { +export async function updateGlobalSettingsImpl(store: TaskStore, patch: Partial, changedBy: ConfigChangedBy = CONFIG_CHANGED_BY_SYSTEM): Promise { assertValidCredentialInstanceSettingsPatch(patch as Record); // Read previous state BEFORE writing so the diff is correct const previousGlobal = await store.globalSettingsStore.getSettings(); diff --git a/packages/core/src/task-store/task-mutation-ops.ts b/packages/core/src/task-store/task-mutation-ops.ts index e30aa99213..9d44721b0c 100644 --- a/packages/core/src/task-store/task-mutation-ops.ts +++ b/packages/core/src/task-store/task-mutation-ops.ts @@ -20,6 +20,7 @@ import {mkdir, readFile, writeFile, rename, unlink} from "node:fs/promises"; import {join} from "node:path"; 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 { CONFIG_CHANGED_BY_SYSTEM } from "../types.js"; import {validateSettingValuePatch, WorkflowSettingRejectionError} from "../workflows/workflow-settings.js"; import "../builtin-traits.js"; import {toJson} from "../db/db.js"; @@ -523,7 +524,7 @@ export function getWorkflowPromptOverridesImpl(_store: TaskStore, _workflowId: s return {}; } -export async function updateWorkflowSettingValuesImpl(store: TaskStore, workflowId: string, projectId: string, patch: Record, changedBy: ConfigChangedBy = { kind: "human", id: "local-user" },): Promise> { +export async function updateWorkflowSettingValuesImpl(store: TaskStore, workflowId: string, projectId: string, patch: Record, changedBy: ConfigChangedBy = CONFIG_CHANGED_BY_SYSTEM,): Promise> { /* FNXC:ConfigVersioning 2026-07-18-19:10: Workflow values are rollbackable only with the PostgreSQL target mutation @@ -617,7 +618,7 @@ export async function updateWorkflowSettingValuesImpl(store: TaskStore, workflow return committed.next; } -export async function rollbackConfigurationImpl(store: TaskStore, revisionId: string, changedBy: ConfigChangedBy = {kind: "human", id: "local-user"}): Promise { +export async function rollbackConfigurationImpl(store: TaskStore, revisionId: string, changedBy: ConfigChangedBy = CONFIG_CHANGED_BY_SYSTEM): Promise { if (!store.backendMode) throw new Error("Configuration rollback requires the PostgreSQL revision store"); const layer = store.asyncLayer!; // First resolve project ownership without a bypass. The selected snapshot and diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index c9a2a9336b..872e093989 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -1539,3 +1539,5 @@ The sorter and its transitive role/merge/priority helpers are browser-safe. */ export { sortTasksForDisplayColumn } 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"; diff --git a/packages/core/src/types/agents/agents.ts b/packages/core/src/types/agents/agents.ts index b861bba4bd..5cdd5df6f0 100644 --- a/packages/core/src/types/agents/agents.ts +++ b/packages/core/src/types/agents/agents.ts @@ -975,7 +975,19 @@ export type ConfigChangedBy = | { kind: "human"; id: string } | { kind: "agent"; id: string } | { kind: "system"; id: string } + | { kind: "api"; 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 ConfigurationTarget = Readonly>; export interface ConfigurationRevision { diff --git a/packages/dashboard/src/__tests__/auth-middleware.test.ts b/packages/dashboard/src/__tests__/auth-middleware.test.ts index 475d77714a..f73dd67465 100644 --- a/packages/dashboard/src/__tests__/auth-middleware.test.ts +++ b/packages/dashboard/src/__tests__/auth-middleware.test.ts @@ -2,7 +2,7 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import type { Request, Response, NextFunction } from "express"; -import { createAuthMiddleware, isDaemonAuthActive } from "../auth-middleware.js"; +import { createAuthMiddleware, hasVerifiedDaemonRequest, isDaemonAuthActive } from "../auth-middleware.js"; describe("createAuthMiddleware", () => { let mockReq: Partial; @@ -72,6 +72,33 @@ describe("createAuthMiddleware", () => { expect(nextFn).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", () => { diff --git a/packages/dashboard/src/__tests__/request-actor.test.ts b/packages/dashboard/src/__tests__/request-actor.test.ts new file mode 100644 index 0000000000..fae6afc43f --- /dev/null +++ b/packages/dashboard/src/__tests__/request-actor.test.ts @@ -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"); + } + }); +}); diff --git a/packages/dashboard/src/auth-middleware.ts b/packages/dashboard/src/auth-middleware.ts index 3766edd4cb..6d2b08bee1 100644 --- a/packages/dashboard/src/auth-middleware.ts +++ b/packages/dashboard/src/auth-middleware.ts @@ -15,6 +15,12 @@ import type { IncomingMessage } from "node:http"; * `?fn_token=` on those URLs. */ 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. @@ -183,6 +189,8 @@ export function createAuthMiddleware(token: string) { 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(); }; } diff --git a/packages/dashboard/src/request-actor.ts b/packages/dashboard/src/request-actor.ts new file mode 100644 index 0000000000..9a357a9fea --- /dev/null +++ b/packages/dashboard/src/request-actor.ts @@ -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; +} diff --git a/packages/dashboard/src/routes/__tests__/register-org-portability-routes.test.ts b/packages/dashboard/src/routes/__tests__/register-org-portability-routes.test.ts index ea4546f11b..c437fa4a1d 100644 --- a/packages/dashboard/src/routes/__tests__/register-org-portability-routes.test.ts +++ b/packages/dashboard/src/routes/__tests__/register-org-portability-routes.test.ts @@ -2,6 +2,7 @@ import express from "express"; import { beforeEach, describe, expect, it, vi } from "vitest"; +import { createAuthMiddleware } from "../../auth-middleware.js"; import { request } from "../../test-request.js"; import { registerOrgPortabilityRoutes } from "../register-org-portability-routes.js"; @@ -12,11 +13,13 @@ const core = vi.hoisted(() => ({ assembleOrgBundle: vi.fn(), materializeOrgBundle: 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); -function createApp() { +function createApp(token?: string) { const store = { getAsyncLayer: vi.fn(() => ({ projectId: "project-1" })), getFusionDir: vi.fn(() => "/project/.fusion"), @@ -37,6 +40,7 @@ function createApp() { }); 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 }); @@ -95,7 +99,16 @@ describe("register-org-portability-routes", () => { expect(response.status).toBe(200); 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 () => { diff --git a/packages/dashboard/src/routes/__tests__/register-settings-memory-worktrunk.test.ts b/packages/dashboard/src/routes/__tests__/register-settings-memory-worktrunk.test.ts index b8becd9461..80f07fd60b 100644 --- a/packages/dashboard/src/routes/__tests__/register-settings-memory-worktrunk.test.ts +++ b/packages/dashboard/src/routes/__tests__/register-settings-memory-worktrunk.test.ts @@ -2,6 +2,7 @@ import express from "express"; import { beforeEach, describe, expect, it, vi } from "vitest"; +import { createAuthMiddleware } from "../../auth-middleware.js"; import { __resetCreateFnAgentForInsights, __setCreateFnAgentForInsights, @@ -58,7 +59,7 @@ vi.mock("@fusion/engine", async () => { }; }); -function createApp(pluginRunner?: Record) { +function createApp(pluginRunner?: Record, daemonToken?: string) { const router = express.Router(); const scopedStore = { getSettings: vi.fn(async () => ({ worktrunk: { enabled: false }, memoryDreamsEnabled: true })), @@ -103,6 +104,7 @@ function createApp(pluginRunner?: Record) { const app = express(); app.use(express.json()); + if (daemonToken) app.use(createAuthMiddleware(daemonToken)); app.use("/api", router); app.use((err: any, _req: express.Request, res: express.Response, _next: express.NextFunction) => { 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(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 () => { @@ -205,7 +210,41 @@ describe("register-settings-memory-routes worktrunk gate", () => { const res = await patchSettings(app, { worktreeNaming: "task-id" }); 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 () => { diff --git a/packages/dashboard/src/routes/__tests__/settings-sync-attribution.test.ts b/packages/dashboard/src/routes/__tests__/settings-sync-attribution.test.ts new file mode 100644 index 0000000000..372d74f538 --- /dev/null +++ b/packages/dashboard/src/routes/__tests__/settings-sync-attribution.test.ts @@ -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("@fusion/core")), + ...core, +})); +vi.mock("../register-settings-sync-helpers.js", async () => ({ + ...(await vi.importActual("../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); + }); +}); diff --git a/packages/dashboard/src/routes/__tests__/workflow-setting-attribution.test.ts b/packages/dashboard/src/routes/__tests__/workflow-setting-attribution.test.ts new file mode 100644 index 0000000000..b27642f9b4 --- /dev/null +++ b/packages/dashboard/src/routes/__tests__/workflow-setting-attribution.test.ts @@ -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[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" }); + }); +}); diff --git a/packages/dashboard/src/routes/register-org-portability-routes.ts b/packages/dashboard/src/routes/register-org-portability-routes.ts index 1849b3edc2..58f6aa42b5 100644 --- a/packages/dashboard/src/routes/register-org-portability-routes.ts +++ b/packages/dashboard/src/routes/register-org-portability-routes.ts @@ -1,4 +1,5 @@ import { ApiError, badRequest } from "../api-error.js"; +import { resolveRequestActor } from "../request-actor.js"; import type { ApiRoutesContext } from "./types.js"; /** @@ -119,7 +120,7 @@ export function registerOrgPortabilityRoutes(ctx: ApiRoutesContext): void { const revisionId = req.params.revisionId?.trim(); if (!revisionId) throw badRequest("revisionId is required"); const { store: scopedStore } = await getProjectContext(req); - const revision = await (scopedStore as unknown as { rollbackConfiguration(id: string, changedBy: { kind: "human"; id: string }): Promise }).rollbackConfiguration(revisionId, { kind: "human", id: "dashboard-operator" }); + const revision = await scopedStore.rollbackConfiguration(revisionId, resolveRequestActor(req)); res.json({ revision }); } catch (error: unknown) { if (error instanceof ApiError) throw error; diff --git a/packages/dashboard/src/routes/register-settings-memory-routes.ts b/packages/dashboard/src/routes/register-settings-memory-routes.ts index 5e460fdc79..d4de728270 100644 --- a/packages/dashboard/src/routes/register-settings-memory-routes.ts +++ b/packages/dashboard/src/routes/register-settings-memory-routes.ts @@ -1,4 +1,5 @@ import { createLogger } from "@fusion/core"; +import { resolveRequestActor } from "../request-actor.js"; const severityAuditLog = createLogger("dashboard-register-settings-memory-routes"); 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); } catch (err: unknown) { diff --git a/packages/dashboard/src/routes/register-settings-sync-inbound-routes.ts b/packages/dashboard/src/routes/register-settings-sync-inbound-routes.ts index 32a34f309f..e2c545eea4 100644 --- a/packages/dashboard/src/routes/register-settings-sync-inbound-routes.ts +++ b/packages/dashboard/src/routes/register-settings-sync-inbound-routes.ts @@ -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 { ApiError, badRequest } from "../api-error.js"; import { invalidateAllGlobalSettingsCaches } from "../project-store-resolver.js"; import { readStoredAuthProvidersFromDisk, toProviderAuthEntries } from "./register-settings-sync-helpers.js"; +import type { ConfigChangedBy } from "@fusion/core"; 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>; type WorkflowSettingsSyncStore = { getWorkflowSettingsProjectId(): string; - updateWorkflowSettingValues(workflowId: string, projectId: string, patch: Record): Promise>; + updateWorkflowSettingValues(workflowId: string, projectId: string, patch: Record, changedBy?: ConfigChangedBy): Promise>; }; function extractRejectedSettingIds(err: unknown): string[] { @@ -27,6 +34,7 @@ function extractRejectedSettingIds(err: unknown): string[] { async function applyWorkflowSettingsSection( store: WorkflowSettingsSyncStore, section: WorkflowSettingsSyncSection, + changedBy: ConfigChangedBy, ): Promise<{ count: number; keys: string[] }> { const projectId = store.getWorkflowSettingsProjectId(); let count = 0; @@ -38,7 +46,7 @@ async function applyWorkflowSettingsSection( while (Object.keys(patch).length > 0) { try { - await store.updateWorkflowSettingValues(workflowId, projectId, patch); + await store.updateWorkflowSettingValues(workflowId, projectId, patch, changedBy); const appliedKeys = Object.entries(patch) .filter(([, value]) => value !== null) .map(([key]) => key); @@ -144,7 +152,7 @@ export const registerSettingsSyncInboundRoutes: ApiRouteRegistrar = (ctx) => { .filter(([key, value]) => value !== undefined && localGlobal[key] === undefined && !isMovedSettingsKey(key)), ); if (Object.keys(globalPatch).length > 0) { - await store.updateGlobalSettings(globalPatch); + await store.updateGlobalSettings(globalPatch, CONFIG_CHANGED_BY_API_VERIFIED_NODE_KEY); invalidateAllGlobalSettingsCaches(); } } @@ -152,7 +160,7 @@ export const registerSettingsSyncInboundRoutes: ApiRouteRegistrar = (ctx) => { let workflowSettingsCount = 0; let appliedWorkflowSettingKeys: string[] = []; 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; appliedWorkflowSettingKeys = workflowApplyResult.keys; } diff --git a/packages/dashboard/src/routes/register-settings-sync-routes.ts b/packages/dashboard/src/routes/register-settings-sync-routes.ts index 1e51423e08..28b7c9dab6 100644 --- a/packages/dashboard/src/routes/register-settings-sync-routes.ts +++ b/packages/dashboard/src/routes/register-settings-sync-routes.ts @@ -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 { createFusionAuthStorage } from "@fusion/engine"; import { basename } from "node:path"; @@ -14,11 +15,17 @@ import { } from "./register-settings-sync-helpers.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>; type WorkflowSettingsSyncStore = { getWorkflowSettingsProjectId(): string; - updateWorkflowSettingValues(workflowId: string, projectId: string, patch: Record): Promise>; + updateWorkflowSettingValues(workflowId: string, projectId: string, patch: Record, changedBy?: ConfigChangedBy): Promise>; }; export type SettingsDiff = { @@ -43,6 +50,7 @@ function extractRejectedSettingIds(err: unknown): string[] { async function applyWorkflowSettingsSection( store: WorkflowSettingsSyncStore, section: WorkflowSettingsSyncSection | undefined, + changedBy: ConfigChangedBy, ): Promise<{ count: number; keys: string[] }> { if (!section) return { count: 0, keys: [] }; const projectId = store.getWorkflowSettingsProjectId(); @@ -54,7 +62,7 @@ async function applyWorkflowSettingsSection( const patch: Record = { ...rawValues }; while (Object.keys(patch).length > 0) { try { - await store.updateWorkflowSettingValues(workflowId, projectId, patch); + await store.updateWorkflowSettingValues(workflowId, projectId, patch, changedBy); const appliedKeys = Object.entries(patch) .filter(([, value]) => value !== null) .map(([key]) => key); @@ -354,7 +362,7 @@ export const registerSettingsSyncRoutes: ApiRouteRegistrar = (ctx) => { checksum, }); const workflowApplyResult = result.success - ? await applyWorkflowSettingsSection(store, remoteSettings.workflowSettings) + ? await applyWorkflowSettingsSection(store, remoteSettings.workflowSettings, resolveRequestActor(req)) : { count: 0, keys: [] }; // 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 // strips moved (tombstoned) keys (KTD-8). if (result.success && remoteSettings.global && typeof remoteSettings.global === "object") { - await store.updateGlobalSettings(remoteSettings.global); + await store.updateGlobalSettings(remoteSettings.global, resolveRequestActor(req)); invalidateAllGlobalSettingsCaches(); } diff --git a/packages/dashboard/src/routes/register-workflow-routes.ts b/packages/dashboard/src/routes/register-workflow-routes.ts index d8ca4065ab..d0eac90d2b 100644 --- a/packages/dashboard/src/routes/register-workflow-routes.ts +++ b/packages/dashboard/src/routes/register-workflow-routes.ts @@ -1,4 +1,5 @@ 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 { buildSessionSkillContextSync, createFnAgent as engineCreateFnAgent, validateCodeNodeSources, validateWorkflowIrDryRun } from "@fusion/engine"; import { ApiError, badRequest, conflict, notFound, rateLimited } from "../api-error.js"; @@ -520,6 +521,7 @@ export function registerWorkflowRoutes(ctx: ApiRoutesContext): void { workflowId, projectId, values as Record, + resolveRequestActor(req), ); const declarations = await resolveSettingDeclarations(store, workflowId); res.json({ @@ -989,6 +991,7 @@ export function registerWorkflowRoutes(ctx: ApiRoutesContext): void { workflow.id, workflowProjectId, importedSettingValues, + resolveRequestActor(req), ); } if (Object.keys(importedPromptOverrides).length > 0) {