diff --git a/frontend/src/lib/components/apps/editor/AppEditorHeaderDeploy.svelte b/frontend/src/lib/components/apps/editor/AppEditorHeaderDeploy.svelte
index 2df3c83354..722d86f2d8 100644
--- a/frontend/src/lib/components/apps/editor/AppEditorHeaderDeploy.svelte
+++ b/frontend/src/lib/components/apps/editor/AppEditorHeaderDeploy.svelte
@@ -21,10 +21,7 @@
type OnBehalfOfChoice
} from '$lib/components/OnBehalfOfSelector.svelte'
import { canUserBypassRuleKind, protectionRulesState } from '$lib/workspaceProtectionRules.svelte'
- import {
- FRONTEND_SDK_SCOPES,
- MIN_SANDBOXED_SDK_VERSION
- } from '$lib/components/raw_apps/sdkScopes'
+ import { FRONTEND_SDK_SCOPES } from '$lib/components/raw_apps/sdkScopes'
const WM_DEPLOYERS_GROUP = 'wm_deployers'
@@ -373,10 +370,10 @@
{/if}
{#if policy.sandbox == true && policy.frontend_sdk_scopes?.length}
-
- A sandboxed app calls the API cross-origin, which earlier versions of the SDK cannot do.
- Make sure your app depends on windmill-client@^{MIN_SANDBOXED_SDK_VERSION} and
- redeploy — apps bundled against an older version fail with a CORS error.
+
+ A sandboxed app calls the API cross-origin, which older windmill-client versions
+ cannot do. An app bundled before this Windmill version fails with a CORS error until you deploy
+ it again, which re-bundles it against a current client.
{/if}
diff --git a/frontend/src/lib/components/apps/editor/PublicAppFrame.svelte b/frontend/src/lib/components/apps/editor/PublicAppFrame.svelte
index a62c640667..3ab9ea5bc7 100644
--- a/frontend/src/lib/components/apps/editor/PublicAppFrame.svelte
+++ b/frontend/src/lib/components/apps/editor/PublicAppFrame.svelte
@@ -241,7 +241,7 @@
})
// Read by RawAppPreview: the consented viewer-scoped SDK token to expose to
- // the bundle (unsandboxed raw apps only).
+ // the bundle, sandboxed or not.
setContext('RAW_APP_SDK_TOKEN', {
get value() {
return sdkToken
@@ -268,8 +268,8 @@
appPath = resp.app_path ?? undefined
workspaceId = resp.workspace_id ?? undefined
// The backend only advertises scopes where a token could actually be
- // minted (raw, unsandboxed, viewer authenticated), and mints one only on
- // the follow-up request carrying the viewer's consent.
+ // minted (a raw app, viewer authenticated), and mints one only on the
+ // follow-up request carrying the viewer's consent.
sdkScopes = resp.sdk_scopes?.length ? resp.sdk_scopes : undefined
viewerEmail = resp.viewer_email ?? ''
sdkToken = undefined
diff --git a/frontend/src/lib/components/raw_apps/RawAppPreview.svelte b/frontend/src/lib/components/raw_apps/RawAppPreview.svelte
index 8aac3ca5de..2b07728d07 100644
--- a/frontend/src/lib/components/raw_apps/RawAppPreview.svelte
+++ b/frontend/src/lib/components/raw_apps/RawAppPreview.svelte
@@ -57,10 +57,11 @@
// - DEFAULT (isolated): a real API URL serving a sandboxed, opaque-origin
// document (`CSP: sandbox` response header + the iframe sandbox attribute),
// so a malicious bundle can never reach the authenticated Windmill origin
- // (no cookie, no window.parent, no token). Root-relative so it resolves
- // against the real host even when this component itself runs inside an opaque
- // viewer (where `location.origin` is "null"). Context is handed over via
- // postMessage — never baked into the document, never a credential.
+ // (no cookie, no window.parent). Root-relative so it resolves against the
+ // real host even when this component itself runs inside an opaque viewer
+ // (where `location.origin` is "null"). Context — and, when the viewer
+ // approved SDK scopes, the viewer-scoped token — is handed over via
+ // postMessage, never baked into the document.
// - UNSANDBOXED (the default — publisher did not opt into isolation): a
// client-built blob: wrapper (same-origin with the SPA) loaded with `allow-same-origin`,
// so relative `fetch('/api/...')` and the session cookie work. The backend
@@ -150,8 +151,21 @@
} catch (_) {}
}
+ // The token may only be handed over once per document we loaded ourselves. The
+ // reply is addressed to a browsing context, not a document, and it must target
+ // `*` because the sandboxed frame has an opaque origin — so a page the bundle
+ // (or a link the viewer clicked) navigated to keeps the same `contentWindow`
+ // and could ask for the credential again by posting `windmill:ready`. Reset
+ // only when WE change the iframe's src, which a navigation away does not.
+ let sdkHandedOff = false
+ $effect(() => {
+ iframeSrc
+ sdkHandedOff = false
+ })
+
function respondCtx() {
- const sdkToken = sdkTokenCtx?.value
+ const sdkToken = sdkHandedOff ? undefined : sdkTokenCtx?.value
+ if (sdkToken) sdkHandedOff = true
iframe?.contentWindow?.postMessage(
{
type: 'windmill:ctx',
diff --git a/frontend/src/lib/components/raw_apps/sdkScopes.ts b/frontend/src/lib/components/raw_apps/sdkScopes.ts
index e31b7e6515..9a825fc02b 100644
--- a/frontend/src/lib/components/raw_apps/sdkScopes.ts
+++ b/frontend/src/lib/components/raw_apps/sdkScopes.ts
@@ -3,12 +3,6 @@
// in the backend `apps.rs` — both lists must stay in sync), plus the viewer-side
// consent persistence for the permission banner.
-/** A sandboxed app calls the API from an opaque origin, which needs the
- * credential-free client shipped alongside this release: earlier versions force
- * `credentials: 'include'`, which CORS rejects. Tracks the monorepo version
- * because `windmill-client` is published from it in lockstep. */
-export const MIN_SANDBOXED_SDK_VERSION = __pkg__.version
-
export const FRONTEND_SDK_SCOPES: { value: string; label: string; description: string }[] = [
{
value: 'jobs:run',