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