From daff81bef4d787e9b74592519561322d8ac39bad Mon Sep 17 00:00:00 2001 From: Source Open Date: Tue, 21 Apr 2026 12:01:25 -0700 Subject: [PATCH] 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 --- apps/app/src/app/app.tsx | 39 +++++- apps/app/src/app/cloud/den-auth-provider.tsx | 38 +++++ .../src/app/cloud/desktop-app-restrictions.ts | 5 +- apps/app/src/app/lib/den.ts | 6 +- apps/app/src/app/system-state.ts | 132 +++++++++++++++++- ee/apps/den-api/src/generated/app-version.ts | 2 +- ee/apps/den-api/src/routes/me/index.ts | 15 +- .../types/src/den/desktop-app-restrictions.ts | 45 ++++++ 8 files changed, 267 insertions(+), 15 deletions(-) diff --git a/apps/app/src/app/app.tsx b/apps/app/src/app/app.tsx index 851f1ea8..715836b4 100644 --- a/apps/app/src/app/app.tsx +++ b/apps/app/src/app/app.tsx @@ -1709,6 +1709,18 @@ export default function App() { const [appVersion, setAppVersion] = createSignal(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); }); diff --git a/apps/app/src/app/cloud/den-auth-provider.tsx b/apps/app/src/app/cloud/den-auth-provider.tsx index 3400d818..ed7ee093 100644 --- a/apps/app/src/app/cloud/den-auth-provider.tsx +++ b/apps/app/src/app/cloud/den-auth-provider.tsx @@ -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(); +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("checking"); const [user, setUser] = createSignal(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(); }; diff --git a/apps/app/src/app/cloud/desktop-app-restrictions.ts b/apps/app/src/app/cloud/desktop-app-restrictions.ts index d7f692ac..92f9bf57 100644 --- a/apps/app/src/app/cloud/desktop-app-restrictions.ts +++ b/apps/app/src/app/cloud/desktop-app-restrictions.ts @@ -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; diff --git a/apps/app/src/app/lib/den.ts b/apps/app/src/app/lib/den.ts index f854b55e..6cebc3bd 100644 --- a/apps/app/src/app/lib/den.ts +++ b/apps/app/src/app/lib/den.ts @@ -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 { diff --git a/apps/app/src/app/system-state.ts b/apps/app/src/app/system-state.ts index e1cfac40..d50a6828 100644 --- a/apps/app/src/app/system-state.ts +++ b/apps/app/src/app/system-state.ts @@ -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", diff --git a/ee/apps/den-api/src/generated/app-version.ts b/ee/apps/den-api/src/generated/app-version.ts index 9cdd36d2..10716959 100644 --- a/ee/apps/den-api/src/generated/app-version.ts +++ b/ee/apps/den-api/src/generated/app-version.ts @@ -1 +1 @@ -export const BUILD_LATEST_APP_VERSION = "0.11.210" as const +export const BUILD_LATEST_APP_VERSION = "0.11.212" as const; diff --git a/ee/apps/den-api/src/routes/me/index.ts b/ee/apps/den-api/src/routes/me/index.ts index aa297e8b..47ec968f 100644 --- a/ee/apps/den-api/src/routes/me/index.ts +++ b/ee/apps/den-api/src/routes/me/index.ts @@ -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 { - 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 } + : {}), + }) }, ) } diff --git a/packages/types/src/den/desktop-app-restrictions.ts b/packages/types/src/den/desktop-app-restrictions.ts index da083e32..79481971 100644 --- a/packages/types/src/den/desktop-app-restrictions.ts +++ b/packages/types/src/den/desktop-app-restrictions.ts @@ -8,6 +8,39 @@ export const desktopAppRestrictionsSchema = z.object({ export type DesktopAppRestrictions = z.infer +export const desktopConfigSchema = desktopAppRestrictionsSchema.extend({ + allowedDesktopVersions: z.array(z.string().trim().min(1).max(32)).optional(), +}).meta({ ref: "DenDesktopConfig" }) + +export type DesktopConfig = z.infer + +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 } : {}), + } +}