From e44da5c410fc0d5a8a645be4045082bdddc670fc Mon Sep 17 00:00:00 2001 From: James Brunton Date: Tue, 30 Jun 2026 15:07:12 +0100 Subject: [PATCH] Fix missing refresh token on desktop (#6838) # Description of Changes Fix #6801, along with fixing policies on desktop, which would attempt to download policy outputs from the local backend instead of the server, where they actually live. I've changed the policies logic to maintain the same backend for the file retrieval as it used for the policy running, so when we support running policies locally, it should still work correctly. --- .../public/locales/en-US/translation.toml | 11 +++ .../editor/src-tauri/src/commands/auth.rs | 4 + .../src/desktop/services/authService.ts | 14 +++- .../desktop/services/policyOutputBaseUrl.ts | 12 +++ .../policies/policyRunStore.test.ts | 1 + .../components/policies/policyRunStore.ts | 8 +- .../policies/usePolicyAutoRun.import.test.tsx | 2 + .../policies/usePolicyAutoRun.retry.test.tsx | 2 + .../components/policies/usePolicyAutoRun.ts | 75 ++++++++++++++++--- .../hooks/usePolicyFileBadges.test.ts | 1 + .../src/proprietary/services/policyApi.ts | 23 +++++- .../src/proprietary/services/policyExport.ts | 9 ++- .../services/policyLiveData.test.ts | 9 ++- .../proprietary/services/policyLiveData.ts | 18 +++-- .../services/policyOutputBaseUrl.ts | 13 ++++ .../proprietary/services/policyPipeline.ts | 6 ++ 16 files changed, 177 insertions(+), 31 deletions(-) create mode 100644 frontend/editor/src/desktop/services/policyOutputBaseUrl.ts create mode 100644 frontend/editor/src/proprietary/services/policyOutputBaseUrl.ts diff --git a/frontend/editor/public/locales/en-US/translation.toml b/frontend/editor/public/locales/en-US/translation.toml index 41a2f220e5..670c315589 100644 --- a/frontend/editor/public/locales/en-US/translation.toml +++ b/frontend/editor/public/locales/en-US/translation.toml @@ -5886,6 +5886,17 @@ successMessage = "Your license has been successfully activated. You can now clos deleteConfirmBody = "This removes the policy and its workflow. Documents already processed are not affected." deleteConfirmTitle = "Delete {{label}} policy?" +[policies.activity] +enforced = "enforced" +enforcing = "Enforcing..." +failed = "Enforcement failed" +outputsUnavailable = "Policy outputs are no longer available to download." +partialOutputsUnavailable = "Some policy outputs are no longer available to download." +retrying = "Busy, retrying..." +runNotFound = "The enforcement run could no longer be found." +step = "step {{current}}/{{total}}" +timedOut = "Enforcement timed out before the run could finish." + [policies.catalog] compliance = "Compliance" ingestion = "Ingestion" diff --git a/frontend/editor/src-tauri/src/commands/auth.rs b/frontend/editor/src-tauri/src/commands/auth.rs index 28d2ddd376..b906bbbf7d 100644 --- a/frontend/editor/src-tauri/src/commands/auth.rs +++ b/frontend/editor/src-tauri/src/commands/auth.rs @@ -400,6 +400,7 @@ struct SupabaseUser { #[derive(Debug, Deserialize)] struct SupabaseLoginResponse { access_token: String, + refresh_token: Option, user: SupabaseUser, } @@ -408,6 +409,7 @@ pub struct LoginResponse { pub token: String, pub username: String, pub email: Option, + pub refresh_token: Option, } /// Login command - makes HTTP request from Rust to bypass CORS @@ -513,6 +515,7 @@ pub async fn login( token: login_response.access_token, username, email, + refresh_token: login_response.refresh_token, }) } else { // Spring Boot authentication flow @@ -615,6 +618,7 @@ pub async fn login( token: login_response.session.access_token, username: login_response.user.username, email: login_response.user.email, + refresh_token: None, }) } } diff --git a/frontend/editor/src/desktop/services/authService.ts b/frontend/editor/src/desktop/services/authService.ts index 6f5d3214f3..63181ef1dd 100644 --- a/frontend/editor/src/desktop/services/authService.ts +++ b/frontend/editor/src/desktop/services/authService.ts @@ -30,6 +30,7 @@ interface LoginResponse { token: string; username: string; email: string | null; + refresh_token: string | null; } interface OAuthCallbackResult { @@ -347,11 +348,18 @@ export class AuthService { saasServerUrl: STIRLING_SAAS_URL, }); - const { token, username: returnedUsername, email } = response; + const { + token, + username: returnedUsername, + email, + refresh_token: refreshToken, + } = response; - // Save token to all storage locations + // Save token to all storage locations. Supabase (SaaS) logins include a + // refresh token so the short-lived access token can be renewed; self-hosted + // logins return null here and refresh via the current access token instead. try { - await this.saveTokenEverywhere(token); + await this.saveTokenEverywhere(token, refreshToken); } catch (error) { console.error("[Desktop AuthService] Failed to save token:", error); throw new Error("Failed to save authentication token", { diff --git a/frontend/editor/src/desktop/services/policyOutputBaseUrl.ts b/frontend/editor/src/desktop/services/policyOutputBaseUrl.ts new file mode 100644 index 0000000000..82b452aa0c --- /dev/null +++ b/frontend/editor/src/desktop/services/policyOutputBaseUrl.ts @@ -0,0 +1,12 @@ +import { STIRLING_SAAS_BACKEND_API_URL } from "@app/constants/connection"; +import type { PolicyExecutionTarget } from "@app/services/policyPipeline"; + +/** + * Desktop: a policy run's outputs live on the backend that executed it. + */ +export function getPolicyOutputBaseUrl(target: PolicyExecutionTarget): string { + if (target === "saas") { + return (STIRLING_SAAS_BACKEND_API_URL ?? "").replace(/\/$/, ""); + } + return ""; +} diff --git a/frontend/editor/src/proprietary/components/policies/policyRunStore.test.ts b/frontend/editor/src/proprietary/components/policies/policyRunStore.test.ts index 922e64650f..2034866b87 100644 --- a/frontend/editor/src/proprietary/components/policies/policyRunStore.test.ts +++ b/frontend/editor/src/proprietary/components/policies/policyRunStore.test.ts @@ -18,6 +18,7 @@ function rec(over: Partial): PolicyRunRecord { fileId: "f1", fileName: "f.pdf", fileSize: 10, + target: "saas", status: "PENDING", outputs: [], error: null, diff --git a/frontend/editor/src/proprietary/components/policies/policyRunStore.ts b/frontend/editor/src/proprietary/components/policies/policyRunStore.ts index 295c45d737..45a8066bf0 100644 --- a/frontend/editor/src/proprietary/components/policies/policyRunStore.ts +++ b/frontend/editor/src/proprietary/components/policies/policyRunStore.ts @@ -10,7 +10,10 @@ */ import { useSyncExternalStore } from "react"; -import type { PolicyRunStatus } from "@app/services/policyPipeline"; +import type { + PolicyExecutionTarget, + PolicyRunStatus, +} from "@app/services/policyPipeline"; export interface PolicyRunRecord { runId: string; @@ -18,6 +21,7 @@ export interface PolicyRunRecord { fileId: string; fileName: string; fileSize: number; + target: PolicyExecutionTarget; status: PolicyRunStatus; /** Pipeline progress reported by the run-status endpoint: the 1-based step * currently running, and the total step count. Drive the "step X/Y" label @@ -72,6 +76,8 @@ function read(): RunState { importedFileIds: Array.isArray(r.importedFileIds) ? r.importedFileIds : [], + // Records predating per-run targets all executed on SaaS. + target: r.target === "local" ? "local" : "saas", })) : [], dispatched: Array.isArray(parsed.dispatched) ? parsed.dispatched : [], diff --git a/frontend/editor/src/proprietary/components/policies/usePolicyAutoRun.import.test.tsx b/frontend/editor/src/proprietary/components/policies/usePolicyAutoRun.import.test.tsx index 36763a3e37..6090557c70 100644 --- a/frontend/editor/src/proprietary/components/policies/usePolicyAutoRun.import.test.tsx +++ b/frontend/editor/src/proprietary/components/policies/usePolicyAutoRun.import.test.tsx @@ -48,6 +48,7 @@ vi.mock("@app/services/policyApi", () => ({ getPolicyRun: vi.fn(), listPolicyRuns: mocks.listPolicyRuns, downloadPolicyOutput: mocks.downloadPolicyOutput, + resolvePolicyRunTarget: () => "saas", })); vi.mock("@app/services/fileStorage", () => ({ fileStorage: { @@ -75,6 +76,7 @@ function recordCompletedRun() { fileId: "file-1", fileName: "doc.pdf", fileSize: 1234, + target: "saas", status: "COMPLETED", outputs: [{ fileId: "out-file-1", fileName: "doc.pdf" }], error: null, diff --git a/frontend/editor/src/proprietary/components/policies/usePolicyAutoRun.retry.test.tsx b/frontend/editor/src/proprietary/components/policies/usePolicyAutoRun.retry.test.tsx index a0891a28f5..7acc1ae2bb 100644 --- a/frontend/editor/src/proprietary/components/policies/usePolicyAutoRun.retry.test.tsx +++ b/frontend/editor/src/proprietary/components/policies/usePolicyAutoRun.retry.test.tsx @@ -25,6 +25,7 @@ vi.mock("@app/services/policyApi", () => ({ runStoredPolicy: vi.fn(), getPolicyRun: vi.fn(), downloadPolicyOutput: vi.fn(), + resolvePolicyRunTarget: () => "saas", })); vi.mock("@app/services/fileStorage", () => ({ fileStorage: { getStirlingFile: vi.fn(), getStirlingFileStub: vi.fn() }, @@ -80,6 +81,7 @@ describe("auto-run queue-rejection retry", () => { fileId: "file-1", fileName: "doc.pdf", fileSize: 1234, + target: "saas", status: "RUNNING", outputs: [], error: null, diff --git a/frontend/editor/src/proprietary/components/policies/usePolicyAutoRun.ts b/frontend/editor/src/proprietary/components/policies/usePolicyAutoRun.ts index 8440d53b5b..19f88c9304 100644 --- a/frontend/editor/src/proprietary/components/policies/usePolicyAutoRun.ts +++ b/frontend/editor/src/proprietary/components/policies/usePolicyAutoRun.ts @@ -20,11 +20,13 @@ import { import { fileStorage } from "@app/services/fileStorage"; import { useIndexedDB } from "@app/contexts/IndexedDBContext"; import { POLICIES_ENABLED } from "@app/constants/featureFlags"; +import i18n from "@app/i18n"; import { runStoredPolicy, getPolicyRun, listPolicyRuns, downloadPolicyOutput, + resolvePolicyRunTarget, } from "@app/services/policyApi"; import type { PolicyRunStatus, @@ -82,9 +84,9 @@ const QUEUE_RETRY_BASE_MS = 4000; * to an instance that hasn't seen it) then fail, rather than polling forever. */ const MAX_NOT_FOUND = 3; -/** A 404 from the run-status endpoint, across the web (axios) and desktop - * (tauri http client → {@code code: "ERR_NOT_FOUND"}) builds. */ -function isRunNotFound(err: unknown): boolean { +/** A 404 (run status gone, or output file gone), across the web (axios) and + * desktop (tauri http client → {@code code: "ERR_NOT_FOUND"}) builds. */ +function isNotFoundError(err: unknown): boolean { const e = err as | { code?: string; status?: number; response?: { status?: number } } | null @@ -354,6 +356,9 @@ async function reconcileServerRuns( fileId: "", fileName: view.outputs[0]?.fileName ?? "", fileSize: 0, + // Rediscovered from the SaaS run registry (listPolicyRuns), so its outputs + // live on the cloud backend. + target: "saas", status: view.status, outputs: view.outputs, error: view.error, @@ -403,9 +408,9 @@ async function importOutputs( const targetName = ctx.outputName ? undefined // use the run's per-output (renamed) name below : run.fileName; - const results = await Promise.allSettled( + const settled = await Promise.allSettled( pending.map(async (out) => { - const blob = await downloadPolicyOutput(out.fileId); + const blob = await downloadPolicyOutput(out.fileId, run.target); return { fileId: out.fileId, file: new File([blob], targetName ?? out.fileName ?? run.fileName, { @@ -414,13 +419,33 @@ async function importOutputs( }; }), ); - const fetched = results + const fetched = settled .filter( (r): r is PromiseFulfilledResult<{ fileId: string; file: File }> => r.status === "fulfilled", ) .map((r) => r.value); - if (fetched.length === 0) return; // all failed — retry the lot on a later tick + // A 404 means the backend no longer has that output (past its retention + // window); retrying it can never succeed, so don't loop on it forever. Any + // other rejection is transient and worth retrying on a later tick. + const rejections = settled + .filter((r): r is PromiseRejectedResult => r.status === "rejected") + .map((r) => r.reason); + const allFailuresPermanent = + rejections.length > 0 && rejections.every(isNotFoundError); + + if (fetched.length === 0) { + if (allFailuresPermanent) { + failRun( + run.runId, + i18n.t( + "policies.activity.outputsUnavailable", + "Policy outputs are no longer available to download.", + ), + ); + } + return; // transient/mixed: retry the lot later; permanent: already failed. + } // Deliver, then mark exactly those imported. If delivery throws we don't mark // them, so they retry (without having been added). @@ -472,12 +497,26 @@ async function importOutputs( deliveredIds = added.map((f) => f.fileId as string); } const importedFileIds = [...done, ...fetched.map((f) => f.fileId)]; + const imported = run.outputs.every((out) => + importedFileIds.includes(out.fileId), + ); updateRun(run.runId, { importedFileIds, // Accumulate across partial-import retries rather than overwriting. outputFileIds: [...(run.outputFileIds ?? []), ...deliveredIds], - imported: run.outputs.every((out) => importedFileIds.includes(out.fileId)), + imported, }); + // Some outputs landed but the rest are permanently gone (404): finalize so the + // run stops re-fetching the missing ones on every tick. + if (!imported && allFailuresPermanent) { + failRun( + run.runId, + i18n.t( + "policies.activity.partialOutputsUnavailable", + "Some policy outputs are no longer available to download.", + ), + ); + } } /** @@ -515,6 +554,7 @@ export async function runPolicyOnFile( return; } try { + const target = resolvePolicyRunTarget(); const runId = await runStoredPolicy(backendId, [file]); // recordRunStart marks this (policy, file) dispatched as it records the run. recordRunStart({ @@ -523,6 +563,7 @@ export async function runPolicyOnFile( fileId, fileName, fileSize: file.size, + target, status: "PENDING", outputs: [], error: null, @@ -562,9 +603,15 @@ export async function poll( // The server lost the run's (in-memory) state — a restart, or a poll that // hopped to an instance without it. Tolerate a brief blip, then fail so // the file stops enforcing forever; the user can retry. - if (isRunNotFound(err)) { + if (isNotFoundError(err)) { if (++notFoundStreak >= MAX_NOT_FOUND) { - failRun(runId, "The enforcement run could no longer be found."); + failRun( + runId, + i18n.t( + "policies.activity.runNotFound", + "The enforcement run could no longer be found.", + ), + ); return; } } else { @@ -591,5 +638,11 @@ export async function poll( } // Budget exhausted without a terminal status — stop here and fail it, so the // file doesn't enforce forever and reloads don't re-poll it. - failRun(runId, "Enforcement timed out — the run didn't finish in time."); + failRun( + runId, + i18n.t( + "policies.activity.timedOut", + "Enforcement timed out before the run could finish.", + ), + ); } diff --git a/frontend/editor/src/proprietary/hooks/usePolicyFileBadges.test.ts b/frontend/editor/src/proprietary/hooks/usePolicyFileBadges.test.ts index 30c5a83952..db69bb77af 100644 --- a/frontend/editor/src/proprietary/hooks/usePolicyFileBadges.test.ts +++ b/frontend/editor/src/proprietary/hooks/usePolicyFileBadges.test.ts @@ -15,6 +15,7 @@ function run(overrides: Partial): PolicyRunRecord { fileId: "in", fileName: "in.pdf", fileSize: 1, + target: "saas", status: "COMPLETED", outputs: [], outputFileIds: ["out"], diff --git a/frontend/editor/src/proprietary/services/policyApi.ts b/frontend/editor/src/proprietary/services/policyApi.ts index 1936cf0894..b3f1fd999f 100644 --- a/frontend/editor/src/proprietary/services/policyApi.ts +++ b/frontend/editor/src/proprietary/services/policyApi.ts @@ -6,9 +6,11 @@ */ import apiClient from "@app/services/apiClient"; +import { getPolicyOutputBaseUrl } from "@app/services/policyOutputBaseUrl"; import type { BackendPipelineDefinition, BackendPolicy, + PolicyExecutionTarget, PolicyRunView, } from "@app/services/policyPipeline"; @@ -88,10 +90,25 @@ export async function runPolicyPipeline( return res.data.jobId; } -/** Download a run's output file by id (via the shared general-files endpoint). */ -export async function downloadPolicyOutput(fileId: string): Promise { +/** + * Where a policy run executes, and thus the backend that holds its outputs. + */ +export function resolvePolicyRunTarget(): PolicyExecutionTarget { + return "saas"; +} + +/** + * Download a run's output file by id (via the shared general-files endpoint). + * `target` is where the run executed: it selects the backend the file is fetched + * from, so a SaaS run's output isn't looked for on the bundled local backend. + */ +export async function downloadPolicyOutput( + fileId: string, + target: PolicyExecutionTarget, +): Promise { + const base = getPolicyOutputBaseUrl(target); const res = await apiClient.get( - `/api/v1/general/files/${encodeURIComponent(fileId)}`, + `${base}/api/v1/general/files/${encodeURIComponent(fileId)}`, { responseType: "blob" }, ); return res.data; diff --git a/frontend/editor/src/proprietary/services/policyExport.ts b/frontend/editor/src/proprietary/services/policyExport.ts index f4dce74873..92b14712dd 100644 --- a/frontend/editor/src/proprietary/services/policyExport.ts +++ b/frontend/editor/src/proprietary/services/policyExport.ts @@ -16,7 +16,9 @@ import { runStoredPolicy, getPolicyRun, downloadPolicyOutput, + resolvePolicyRunTarget, } from "@app/services/policyApi"; +import type { PolicyExecutionTarget } from "@app/services/policyPipeline"; import { recordRunStart, isDispatched, @@ -52,6 +54,7 @@ interface ExportPolicy { interface PolicyRunResult { file: File; runId: string; + target: PolicyExecutionTarget; outputs: { fileId: string; fileName: string }[]; } @@ -84,6 +87,7 @@ async function runToCompletion( backendId: string, file: File, ): Promise { + const target = resolvePolicyRunTarget(); const runId = await runStoredPolicy(backendId, [file]); for (let i = 0; i < MAX_POLLS; i++) { await delay(POLL_MS); @@ -96,12 +100,12 @@ async function runToCompletion( if (view.status === "COMPLETED") { const out = view.outputs?.[0]; if (!out) throw new Error("policy produced no output"); - const blob = await downloadPolicyOutput(out.fileId); + const blob = await downloadPolicyOutput(out.fileId, target); // Keep the export's filename; only the bytes are the enforced result. const enforced = new File([blob], file.name, { type: blob.type || file.type || "application/pdf", }); - return { file: enforced, runId, outputs: view.outputs ?? [] }; + return { file: enforced, runId, target, outputs: view.outputs ?? [] }; } if (view.status === "FAILED" || view.status === "CANCELLED") { throw new Error(view.error || `policy run ${view.status.toLowerCase()}`); @@ -219,6 +223,7 @@ export async function enforceExportPolicies( fileId, fileName: file.name, fileSize: file.size, + target: versionRun!.target, status: "COMPLETED", outputs: versionRun.outputs, error: null, diff --git a/frontend/editor/src/proprietary/services/policyLiveData.test.ts b/frontend/editor/src/proprietary/services/policyLiveData.test.ts index 38b96c845e..c3016f071b 100644 --- a/frontend/editor/src/proprietary/services/policyLiveData.test.ts +++ b/frontend/editor/src/proprietary/services/policyLiveData.test.ts @@ -14,6 +14,7 @@ function run(over: Partial): PolicyRunRecord { fileId: "f1", fileName: "f.pdf", fileSize: 0, + target: "saas", status: "COMPLETED", outputs: [], error: null, @@ -47,7 +48,7 @@ describe("runsToActivity", () => { expect(activity[0]).toMatchObject({ doc: "fresh.pdf", status: "processing", - action: "Enforcing…", + action: "Enforcing...", }); expect(activity[1]).toMatchObject({ doc: "contract.pdf", @@ -66,9 +67,9 @@ describe("runsToActivity", () => { run({ runId: "a", status: "RUNNING", currentStep: 1, stepCount: 2 }), run({ runId: "b", status: "RUNNING" }), ]); - expect(withStep.action).toBe("Enforcing… · step 1/2"); + expect(withStep.action).toBe("Enforcing... · step 1/2"); // Before the first status report (no step yet) it stays the plain label. - expect(noStep.action).toBe("Enforcing…"); + expect(noStep.action).toBe("Enforcing..."); }); it("shows a queue-rejected run awaiting retry as busy, not a failure", () => { @@ -81,7 +82,7 @@ describe("runsToActivity", () => { }), ]); expect(item.status).toBe("processing"); - expect(item.action).toBe("Busy — retrying…"); + expect(item.action).toBe("Busy, retrying..."); }); it("shows a queue rejection that has exhausted its retries as a failure", () => { diff --git a/frontend/editor/src/proprietary/services/policyLiveData.ts b/frontend/editor/src/proprietary/services/policyLiveData.ts index 67d53f9c5e..56401f4d19 100644 --- a/frontend/editor/src/proprietary/services/policyLiveData.ts +++ b/frontend/editor/src/proprietary/services/policyLiveData.ts @@ -5,6 +5,7 @@ * policy's actual enforcement history — not a cosmetic file listing. */ +import i18n from "@app/i18n"; import type { PolicyActivityItem, PolicyStats } from "@app/types/policies"; import type { PolicyRunRecord } from "@app/components/policies/policyRunStore"; @@ -58,17 +59,20 @@ function activityStatus(run: PolicyRunRecord): PolicyActivityItem["status"] { function activityAction(run: PolicyRunRecord): string { switch (activityStatus(run)) { case "enforced": - return `${formatBytes(run.fileSize)} • enforced`; + return `${formatBytes(run.fileSize)} • ${i18n.t("policies.activity.enforced", "enforced")}`; case "flagged": - return run.error ?? "Enforcement failed"; + return ( + run.error ?? i18n.t("policies.activity.failed", "Enforcement failed") + ); default: { - if (run.retrying) return "Busy — retrying…"; - // Show pipeline progress while running, once the status endpoint reports - // it — turns a static "Enforcing…" into visible movement on slow steps. + if (run.retrying) + return i18n.t("policies.activity.retrying", "Busy, retrying..."); + const enforcing = i18n.t("policies.activity.enforcing", "Enforcing..."); + // Show pipeline progress while running, once the status endpoint reports it const { currentStep, stepCount } = run; return currentStep && stepCount - ? `Enforcing… · step ${currentStep}/${stepCount}` - : "Enforcing…"; + ? `${enforcing} · ${i18n.t("policies.activity.step", "step {{current}}/{{total}}", { current: currentStep, total: stepCount })}` + : enforcing; } } } diff --git a/frontend/editor/src/proprietary/services/policyOutputBaseUrl.ts b/frontend/editor/src/proprietary/services/policyOutputBaseUrl.ts new file mode 100644 index 0000000000..c5a127c0d5 --- /dev/null +++ b/frontend/editor/src/proprietary/services/policyOutputBaseUrl.ts @@ -0,0 +1,13 @@ +import type { PolicyExecutionTarget } from "@app/services/policyPipeline"; + +/** + * Base URL for downloading a policy run's output file, given where the run + * executed. + * + * Web builds are served from their own backend, so a relative request resolves + * to the right place regardless of where the run ran, hence "" for every + * target. + */ +export function getPolicyOutputBaseUrl(_target: PolicyExecutionTarget): string { + return ""; +} diff --git a/frontend/editor/src/proprietary/services/policyPipeline.ts b/frontend/editor/src/proprietary/services/policyPipeline.ts index 21e304e4fb..b27bd09e4d 100644 --- a/frontend/editor/src/proprietary/services/policyPipeline.ts +++ b/frontend/editor/src/proprietary/services/policyPipeline.ts @@ -56,6 +56,12 @@ export interface BackendPolicy { output: BackendOutputSpec; } +/** + * Where a policy run executes, and therefore where its output files live and + * are downloaded from. + */ +export type PolicyExecutionTarget = "local" | "saas"; + /** Lifecycle states of a backend run (mirrors PolicyRunStatus). */ export type PolicyRunStatus = | "PENDING"