feat(FN-4847): add actionable denial reason to sync-status

Fusion-Task-Id: FN-4847
Fusion-Task-Lineage: af551b65-a484-496a-8bab-b46193e06104
This commit is contained in:
Fusion (runfusion.ai)
2026-05-17 08:23:37 -07:00
committed by gsxdsm
parent af8999f6c6
commit ab31aaceb4
2 changed files with 44 additions and 2 deletions

View File

@@ -5,6 +5,43 @@ import { getAuthFileCandidates, type StoredAuthProvider } from "../auth-paths.js
export const MISSING_REMOTE_NODE_API_KEY_MESSAGE = "Remote node requires an apiKey for authenticated sync";
// FN-4847: Stable denial-reason enum surfaced by sync-status for actionable remote probe failures.
export const SYNC_STATUS_DENIAL_REASONS = ["missing-remote-api-key", "auth-failed", "unreachable", "unknown"] as const;
// FN-4847: Public contract type for actionable sync-status denial diagnostics.
export type SyncStatusDenialReason = (typeof SYNC_STATUS_DENIAL_REASONS)[number];
// FN-4847: Classify remote probe failures to a non-leaking enum suitable for API responses.
export function classifySyncStatusDenialReason(err: unknown): SyncStatusDenialReason {
if (err instanceof ApiError && err.message === MISSING_REMOTE_NODE_API_KEY_MESSAGE) {
return "missing-remote-api-key";
}
if (err instanceof ApiError && err.message === "Remote node authentication failed") {
return "auth-failed";
}
if (err instanceof ApiError && err.message === "Remote node unreachable") {
return "unreachable";
}
if (err instanceof Error) {
const message = err.message ?? "";
const causeCode = (err as { cause?: { code?: unknown } }).cause?.code;
if (
err.name === "AbortError"
|| /fetch failed|ECONNREFUSED|ENOTFOUND|ETIMEDOUT|network/i.test(message)
|| causeCode === "ECONNREFUSED"
|| causeCode === "ENOTFOUND"
|| causeCode === "ETIMEDOUT"
) {
return "unreachable";
}
}
return "unknown";
}
export async function readStoredAuthProvidersFromDisk(): Promise<Record<string, StoredAuthProvider>> {
const merged: Record<string, StoredAuthProvider> = {};
for (const authJsonPath of getAuthFileCandidates()) {

View File

@@ -3,10 +3,12 @@ import { basename } from "node:path";
import { ApiError, badRequest, notFound } from "../api-error.js";
import { getFusionAuthPath } from "../auth-paths.js";
import {
classifySyncStatusDenialReason,
fetchFromRemoteNode,
MISSING_REMOTE_NODE_API_KEY_MESSAGE,
readStoredAuthProvidersFromDisk,
toProviderAuthEntries,
type SyncStatusDenialReason,
} from "./register-settings-sync-helpers.js";
import type { ApiRouteRegistrar } from "./types.js";
@@ -294,6 +296,7 @@ export const registerSettingsSyncRoutes: ApiRouteRegistrar = (ctx) => {
let remoteSettings: { global: Record<string, unknown>; project: Record<string, unknown> } | null = null;
let diffGlobal: string[] = [];
let diffProject: string[] = [];
let denialReason: SyncStatusDenialReason | null = null; // FN-4847: stable, non-leaking denial classification for degraded probes.
try {
remoteSettings = await fetchFromRemoteNode(node, "/api/settings/scopes") as {
@@ -311,8 +314,9 @@ export const registerSettingsSyncRoutes: ApiRouteRegistrar = (ctx) => {
);
diffGlobal = diff.global;
diffProject = diff.project;
} catch {
// Remote unreachable - diff will be empty arrays
} catch (err) {
// FN-4847: Remote probe failures are classified into actionable, enum-only denial reasons.
denialReason = classifySyncStatusDenialReason(err);
}
await central.close();
@@ -322,6 +326,7 @@ export const registerSettingsSyncRoutes: ApiRouteRegistrar = (ctx) => {
lastSyncDirection: syncState ? "sync" : null, // Direction not tracked in new schema
localUpdatedAt: syncState?.updatedAt ?? new Date().toISOString(),
remoteReachable,
actionableDenialReason: denialReason, // FN-4847: explicit null on success, enum value on degraded failures.
diff: { global: diffGlobal, project: diffProject },
});
} catch (err: unknown) {