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:
7
.changeset/fn-8851-settings-attribution.md
Normal file
7
.changeset/fn-8851-settings-attribution.md
Normal 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.
|
||||||
@@ -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({
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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 () => {
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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";
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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", () => {
|
||||||
|
|||||||
46
packages/dashboard/src/__tests__/request-actor.test.ts
Normal file
46
packages/dashboard/src/__tests__/request-actor.test.ts
Normal 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");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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();
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
13
packages/dashboard/src/request-actor.ts
Normal file
13
packages/dashboard/src/request-actor.ts
Normal 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;
|
||||||
|
}
|
||||||
@@ -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 () => {
|
||||||
|
|||||||
@@ -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 () => {
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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" });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
Reference in New Issue
Block a user