feat(app): gate desktop updates by org config (#1512)

* feat(app): gate desktop updates by org config

Return allowed desktop versions through the signed-in desktop config endpoint and only surface Tauri updates when both the server and active org explicitly allow them, logging failures silently for debugging.

* fix(app): trace den-gated update checks

Wait for Den auth hydration before the startup update check and add debug logging around auth restoration and update gating so local-vs-cloud version decisions are visible while testing.

---------

Co-authored-by: src-opn <src-opn@users.noreply.github.com>
This commit is contained in:
Source Open
2026-04-21 12:01:25 -07:00
committed by GitHub
parent 0916916b87
commit daff81bef4
8 changed files with 267 additions and 15 deletions

View File

@@ -1709,6 +1709,18 @@ export default function App() {
const [appVersion, setAppVersion] = createSignal<string | null>(null);
const [launchUpdateCheckTriggered, setLaunchUpdateCheckTriggered] = createSignal(false);
const logAppUpdateLifecycle = (label: string, payload?: unknown) => {
try {
if (payload === undefined) {
console.log(`[APP-UPDATES] ${label}`);
} else {
console.log(`[APP-UPDATES] ${label}`, payload);
}
} catch {
// ignore
}
};
const busySeconds = createMemo(() => {
const start = busyStartedAt();
@@ -1893,7 +1905,10 @@ export default function App() {
// ignore
}
if (!launchUpdateCheckTriggered()) {
if (!launchUpdateCheckTriggered() && denAuth.status() !== "checking") {
logAppUpdateLifecycle("mount-triggering-launch-update-check", {
denAuthStatus: denAuth.status(),
});
setLaunchUpdateCheckTriggered(true);
checkForUpdates({ quiet: true }).catch(() => undefined);
}
@@ -2052,14 +2067,34 @@ export default function App() {
}
});
createEffect(() => {
logAppUpdateLifecycle("den-auth-status", {
status: denAuth.status(),
isSignedIn: denAuth.isSignedIn(),
});
});
createEffect(() => {
if (booting()) return;
if (!isTauriRuntime()) return;
if (launchUpdateCheckTriggered()) return;
if (launchUpdateCheckTriggered()) {
logAppUpdateLifecycle("launch-update-check-skipped-already-triggered");
return;
}
if (denAuth.status() === "checking") {
logAppUpdateLifecycle("launch-update-check-waiting-for-den-auth", {
denAuthStatus: denAuth.status(),
});
return;
}
const state = updateStatus();
if (state.state === "checking" || state.state === "downloading") return;
logAppUpdateLifecycle("effect-triggering-launch-update-check", {
denAuthStatus: denAuth.status(),
updateState: state.state,
});
setLaunchUpdateCheckTriggered(true);
checkForUpdates({ quiet: true }).catch(() => undefined);
});

View File

@@ -1,6 +1,7 @@
import { createContext, createMemo, createSignal, onCleanup, onMount, useContext, type Accessor, type ParentProps } from "solid-js";
import { clearDenSession, createDenClient, DenApiError, ensureDenActiveOrganization, readDenSettings, type DenUser } from "../lib/den";
import { denSessionUpdatedEvent } from "../lib/den-session-events";
import { recordDevLog } from "../lib/dev-log";
type DenAuthStatus = "checking" | "signed_in" | "signed_out";
@@ -14,6 +15,19 @@ type DenAuthStore = {
const DenAuthContext = createContext<DenAuthStore>();
function logDenAuth(label: string, payload?: unknown) {
try {
recordDevLog(true, { level: "debug", source: "den-auth", label, payload });
if (payload === undefined) {
console.log(`[DEN-AUTH] ${label}`);
} else {
console.log(`[DEN-AUTH] ${label}`, payload);
}
} catch {
// ignore
}
}
export function DenAuthProvider(props: ParentProps) {
const [status, setStatus] = createSignal<DenAuthStatus>("checking");
const [user, setUser] = createSignal<DenUser | null>(null);
@@ -25,14 +39,24 @@ export function DenAuthProvider(props: ParentProps) {
const settings = readDenSettings();
const token = settings.authToken?.trim() ?? "";
logDenAuth("refresh-start", {
currentRun,
hasToken: Boolean(token),
activeOrgId: settings.activeOrgId ?? null,
activeOrgSlug: settings.activeOrgSlug ?? null,
baseUrl: settings.baseUrl,
});
if (!token) {
setUser(null);
setError(null);
setStatus("signed_out");
logDenAuth("refresh-signed-out-no-token", { currentRun });
return;
}
setStatus("checking");
logDenAuth("refresh-status-checking", { currentRun });
try {
const nextUser = await createDenClient({
@@ -42,6 +66,7 @@ export function DenAuthProvider(props: ParentProps) {
}).getSession();
if (currentRun !== refreshToken) {
logDenAuth("refresh-stale-after-session", { currentRun, refreshToken });
return;
}
@@ -52,14 +77,22 @@ export function DenAuthProvider(props: ParentProps) {
}).catch(() => null);
if (currentRun !== refreshToken) {
logDenAuth("refresh-stale-after-org-sync", { currentRun, refreshToken });
return;
}
setUser(nextUser);
setError(null);
setStatus("signed_in");
logDenAuth("refresh-signed-in", {
currentRun,
userId: nextUser.id,
activeOrgId: readDenSettings().activeOrgId ?? null,
activeOrgSlug: readDenSettings().activeOrgSlug ?? null,
});
} catch (nextError) {
if (currentRun !== refreshToken) {
logDenAuth("refresh-stale-after-error", { currentRun, refreshToken });
return;
}
@@ -70,6 +103,10 @@ export function DenAuthProvider(props: ParentProps) {
setUser(null);
setError(nextError instanceof Error ? nextError.message : "Failed to restore OpenWork Cloud session.");
setStatus("signed_out");
logDenAuth("refresh-error", {
currentRun,
error: nextError instanceof Error ? nextError.message : String(nextError),
});
}
};
@@ -81,6 +118,7 @@ export function DenAuthProvider(props: ParentProps) {
}
const handleSessionUpdated = () => {
logDenAuth("session-updated-event");
void refresh();
};

View File

@@ -1,7 +1,8 @@
import type { DesktopAppRestrictions as DenDesktopConfig } from "@openwork/types/den/desktop-app-restrictions";
import type { DesktopAppRestrictions } from "@openwork/types/den/desktop-app-restrictions";
import type { DenDesktopConfig } from "../lib/den";
import type { ModelRef } from "../types";
export type DesktopAppRestrictionKey = keyof DenDesktopConfig;
export type DesktopAppRestrictionKey = keyof DesktopAppRestrictions;
export type DesktopAppRestrictionChecker = (input: {
restriction: DesktopAppRestrictionKey;

View File

@@ -1,5 +1,5 @@
import { fetch as tauriFetch } from "@tauri-apps/plugin-http";
import { normalizeDesktopAppRestrictions, type DesktopAppRestrictions } from "@openwork/types/den/desktop-app-restrictions";
import { normalizeDesktopConfig, type DesktopConfig as SharedDesktopConfig } from "@openwork/types/den/desktop-app-restrictions";
import { isDesktopDeployment } from "./openwork-deployment";
import {
dispatchDenSettingsChanged,
@@ -54,7 +54,7 @@ export type DenBootstrapConfig = DenBaseUrls & {
requireSignin: boolean;
};
export type DenDesktopConfig = DesktopAppRestrictions;
export type DenDesktopConfig = SharedDesktopConfig;
export type DenUser = {
id: string;
@@ -237,7 +237,7 @@ function getDenAppVersionMetadata(payload: unknown): DenAppVersionMetadata | nul
}
export function normalizeDenDesktopConfig(payload: unknown): DenDesktopConfig {
return normalizeDesktopAppRestrictions(payload);
return normalizeDesktopConfig(payload);
}
export function normalizeDenBaseUrl(input: string | null | undefined): string | null {

View File

@@ -18,7 +18,8 @@ import type {
import { addOpencodeCacheHint, isTauriRuntime, safeStringify } from "./utils";
import { filterProviderList, mapConfigProvidersToList } from "./utils/providers";
import { createUpdaterState, type UpdateStatus } from "./context/updater";
import { createDenClient, readDenSettings } from "./lib/den";
import { createDenClient, readDenSettings, type DenDesktopConfig } from "./lib/den";
import { recordDevLog } from "./lib/dev-log";
import {
resetOpenworkState,
resetOpencodeCache,
@@ -132,14 +133,122 @@ function compareVersions(left: string, right: string): number | null {
return comparePrereleaseIdentifiers(parsedLeft.prerelease, parsedRight.prerelease);
}
function logUpdateGateFailure(label: string, payload?: unknown) {
try {
recordDevLog(true, {
level: "warn",
source: "updates",
label,
payload,
});
if (payload === undefined) {
console.warn(`[UPDATES] ${label}`);
} else {
console.warn(`[UPDATES] ${label}`, payload);
}
} catch {
// ignore
}
}
function logUpdateGateDebug(label: string, payload?: unknown) {
try {
recordDevLog(true, {
level: "debug",
source: "updates",
label,
payload,
});
if (payload === undefined) {
console.log(`[UPDATES] ${label}`);
} else {
console.log(`[UPDATES] ${label}`, payload);
}
} catch {
// ignore
}
}
function isUpdateAllowedByDesktopConfig(
updateVersion: string,
desktopConfig: DenDesktopConfig | null | undefined,
) {
if (!Array.isArray(desktopConfig?.allowedDesktopVersions)) {
return true;
}
return desktopConfig.allowedDesktopVersions.some(
(allowedVersion) => compareVersions(updateVersion, allowedVersion) === 0,
);
}
async function isUpdateSupportedByDen(updateVersion: string) {
try {
const settings = readDenSettings();
const client = createDenClient({ baseUrl: settings.baseUrl, apiBaseUrl: settings.apiBaseUrl });
const token = settings.authToken?.trim() ?? "";
logUpdateGateDebug("den-update-check-start", {
updateVersion,
hasToken: Boolean(token),
activeOrgId: settings.activeOrgId ?? null,
activeOrgSlug: settings.activeOrgSlug ?? null,
baseUrl: settings.baseUrl,
apiBaseUrl: settings.apiBaseUrl ?? null,
});
const client = createDenClient({
baseUrl: settings.baseUrl,
apiBaseUrl: settings.apiBaseUrl,
...(token ? { token } : {}),
});
const metadata = await client.getAppVersionMetadata();
const comparison = compareVersions(updateVersion, metadata.latestAppVersion);
return comparison !== null && comparison <= 0;
} catch {
logUpdateGateDebug("den-update-check-app-version-response", {
updateVersion,
minAppVersion: metadata.minAppVersion,
latestAppVersion: metadata.latestAppVersion,
comparison,
});
if (comparison === null) {
logUpdateGateFailure("den-update-check-invalid-version-comparison", {
updateVersion,
latestAppVersion: metadata.latestAppVersion,
});
return false;
}
if (comparison > 0) {
logUpdateGateDebug("den-update-check-blocked-by-server-max", {
updateVersion,
latestAppVersion: metadata.latestAppVersion,
});
return false;
}
if (!token) {
logUpdateGateDebug("den-update-check-allowed-no-token", { updateVersion });
return true;
}
try {
const desktopConfig = await client.getDesktopConfig();
const allowed = isUpdateAllowedByDesktopConfig(updateVersion, desktopConfig);
logUpdateGateDebug("den-update-check-desktop-config-response", {
updateVersion,
allowedDesktopVersions: desktopConfig.allowedDesktopVersions ?? null,
allowed,
});
return allowed;
} catch (error) {
logUpdateGateFailure("den-update-check-desktop-config-fetch-failed", {
updateVersion,
error: error instanceof Error ? error.message : safeStringify(error),
});
return false;
}
} catch (error) {
logUpdateGateFailure("den-update-check-app-version-fetch-failed", {
updateVersion,
error: error instanceof Error ? error.message : safeStringify(error),
});
return false;
}
}
@@ -516,6 +625,15 @@ export function createSystemState(options: {
const update = (await check({ timeout: 8_000 })) as unknown as UpdateHandle | null;
const checkedAt = Date.now();
logUpdateGateDebug("tauri-update-check-result", update
? {
available: update.available,
currentVersion: update.currentVersion,
version: update.version,
date: update.date ?? null,
}
: { available: false });
if (!update) {
setPendingUpdate(null);
setUpdateStatus({ state: "idle", lastCheckedAt: checkedAt });
@@ -525,11 +643,17 @@ export function createSystemState(options: {
const notes = typeof update.body === "string" ? update.body : undefined;
if (!(await isUpdateSupportedByDen(update.version))) {
logUpdateGateDebug("tauri-update-check-suppressed-by-den", {
version: update.version,
});
setPendingUpdate(null);
setUpdateStatus({ state: "idle", lastCheckedAt: checkedAt });
return;
}
logUpdateGateDebug("tauri-update-check-allowed-by-den", {
version: update.version,
});
setPendingUpdate({ update, version: update.version, notes });
setUpdateStatus({
state: "available",

View File

@@ -1 +1 @@
export const BUILD_LATEST_APP_VERSION = "0.11.210" as const
export const BUILD_LATEST_APP_VERSION = "0.11.212" as const;

View File

@@ -1,9 +1,10 @@
import type { Hono } from "hono"
import { describeRoute } from "hono-openapi"
import { desktopAppRestrictionsSchema } from "@openwork/types/den/desktop-app-restrictions"
import { desktopConfigSchema } from "@openwork/types/den/desktop-app-restrictions"
import { z } from "zod"
import { requireUserMiddleware, resolveOrganizationContextMiddleware, resolveUserOrganizationsMiddleware, type OrganizationContextVariables, type UserOrganizationsContext } from "../../middleware/index.js"
import { denTypeIdSchema, jsonResponse, unauthorizedSchema } from "../../openapi.js"
import { normalizeOrganizationMetadata } from "../../organization-limits.js"
import type { AuthContextVariables } from "../../session.js"
const meResponseSchema = z.object({
@@ -20,7 +21,7 @@ const meOrganizationsResponseSchema = z.object({
activeOrgSlug: z.string().nullable(),
}).meta({ ref: "CurrentUserOrganizationsResponse" })
const meDesktopConfigResponseSchema = desktopAppRestrictionsSchema.meta({
const meDesktopConfigResponseSchema = desktopConfigSchema.meta({
ref: "CurrentUserDesktopConfigResponse",
})
@@ -84,7 +85,15 @@ export function registerMeRoutes<T extends { Variables: AuthContextVariables & P
requireUserMiddleware,
resolveOrganizationContextMiddleware,
(c) => {
return c.json(c.get("organizationContext").organization.desktopAppRestrictions)
const organization = c.get("organizationContext").organization
const metadata = normalizeOrganizationMetadata(organization.metadata).metadata
return c.json({
...organization.desktopAppRestrictions,
...(Array.isArray(metadata.allowedDesktopVersions)
? { allowedDesktopVersions: metadata.allowedDesktopVersions }
: {}),
})
},
)
}

View File

@@ -8,6 +8,39 @@ export const desktopAppRestrictionsSchema = z.object({
export type DesktopAppRestrictions = z.infer<typeof desktopAppRestrictionsSchema>
export const desktopConfigSchema = desktopAppRestrictionsSchema.extend({
allowedDesktopVersions: z.array(z.string().trim().min(1).max(32)).optional(),
}).meta({ ref: "DenDesktopConfig" })
export type DesktopConfig = z.infer<typeof desktopConfigSchema>
function normalizeDesktopVersionString(value: unknown) {
if (typeof value !== "string") {
return null
}
const normalized = value.trim().replace(/^v/i, "")
return /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/.test(normalized)
? normalized
: null
}
function normalizeAllowedDesktopVersions(value: unknown): string[] | undefined {
if (!Array.isArray(value)) {
return undefined
}
const versions = [
...new Set(
value
.map((entry) => normalizeDesktopVersionString(entry))
.filter((entry): entry is string => Boolean(entry)),
),
]
return versions
}
export function normalizeDesktopAppRestrictions(value: unknown): DesktopAppRestrictions {
const parsed = desktopAppRestrictionsSchema.safeParse(value)
if (parsed.success) {
@@ -28,3 +61,15 @@ export function normalizeDesktopAppRestrictions(value: unknown): DesktopAppRestr
...(legacy?.models?.removeZen === true ? { blockZenModel: true } : {}),
}
}
export function normalizeDesktopConfig(value: unknown): DesktopConfig {
const restrictions = normalizeDesktopAppRestrictions(value)
const allowedDesktopVersions = normalizeAllowedDesktopVersions(
(value as { allowedDesktopVersions?: unknown } | null)?.allowedDesktopVersions,
)
return {
...restrictions,
...(allowedDesktopVersions !== undefined ? { allowedDesktopVersions } : {}),
}
}