Skip to content

Commit e44da5c

Browse files
authored
Fix missing refresh token on desktop (Stirling-Tools#6838)
# Description of Changes Fix Stirling-Tools#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.
1 parent 0beff1a commit e44da5c

16 files changed

Lines changed: 177 additions & 31 deletions

frontend/editor/public/locales/en-US/translation.toml

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5886,6 +5886,17 @@ successMessage = "Your license has been successfully activated. You can now clos
58865886
deleteConfirmBody = "This removes the policy and its workflow. Documents already processed are not affected."
58875887
deleteConfirmTitle = "Delete {{label}} policy?"
58885888

5889+
[policies.activity]
5890+
enforced = "enforced"
5891+
enforcing = "Enforcing..."
5892+
failed = "Enforcement failed"
5893+
outputsUnavailable = "Policy outputs are no longer available to download."
5894+
partialOutputsUnavailable = "Some policy outputs are no longer available to download."
5895+
retrying = "Busy, retrying..."
5896+
runNotFound = "The enforcement run could no longer be found."
5897+
step = "step {{current}}/{{total}}"
5898+
timedOut = "Enforcement timed out before the run could finish."
5899+
58895900
[policies.catalog]
58905901
compliance = "Compliance"
58915902
ingestion = "Ingestion"

frontend/editor/src-tauri/src/commands/auth.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -400,6 +400,7 @@ struct SupabaseUser {
400400
#[derive(Debug, Deserialize)]
401401
struct SupabaseLoginResponse {
402402
access_token: String,
403+
refresh_token: Option<String>,
403404
user: SupabaseUser,
404405
}
405406

@@ -408,6 +409,7 @@ pub struct LoginResponse {
408409
pub token: String,
409410
pub username: String,
410411
pub email: Option<String>,
412+
pub refresh_token: Option<String>,
411413
}
412414

413415
/// Login command - makes HTTP request from Rust to bypass CORS
@@ -513,6 +515,7 @@ pub async fn login(
513515
token: login_response.access_token,
514516
username,
515517
email,
518+
refresh_token: login_response.refresh_token,
516519
})
517520
} else {
518521
// Spring Boot authentication flow
@@ -615,6 +618,7 @@ pub async fn login(
615618
token: login_response.session.access_token,
616619
username: login_response.user.username,
617620
email: login_response.user.email,
621+
refresh_token: None,
618622
})
619623
}
620624
}

frontend/editor/src/desktop/services/authService.ts

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ interface LoginResponse {
3030
token: string;
3131
username: string;
3232
email: string | null;
33+
refresh_token: string | null;
3334
}
3435

3536
interface OAuthCallbackResult {
@@ -347,11 +348,18 @@ export class AuthService {
347348
saasServerUrl: STIRLING_SAAS_URL,
348349
});
349350

350-
const { token, username: returnedUsername, email } = response;
351+
const {
352+
token,
353+
username: returnedUsername,
354+
email,
355+
refresh_token: refreshToken,
356+
} = response;
351357

352-
// Save token to all storage locations
358+
// Save token to all storage locations. Supabase (SaaS) logins include a
359+
// refresh token so the short-lived access token can be renewed; self-hosted
360+
// logins return null here and refresh via the current access token instead.
353361
try {
354-
await this.saveTokenEverywhere(token);
362+
await this.saveTokenEverywhere(token, refreshToken);
355363
} catch (error) {
356364
console.error("[Desktop AuthService] Failed to save token:", error);
357365
throw new Error("Failed to save authentication token", {
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
import { STIRLING_SAAS_BACKEND_API_URL } from "@app/constants/connection";
2+
import type { PolicyExecutionTarget } from "@app/services/policyPipeline";
3+
4+
/**
5+
* Desktop: a policy run's outputs live on the backend that executed it.
6+
*/
7+
export function getPolicyOutputBaseUrl(target: PolicyExecutionTarget): string {
8+
if (target === "saas") {
9+
return (STIRLING_SAAS_BACKEND_API_URL ?? "").replace(/\/$/, "");
10+
}
11+
return "";
12+
}

frontend/editor/src/proprietary/components/policies/policyRunStore.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ function rec(over: Partial<PolicyRunRecord>): PolicyRunRecord {
1818
fileId: "f1",
1919
fileName: "f.pdf",
2020
fileSize: 10,
21+
target: "saas",
2122
status: "PENDING",
2223
outputs: [],
2324
error: null,

frontend/editor/src/proprietary/components/policies/policyRunStore.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,14 +10,18 @@
1010
*/
1111

1212
import { useSyncExternalStore } from "react";
13-
import type { PolicyRunStatus } from "@app/services/policyPipeline";
13+
import type {
14+
PolicyExecutionTarget,
15+
PolicyRunStatus,
16+
} from "@app/services/policyPipeline";
1417

1518
export interface PolicyRunRecord {
1619
runId: string;
1720
categoryId: string;
1821
fileId: string;
1922
fileName: string;
2023
fileSize: number;
24+
target: PolicyExecutionTarget;
2125
status: PolicyRunStatus;
2226
/** Pipeline progress reported by the run-status endpoint: the 1-based step
2327
* currently running, and the total step count. Drive the "step X/Y" label
@@ -72,6 +76,8 @@ function read(): RunState {
7276
importedFileIds: Array.isArray(r.importedFileIds)
7377
? r.importedFileIds
7478
: [],
79+
// Records predating per-run targets all executed on SaaS.
80+
target: r.target === "local" ? "local" : "saas",
7581
}))
7682
: [],
7783
dispatched: Array.isArray(parsed.dispatched) ? parsed.dispatched : [],

frontend/editor/src/proprietary/components/policies/usePolicyAutoRun.import.test.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@ vi.mock("@app/services/policyApi", () => ({
4848
getPolicyRun: vi.fn(),
4949
listPolicyRuns: mocks.listPolicyRuns,
5050
downloadPolicyOutput: mocks.downloadPolicyOutput,
51+
resolvePolicyRunTarget: () => "saas",
5152
}));
5253
vi.mock("@app/services/fileStorage", () => ({
5354
fileStorage: {
@@ -75,6 +76,7 @@ function recordCompletedRun() {
7576
fileId: "file-1",
7677
fileName: "doc.pdf",
7778
fileSize: 1234,
79+
target: "saas",
7880
status: "COMPLETED",
7981
outputs: [{ fileId: "out-file-1", fileName: "doc.pdf" }],
8082
error: null,

frontend/editor/src/proprietary/components/policies/usePolicyAutoRun.retry.test.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ vi.mock("@app/services/policyApi", () => ({
2525
runStoredPolicy: vi.fn(),
2626
getPolicyRun: vi.fn(),
2727
downloadPolicyOutput: vi.fn(),
28+
resolvePolicyRunTarget: () => "saas",
2829
}));
2930
vi.mock("@app/services/fileStorage", () => ({
3031
fileStorage: { getStirlingFile: vi.fn(), getStirlingFileStub: vi.fn() },
@@ -80,6 +81,7 @@ describe("auto-run queue-rejection retry", () => {
8081
fileId: "file-1",
8182
fileName: "doc.pdf",
8283
fileSize: 1234,
84+
target: "saas",
8385
status: "RUNNING",
8486
outputs: [],
8587
error: null,

frontend/editor/src/proprietary/components/policies/usePolicyAutoRun.ts

Lines changed: 64 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -20,11 +20,13 @@ import {
2020
import { fileStorage } from "@app/services/fileStorage";
2121
import { useIndexedDB } from "@app/contexts/IndexedDBContext";
2222
import { POLICIES_ENABLED } from "@app/constants/featureFlags";
23+
import i18n from "@app/i18n";
2324
import {
2425
runStoredPolicy,
2526
getPolicyRun,
2627
listPolicyRuns,
2728
downloadPolicyOutput,
29+
resolvePolicyRunTarget,
2830
} from "@app/services/policyApi";
2931
import type {
3032
PolicyRunStatus,
@@ -82,9 +84,9 @@ const QUEUE_RETRY_BASE_MS = 4000;
8284
* to an instance that hasn't seen it) then fail, rather than polling forever. */
8385
const MAX_NOT_FOUND = 3;
8486

85-
/** A 404 from the run-status endpoint, across the web (axios) and desktop
86-
* (tauri http client → {@code code: "ERR_NOT_FOUND"}) builds. */
87-
function isRunNotFound(err: unknown): boolean {
87+
/** A 404 (run status gone, or output file gone), across the web (axios) and
88+
* desktop (tauri http client → {@code code: "ERR_NOT_FOUND"}) builds. */
89+
function isNotFoundError(err: unknown): boolean {
8890
const e = err as
8991
| { code?: string; status?: number; response?: { status?: number } }
9092
| null
@@ -354,6 +356,9 @@ async function reconcileServerRuns(
354356
fileId: "",
355357
fileName: view.outputs[0]?.fileName ?? "",
356358
fileSize: 0,
359+
// Rediscovered from the SaaS run registry (listPolicyRuns), so its outputs
360+
// live on the cloud backend.
361+
target: "saas",
357362
status: view.status,
358363
outputs: view.outputs,
359364
error: view.error,
@@ -403,9 +408,9 @@ async function importOutputs(
403408
const targetName = ctx.outputName
404409
? undefined // use the run's per-output (renamed) name below
405410
: run.fileName;
406-
const results = await Promise.allSettled(
411+
const settled = await Promise.allSettled(
407412
pending.map(async (out) => {
408-
const blob = await downloadPolicyOutput(out.fileId);
413+
const blob = await downloadPolicyOutput(out.fileId, run.target);
409414
return {
410415
fileId: out.fileId,
411416
file: new File([blob], targetName ?? out.fileName ?? run.fileName, {
@@ -414,13 +419,33 @@ async function importOutputs(
414419
};
415420
}),
416421
);
417-
const fetched = results
422+
const fetched = settled
418423
.filter(
419424
(r): r is PromiseFulfilledResult<{ fileId: string; file: File }> =>
420425
r.status === "fulfilled",
421426
)
422427
.map((r) => r.value);
423-
if (fetched.length === 0) return; // all failed — retry the lot on a later tick
428+
// A 404 means the backend no longer has that output (past its retention
429+
// window); retrying it can never succeed, so don't loop on it forever. Any
430+
// other rejection is transient and worth retrying on a later tick.
431+
const rejections = settled
432+
.filter((r): r is PromiseRejectedResult => r.status === "rejected")
433+
.map((r) => r.reason);
434+
const allFailuresPermanent =
435+
rejections.length > 0 && rejections.every(isNotFoundError);
436+
437+
if (fetched.length === 0) {
438+
if (allFailuresPermanent) {
439+
failRun(
440+
run.runId,
441+
i18n.t(
442+
"policies.activity.outputsUnavailable",
443+
"Policy outputs are no longer available to download.",
444+
),
445+
);
446+
}
447+
return; // transient/mixed: retry the lot later; permanent: already failed.
448+
}
424449

425450
// Deliver, then mark exactly those imported. If delivery throws we don't mark
426451
// them, so they retry (without having been added).
@@ -472,12 +497,26 @@ async function importOutputs(
472497
deliveredIds = added.map((f) => f.fileId as string);
473498
}
474499
const importedFileIds = [...done, ...fetched.map((f) => f.fileId)];
500+
const imported = run.outputs.every((out) =>
501+
importedFileIds.includes(out.fileId),
502+
);
475503
updateRun(run.runId, {
476504
importedFileIds,
477505
// Accumulate across partial-import retries rather than overwriting.
478506
outputFileIds: [...(run.outputFileIds ?? []), ...deliveredIds],
479-
imported: run.outputs.every((out) => importedFileIds.includes(out.fileId)),
507+
imported,
480508
});
509+
// Some outputs landed but the rest are permanently gone (404): finalize so the
510+
// run stops re-fetching the missing ones on every tick.
511+
if (!imported && allFailuresPermanent) {
512+
failRun(
513+
run.runId,
514+
i18n.t(
515+
"policies.activity.partialOutputsUnavailable",
516+
"Some policy outputs are no longer available to download.",
517+
),
518+
);
519+
}
481520
}
482521

483522
/**
@@ -515,6 +554,7 @@ export async function runPolicyOnFile(
515554
return;
516555
}
517556
try {
557+
const target = resolvePolicyRunTarget();
518558
const runId = await runStoredPolicy(backendId, [file]);
519559
// recordRunStart marks this (policy, file) dispatched as it records the run.
520560
recordRunStart({
@@ -523,6 +563,7 @@ export async function runPolicyOnFile(
523563
fileId,
524564
fileName,
525565
fileSize: file.size,
566+
target,
526567
status: "PENDING",
527568
outputs: [],
528569
error: null,
@@ -562,9 +603,15 @@ export async function poll(
562603
// The server lost the run's (in-memory) state — a restart, or a poll that
563604
// hopped to an instance without it. Tolerate a brief blip, then fail so
564605
// the file stops enforcing forever; the user can retry.
565-
if (isRunNotFound(err)) {
606+
if (isNotFoundError(err)) {
566607
if (++notFoundStreak >= MAX_NOT_FOUND) {
567-
failRun(runId, "The enforcement run could no longer be found.");
608+
failRun(
609+
runId,
610+
i18n.t(
611+
"policies.activity.runNotFound",
612+
"The enforcement run could no longer be found.",
613+
),
614+
);
568615
return;
569616
}
570617
} else {
@@ -591,5 +638,11 @@ export async function poll(
591638
}
592639
// Budget exhausted without a terminal status — stop here and fail it, so the
593640
// file doesn't enforce forever and reloads don't re-poll it.
594-
failRun(runId, "Enforcement timed out — the run didn't finish in time.");
641+
failRun(
642+
runId,
643+
i18n.t(
644+
"policies.activity.timedOut",
645+
"Enforcement timed out before the run could finish.",
646+
),
647+
);
595648
}

frontend/editor/src/proprietary/hooks/usePolicyFileBadges.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ function run(overrides: Partial<PolicyRunRecord>): PolicyRunRecord {
1515
fileId: "in",
1616
fileName: "in.pdf",
1717
fileSize: 1,
18+
target: "saas",
1819
status: "COMPLETED",
1920
outputs: [],
2021
outputFileIds: ["out"],

0 commit comments

Comments
 (0)