feat(apps): use the windmill-client SDK from raw app frontend code

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018Gmsk9kAG7p9t2Qy6ADRJz
This commit is contained in:
Diego Imbert
2026-07-28 11:08:24 +02:00
co-authored by Claude Fable 5
parent 044ce39e5f
commit 397f4a6e1a
13 changed files with 533 additions and 66 deletions
+33
View File
@@ -8313,6 +8313,7 @@ paths:
- app
parameters:
- $ref: "#/components/parameters/CustomPath"
- $ref: "#/components/parameters/SdkConsent"
responses:
"200":
description: embed token
@@ -11838,6 +11839,7 @@ paths:
parameters:
- $ref: "#/components/parameters/WorkspaceId"
- $ref: "#/components/parameters/ScriptPath"
- $ref: "#/components/parameters/SdkConsent"
responses:
"200":
description: embed token
@@ -11976,6 +11978,7 @@ paths:
required: true
schema:
type: string
- $ref: "#/components/parameters/SdkConsent"
responses:
"200":
description: embed token
@@ -23866,6 +23869,16 @@ components:
required: true
schema:
type: string
SdkConsent:
name: sdk_consent
in: query
required: false
description: >
Raw apps: the viewer confirmed the frontend-SDK permissions consent
banner, so the viewer-scoped SDK token may actually be minted. Without
it the response only advertises the declared sdk_scopes.
schema:
type: boolean
PathId:
name: id
in: path
@@ -30700,6 +30713,17 @@ components:
is isolated from each viewer's Windmill session. When false/absent
the app runs same-origin with the viewer's full session (the
default, pre-isolation behavior).
frontend_sdk_scopes:
type: array
items:
type: string
description: >
Raw apps: author-declared scopes for the frontend SDK token. When
non-empty, viewers can mint (after consenting) a short-lived token
carrying their own identity restricted to these scopes, handed to
the app bundle so `windmill-client` calls run as the viewer. Must
be a subset of the server's curated allowlist (jobs:run, jobs:read,
users:read, resources:read, variables:read).
ListableApp:
type: object
@@ -30945,6 +30969,15 @@ components:
type: string
nullable: true
description: The resolved workspace; pairs with app_path so apps at the same path in different workspaces don't share a localStorage store.
sdk_scopes:
type: array
nullable: true
items:
type: string
description: >
Raw apps: scopes the app policy declares for the frontend SDK
token. The viewer renders these in the consent banner; token stays
absent until the endpoint is re-called with sdk_consent=true.
required:
- raw_app
- sandbox
+233 -41
View File
@@ -329,6 +329,13 @@ pub struct Policy {
// with the viewer's full session, the pre-isolation behavior.
#[serde(skip_serializing_if = "Option::is_none")]
pub sandbox: Option<bool>,
/// Author-declared scopes for the frontend SDK token (raw apps): when set and
/// non-empty, viewers can mint a short-lived token carrying THEIR identity
/// restricted to these scopes, handed to the app bundle so `windmill-client`
/// calls run as the viewer. Must be a subset of `FRONTEND_SDK_ALLOWED_SCOPES`.
/// Absent/empty means the bundle receives no credential (the default).
#[serde(skip_serializing_if = "Option::is_none")]
pub frontend_sdk_scopes: Option<Vec<String>>,
}
#[derive(Deserialize)]
@@ -1210,6 +1217,140 @@ pub const APP_EMBED_SCOPES: [&str; 5] = [
/// (e.g. after a `401` from the iframe) so this can stay short.
const APP_EMBED_TOKEN_VALIDITY_HOURS: i64 = 12;
/// Scopes an app author may declare in `Policy::frontend_sdk_scopes` (the
/// viewer-identity token handed to a raw app's bundled `windmill-client`).
/// Deliberately excludes every `apps:*` scope: the embed/SDK mint routes live in
/// the Apps scope domain and scoped tokens are default-denied outside their
/// domains, so a minted SDK token can never reach the mint endpoints to renew
/// itself past its expiry.
pub const FRONTEND_SDK_ALLOWED_SCOPES: [&str; 5] = [
"jobs:run",
"jobs:read",
"users:read",
"resources:read",
"variables:read",
];
/// Reject a policy declaring frontend SDK scopes outside the curated list.
/// Enforced on every policy write AND re-checked at mint time, so a policy
/// written by an older/foreign client can't broaden what gets minted.
fn validate_frontend_sdk_scopes_list(scopes: &[String]) -> Result<()> {
for s in scopes {
if !FRONTEND_SDK_ALLOWED_SCOPES.contains(&s.as_str()) {
return Err(Error::BadRequest(format!(
"Invalid frontend SDK scope '{}'. Allowed scopes: {}",
s,
FRONTEND_SDK_ALLOWED_SCOPES.join(", ")
)));
}
}
Ok(())
}
fn validate_frontend_sdk_scopes(policy: &Policy) -> Result<()> {
if let Some(scopes) = &policy.frontend_sdk_scopes {
validate_frontend_sdk_scopes_list(scopes)?;
}
Ok(())
}
/// Mint the short-lived viewer-identity token a raw app's bundle uses for
/// `windmill-client` calls. Unlike the app-embed token (fixed narrow scopes +
/// route allowlist), its scopes are the policy-declared `frontend_sdk_scopes` —
/// the viewer consented to them before this is called — and confinement is the
/// regular default-deny scope system.
pub async fn mint_raw_app_sdk_token(
db: &DB,
w_id: &str,
app_path: &str,
authed: &ApiAuthed,
scopes: &[String],
) -> Result<(String, chrono::DateTime<chrono::Utc>)> {
// An embed token represents untrusted app JS; it must not bootstrap a
// broader SDK credential (same guard as `mint_app_embed_token`).
if windmill_api_auth::scopes::has_app_embed_sentinel(authed.scopes.as_deref()) {
return Err(Error::NotAuthorized(
"App embed tokens cannot mint SDK tokens".to_string(),
));
}
validate_frontend_sdk_scopes_list(scopes)?;
let scopes = scopes.to_vec();
// A scope-restricted caller token must not bootstrap a broader-scoped SDK
// token. No-op for unscoped browser sessions.
ensure_scopes_within_caller(authed, Some(&scopes))?;
let expiration = chrono::Utc::now() + chrono::Duration::hours(APP_EMBED_TOKEN_VALIDITY_HOURS);
let token_config = NewToken::new(
Some(format!("sdk_app:{app_path}")),
Some(expiration),
None,
Some(scopes),
Some(w_id.to_string()),
// Never let an SDK token gain write capability the caller's own
// session lacks.
Some(authed.read_only),
);
let mut tx = db.begin().await?;
let token = create_token_internal(&mut *tx, db, authed, token_config).await?;
tx.commit().await?;
Ok((token, expiration))
}
/// Shared tail of the three embed-token endpoints (by secret, by path, and the
/// EE by-custom-path variant): resolves which credential — if any — the viewer
/// gets for this app. Sandboxed low-code apps get the narrow embed token; raw
/// apps whose policy declares `frontend_sdk_scopes` get the viewer-scoped SDK
/// token, but only once the viewer consented (`sdk_consent`) — without it the
/// response only advertises `sdk_scopes` so the viewer can render the consent
/// banner and re-request.
pub async fn build_embed_token_response(
db: &DB,
w_id: &str,
app_path: &str,
raw_app: bool,
policy: &EmbedPolicyView,
opt_authed: Option<&ApiAuthed>,
sdk_consent: bool,
) -> Result<EmbedTokenResponse> {
let sdk_scopes = if raw_app && !policy.frontend_sdk_scopes.is_empty() {
Some(policy.frontend_sdk_scopes.clone())
} else {
None
};
let (token, expiration) = if raw_app {
match (&sdk_scopes, opt_authed) {
(Some(scopes), Some(authed)) if sdk_consent => {
let (t, e) = mint_raw_app_sdk_token(db, w_id, app_path, authed, scopes).await?;
(Some(t), Some(e))
}
_ => (None, None),
}
} else if policy.sandbox {
let resp = mint_app_embed_token(db, w_id, app_path, opt_authed).await?;
(resp.token, resp.expiration)
} else {
(None, None)
};
Ok(EmbedTokenResponse {
token,
expiration,
raw_app,
sandbox: policy.sandbox,
app_path: Some(app_path.to_string()),
workspace_id: Some(w_id.to_string()),
sdk_scopes,
})
}
/// Query for the embed-token endpoints.
#[derive(Deserialize)]
pub struct EmbedTokenQuery {
/// Raw apps: the viewer confirmed the SDK-permissions consent banner, so the
/// viewer-scoped SDK token may actually be minted. Defaults to false — the
/// first fetch only advertises the declared scopes.
#[serde(default)]
pub sdk_consent: bool,
}
#[derive(Serialize)]
pub struct EmbedTokenResponse {
/// Narrowly-scoped token for the iframe. `None` for fully anonymous access
@@ -1237,6 +1378,11 @@ pub struct EmbedTokenResponse {
/// share a store. For custom-path apps the viewer can't derive this itself.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub workspace_id: Option<String>,
/// Raw apps: scopes the app policy declares for the frontend SDK token. The
/// viewer renders these in the consent banner; `token` stays `None` until the
/// endpoint is re-called with `sdk_consent=true`.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub sdk_scopes: Option<Vec<String>>,
}
/// Mint a short-lived, narrowly-scoped embed token for `app_path` when a caller
@@ -1313,6 +1459,7 @@ pub async fn mint_app_embed_token(
sandbox: false,
app_path: Some(app_path.to_string()),
workspace_id: Some(w_id.to_string()),
sdk_scopes: None,
})
}
@@ -1325,6 +1472,7 @@ async fn get_app_embed_token(
Extension(user_db): Extension<UserDB>,
Extension(db): Extension<DB>,
Path((w_id, secret)): Path<(String, String)>,
Query(sdk_query): Query<EmbedTokenQuery>,
) -> JsonResult<EmbedTokenResponse> {
let id = get_id_from_secret(&db, &w_id, secret, None).await?;
@@ -1374,29 +1522,16 @@ async fn get_app_embed_token(
Some(authed)
};
// The token is only consumed by the sandboxed low-code render. Raw apps
// render single-iframe with the page credential (WIN-2006 Variant A), and
// unsandboxed apps render same-origin with the viewer's own session — minting
// for those would write a useless token row per view and, worse, could fail
// the whole render for a scope-restricted caller (`ensure_scopes_within_caller`)
// even though no token is needed. The access check above still gates
// visibility in every case.
let mut resp = if raw_app || !policy.sandbox {
EmbedTokenResponse {
token: None,
expiration: None,
raw_app,
sandbox: policy.sandbox,
app_path: None,
workspace_id: None,
}
} else {
mint_app_embed_token(&db, &w_id, &app.path, authed_for_token.as_ref()).await?
};
resp.raw_app = raw_app;
resp.sandbox = policy.sandbox;
resp.app_path = Some(app.path);
resp.workspace_id = Some(w_id.to_string());
let resp = build_embed_token_response(
&db,
&w_id,
&app.path,
raw_app,
&policy,
authed_for_token.as_ref(),
sdk_query.sdk_consent,
)
.await?;
Ok(Json(resp))
}
@@ -1410,6 +1545,9 @@ async fn get_app_embed_token(
pub struct EmbedPolicyView {
pub anonymous_execution: bool,
pub sandbox: bool,
/// Raw apps: author-declared scopes for the frontend SDK token; empty when
/// the app doesn't use the frontend SDK (non-string entries are ignored).
pub frontend_sdk_scopes: Vec<String>,
}
pub fn parse_embed_policy(policy_str: &str) -> Result<EmbedPolicyView> {
@@ -1417,6 +1555,15 @@ pub fn parse_embed_policy(policy_str: &str) -> Result<EmbedPolicyView> {
Ok(EmbedPolicyView {
anonymous_execution: v.get("execution_mode").and_then(|m| m.as_str()) == Some("anonymous"),
sandbox: v.get("sandbox").and_then(|b| b.as_bool()).unwrap_or(false),
frontend_sdk_scopes: v
.get("frontend_sdk_scopes")
.and_then(|s| s.as_array())
.map(|a| {
a.iter()
.filter_map(|x| x.as_str().map(|s| s.to_string()))
.collect()
})
.unwrap_or_default(),
})
}
@@ -1430,6 +1577,7 @@ async fn get_app_embed_token_for_path(
Extension(user_db): Extension<UserDB>,
Extension(db): Extension<DB>,
Path((w_id, path)): Path<(String, StripPath)>,
Query(sdk_query): Query<EmbedTokenQuery>,
) -> JsonResult<EmbedTokenResponse> {
let path = path.to_path();
check_scopes(&authed, || format!("apps:read:{}", path))?;
@@ -1450,26 +1598,19 @@ async fn get_app_embed_token_for_path(
let policy_str = app
.policy
.ok_or_else(|| Error::internal_err("App policy missing".to_string()))?;
// Lenient parse + mint only for the sandboxed low-code render — see
// [`get_app_embed_token`] for the rationale (identical here).
// Lenient parse — see [`get_app_embed_token`] for the rationale (identical here).
let policy = parse_embed_policy(&policy_str)?;
let mut resp = if raw_app || !policy.sandbox {
EmbedTokenResponse {
token: None,
expiration: None,
raw_app,
sandbox: policy.sandbox,
app_path: None,
workspace_id: None,
}
} else {
mint_app_embed_token(&db, &w_id, path, Some(&authed)).await?
};
resp.raw_app = raw_app;
resp.sandbox = policy.sandbox;
resp.app_path = Some(path.to_string());
resp.workspace_id = Some(w_id.to_string());
let resp = build_embed_token_response(
&db,
&w_id,
path,
raw_app,
&policy,
Some(&authed),
sdk_query.sdk_consent,
)
.await?;
Ok(Json(resp))
}
@@ -1854,6 +1995,7 @@ async fn create_app_internal<'a>(
// inside process_app_multipart!, so checking after this call would leave a
// denied app committed in the DB.
check_scopes(&authed, || format!("apps:write:{}", &app.path))?;
validate_frontend_sdk_scopes(&app.policy)?;
if *CLOUD_HOSTED {
let nb_apps =
sqlx::query_scalar!("SELECT COUNT(*) FROM app WHERE workspace_id = $1", &w_id)
@@ -2492,6 +2634,7 @@ async fn update_app_internal<'a>(
}
if let Some(mut npolicy) = ns.policy {
validate_frontend_sdk_scopes(&npolicy)?;
if matches!(npolicy.execution_mode, ExecutionMode::Anonymous) && !authed.is_admin {
// Restricted users may keep deploying an app that is already
// public, but flipping an app to anonymous (public) access is
@@ -4724,6 +4867,55 @@ mod embed_token_tests {
.includes(&required));
}
/// The raw-app frontend SDK token's confinement rests on one property: the
/// curated scope list contains no `apps:*` scope, so the (Apps-domain,
/// default-denied) mint endpoints are unreachable and a captured SDK token
/// cannot renew itself past its expiry. Lock that, plus the intended reach:
/// the viewer-permissioned surface each curated scope grants.
#[test]
fn frontend_sdk_scopes_reach_declared_domains_but_never_mint_routes() {
let scopes: Vec<String> = super::FRONTEND_SDK_ALLOWED_SCOPES
.iter()
.map(|s| s.to_string())
.collect();
let scopes = Some(scopes.as_slice());
let allowed = [
("/api/w/test/jobs/run/p/u/admin/script", "POST"),
("/api/w/test/jobs/run_wait_result/p/u/admin/script", "POST"),
("/api/w/test/jobs_u/completed/get_result/some-uuid", "GET"),
("/api/w/test/users/whoami", "GET"),
// Unlike the embed token, resource VALUE reads are intended here —
// the author declared it and the viewer consented.
("/api/w/test/resources/get_value/u/admin/r", "GET"),
("/api/w/test/variables/get_value/u/admin/v", "GET"),
];
for (path, method) in allowed {
assert!(
check_scopes_for_route(scopes, path, method).is_ok(),
"SDK token should allow {method} {path}"
);
}
let denied = [
// No apps scope → every mint endpoint (and the Apps domain at large)
// is unreachable: no self-renewal, no embed-token bootstrap.
("/api/w/test/apps_u/embed_token/secret", "GET"),
("/api/w/test/apps/embed_token/p/u/admin/app", "GET"),
("/api/w/test/apps_u/embed_token_by_custom_path/foo", "GET"),
("/api/w/test/apps/get/p/u/admin/app", "GET"),
// Read-level scopes must not grant writes.
("/api/w/test/resources/update/u/admin/r", "POST"),
("/api/w/test/variables/create", "POST"),
];
for (path, method) in denied {
assert!(
check_scopes_for_route(scopes, path, method).is_err(),
"SDK token should deny {method} {path}"
);
}
}
/// The token carries path-scoped `apps:run:<own path>` and `apps:read:<own path>`
/// (NOT unqualified `apps:run`). Every handler that resolves an app and acts on
/// its behalf re-checks the requested path via `ScopeDefinition::includes`, so the
@@ -21,6 +21,7 @@
type OnBehalfOfChoice
} from '$lib/components/OnBehalfOfSelector.svelte'
import { canUserBypassRuleKind, protectionRulesState } from '$lib/workspaceProtectionRules.svelte'
import { FRONTEND_SDK_SCOPES } from '$lib/components/raw_apps/sdkScopes'
const WM_DEPLOYERS_GROUP = 'wm_deployers'
@@ -329,6 +330,55 @@
{/if}
</div>
{#if rawApp}
<h2>Frontend API access</h2>
<div class="my-6">
<div class="text-xs text-secondary mb-3">
Let the app's frontend code call the Windmill API through the <code>windmill-client</code>
SDK, authenticated as <b>the viewer</b> (unlike runnables, which run on behalf of the publisher).
Each viewer is asked to approve the scopes below before the app runs. Grant only what the app needs:
its code or an XSS bug in it can use them as that viewer.
</div>
{#each FRONTEND_SDK_SCOPES as scope (scope.value)}
<div class="mb-2">
<Toggle
size="xs"
options={{ right: scope.label }}
checked={policy.frontend_sdk_scopes?.includes(scope.value) ?? false}
on:change={(e) => {
const current: string[] = policy.frontend_sdk_scopes ?? []
const next = e.detail
? [...current, scope.value]
: current.filter((s) => s !== scope.value)
// Keep the curated order so the consent banner and the stored
// consent compare stably across deploys.
const ordered = FRONTEND_SDK_SCOPES.map((s) => s.value).filter((s) => next.includes(s))
policy.frontend_sdk_scopes = ordered.length > 0 ? ordered : undefined
// Same as sandbox: a not-yet-deployed app has no row to PATCH, so the
// scopes ride along in the first deploy's policy instead.
if (savedApp && !newApp) {
setPublishState('Frontend API access updated')
}
}}
disabled={!savedApp}
/>
<div class="text-xs text-tertiary ml-10">{scope.description}</div>
</div>
{/each}
{#if newApp}
<div class="text-xs text-tertiary mt-1">Takes effect when you first deploy this app.</div>
{/if}
{#if policy.sandbox == true && policy.frontend_sdk_scopes?.length}
<div class="mt-2">
<Alert type="warning" title="Not available with sandbox isolation" size="xs">
A sandboxed app's bundle runs on an opaque origin and cannot use the SDK token. Turn off
sandbox isolation for these scopes to take effect.
</Alert>
</div>
{/if}
</div>
{/if}
{#if !hideSecretUrl}
<h2>Public URL</h2>
@@ -50,12 +50,13 @@
const hideRefreshBar = page.url.searchParams.get('hideRefreshBar') === 'true'
// Embedder side: mint a scoped embed token (by path) from the member's session.
async function fetchEmbedToken(): Promise<{ token?: string }> {
async function fetchEmbedToken(opts?: { sdkConsent?: boolean }): Promise<{ token?: string }> {
const headers: Record<string, string> = {}
if (typeof OpenAPI.TOKEN === 'string' && OpenAPI.TOKEN) {
headers['Authorization'] = `Bearer ${OpenAPI.TOKEN}`
}
const res = await fetch(`${OpenAPI.BASE}/w/${workspace}/apps/embed_token/p/${path}`, {
const consent = opts?.sdkConsent ? '?sdk_consent=true' : ''
const res = await fetch(`${OpenAPI.BASE}/w/${workspace}/apps/embed_token/p/${path}${consent}`, {
headers
})
if (!res.ok) {
@@ -32,6 +32,8 @@
import Login from '$lib/components/Login.svelte'
import { WINDMILL_RESERVED_QUERY_PARAMS } from '$lib/utils'
import { EMBED_NAV_CONTEXT_KEY, type EmbedNav } from '../types'
import RawAppSdkConsent from '$lib/components/raw_apps/RawAppSdkConsent.svelte'
import { hasStoredSdkConsent, storeSdkConsent } from '$lib/components/raw_apps/sdkScopes'
type EmbedToken = {
token?: string | null
@@ -39,6 +41,7 @@
sandbox?: boolean
app_path?: string | null
workspace_id?: string | null
sdk_scopes?: string[] | null
}
let {
@@ -48,8 +51,10 @@
viewerUrl
}: {
/** Embedder-side: validate access + mint the scoped token. Throws with a
* `.status` of 401 (login required) or 404 (not found). */
fetchEmbedToken: () => Promise<EmbedToken>
* `.status` of 401 (login required) or 404 (not found). Pass
* `sdkConsent` once the viewer accepted the frontend-SDK permission
* banner — only then does the backend mint the raw-app SDK token. */
fetchEmbedToken: (opts?: { sdkConsent?: boolean }) => Promise<EmbedToken>
/** Viewer-side: fired (once per received token) when the embed token is
* available, before the app renders. Use it to kick off data loading.
* `requestTokenRefresh` asks the embedder for a fresh token on a 401. */
@@ -188,10 +193,17 @@
}
// ---------------------------- embedder mode ----------------------------
let status: 'loading' | 'ready' | 'noPermission' | 'notExists' = $state('loading')
let status: 'loading' | 'ready' | 'noPermission' | 'notExists' | 'sdkConsent' = $state('loading')
let embedToken: string | null = $state(null)
let iframeEl: HTMLIFrameElement | undefined = $state(undefined)
// Raw-app frontend SDK (viewer-permissioned windmill-client): the scopes the
// app policy declares, and the viewer-identity token minted after consent.
// RawAppPreview reads the token via context and exposes it to the bundle as
// `window.process.env` so a bundled `windmill-client` auto-configures.
let sdkScopes: string[] | undefined = $state(undefined)
let sdkToken: string | undefined = $state(undefined)
// WIN-2006: publisher opted this app into sandbox isolation (alpha). When false
// (the default) the app runs same-origin with the viewer's full session — the
// pre-isolation behavior.
@@ -224,6 +236,14 @@
}
})
// Read by RawAppPreview: the consented viewer-scoped SDK token to expose to
// the bundle (unsandboxed raw apps only).
setContext('RAW_APP_SDK_TOKEN', {
get value() {
return sdkToken
}
})
function buildViewerUrl(): string {
// Default: embed the current route. The in-workspace viewer overrides this
// with a dedicated cookieless, chrome-less viewer route (`/app_embed`).
@@ -243,22 +263,61 @@
isRaw = resp.raw_app ?? false
appPath = resp.app_path ?? undefined
workspaceId = resp.workspace_id ?? undefined
status = 'ready'
if (unsandboxed || isRaw) {
// Render the app directly on this origin: same-origin when unsandboxed
// (the default), or a single opaque bundle iframe when it's a sandboxed
// raw app.
onViewerReady?.(undefined, requestTokenRefresh)
} else {
// Sandboxed low-code: hand the scoped token to the opaque viewer iframe.
postTokenToIframe()
// SDK tokens only exist for unsandboxed raw apps: the sandboxed (opaque
// origin) bundle can't reach the API cross-origin with this credential yet.
sdkScopes =
resp.raw_app && !resp.sandbox && resp.sdk_scopes?.length ? resp.sdk_scopes : undefined
if (sdkScopes) {
if (!hasStoredSdkConsent(workspaceId ?? '', appPath ?? '', sdkScopes)) {
// Block the app render behind the permission banner: the app's own
// code must not run before the viewer decided (it runs same-origin).
status = 'sdkConsent'
return
}
await mintSdkToken()
}
finishReady()
} catch (e: any) {
status = e?.status === 401 ? 'noPermission' : 'notExists'
}
}
function finishReady() {
status = 'ready'
if (unsandboxed || isRaw) {
// Render the app directly on this origin: same-origin when unsandboxed
// (the default), or a single opaque bundle iframe when it's a sandboxed
// raw app.
onViewerReady?.(undefined, requestTokenRefresh)
} else {
// Sandboxed low-code: hand the scoped token to the opaque viewer iframe.
postTokenToIframe()
}
}
/** Mint the viewer-scoped SDK token (consent already given). Tolerant: a
* failed mint (e.g. a scope-restricted caller session that can't satisfy the
* declared scopes) must not block the app render — the bundle then simply
* gets no credential and its SDK calls fail with 401. */
async function mintSdkToken() {
try {
const resp = await fetchEmbedToken({ sdkConsent: true })
sdkToken = resp.token ?? undefined
} catch (e) {
console.warn('Failed to mint the frontend SDK token', e)
sdkToken = undefined
}
}
async function onSdkConsentContinue(dontAskAgain: boolean) {
if (dontAskAgain) {
storeSdkConsent(workspaceId ?? '', appPath ?? '', sdkScopes ?? [])
}
status = 'loading'
await mintSdkToken()
finishReady()
}
function postTokenToIframe() {
// The iframe is an opaque origin ("null"), which cannot be named as a
// targetOrigin, so we use '*'. This only relaxes the receiver-origin
@@ -390,6 +449,11 @@
<a href={base}>Go to Windmill</a>
</Alert>
</div>
{:else if status === 'sdkConsent'}
<!-- Raw-app frontend SDK: the app code would run same-origin with a
viewer-scoped token, so nothing renders until the viewer accepts the
declared permissions. -->
<RawAppSdkConsent scopes={sdkScopes ?? []} onContinue={onSdkConsentContinue} />
{:else if status === 'noPermission'}
<!-- Login happens here, on the embedder (main) window, so the session cookie
is set on the main origin only and never reaches the opaque iframe. -->
@@ -36,6 +36,11 @@
// same-origin with full access (the default); otherwise the opaque-origin sandbox.
const unsandboxedCtx = getContext<{ value: boolean }>('IS_APP_UNSANDBOXED')
let unsandboxed = $derived(unsandboxedCtx?.value ?? false)
// Viewer-scoped frontend SDK token (PublicAppFrame mints it after the viewer
// consents to the app's declared scopes). Exposed to the bundle as
// `window.process.env` so a bundled `windmill-client` auto-configures.
const sdkTokenCtx = getContext<{ value: string | undefined }>('RAW_APP_SDK_TOKEN')
// Unsandboxed (the default) must match the pre-isolation viewer exactly: NO
// sandbox attribute (a same-origin blob with full session — an attribute would
// only break leftover features like unsandboxed popups for OAuth flows, while
@@ -69,12 +74,16 @@
// Always pass the wrapper object — pre-sandbox bundles rely on
// `window.ctx.workspace` even for anonymous viewers (ctx.ctx undefined).
const u = untrack(() => user)
const sdkToken = sdkTokenCtx?.value
const html = unsandboxedRawAppHtml(
workspace,
secret,
{ ctx: u, workspace },
window.location.origin,
window.location.hash || ''
window.location.hash || '',
sdkToken
? { WM_TOKEN: sdkToken, BASE_URL: window.location.origin, WM_WORKSPACE: workspace }
: undefined
)
return URL.createObjectURL(new Blob([html], { type: 'text/html' }))
}
@@ -0,0 +1,45 @@
<script lang="ts">
import { Button } from '$lib/components/common'
import Toggle from '$lib/components/Toggle.svelte'
import { ShieldCheck } from 'lucide-svelte'
import { sdkScopeDescription, sdkScopeLabel } from './sdkScopes'
let {
scopes,
onContinue
}: {
/** Scopes the app policy declares for its frontend SDK token. */
scopes: string[]
/** Fired when the viewer accepts; `dontAskAgain` persists the consent for
* this app path so the banner is skipped until the declared scopes grow. */
onContinue: (dontAskAgain: boolean) => void
} = $props()
let dontAskAgain = $state(false)
</script>
<div class="px-4 mt-20 max-w-xl mx-auto">
<div class="border rounded-md p-6 bg-surface shadow-sm flex flex-col gap-4">
<div class="flex items-center gap-2">
<ShieldCheck size={20} class="text-primary" />
<div class="text-lg font-semibold">This app requires the following permissions</div>
</div>
<p class="text-sm text-secondary">
The app's code will be able to call the Windmill API on your behalf, restricted to:
</p>
<ul class="flex flex-col gap-2">
{#each scopes as scope (scope)}
<li class="text-sm">
<span class="font-medium">{sdkScopeLabel(scope)}</span>
{#if sdkScopeDescription(scope)}
<span class="text-tertiary">{sdkScopeDescription(scope)}</span>
{/if}
</li>
{/each}
</ul>
<div class="flex items-center justify-between gap-4 pt-2">
<Toggle bind:checked={dontAskAgain} size="xs" options={{ right: 'Do not ask again' }} />
<Button variant="accent" onclick={() => onContinue(dontAskAgain)}>Continue</Button>
</div>
</div>
</div>
@@ -0,0 +1,61 @@
// Frontend-SDK permissions for raw apps: the curated scopes an app author may
// declare in `policy.frontend_sdk_scopes` (mirrors FRONTEND_SDK_ALLOWED_SCOPES
// in the backend `apps.rs` — both lists must stay in sync), plus the viewer-side
// consent persistence for the permission banner.
export const FRONTEND_SDK_SCOPES: { value: string; label: string; description: string }[] = [
{
value: 'jobs:run',
label: 'Run scripts and flows',
description: 'Execute any script or flow the viewer can run, and read jobs'
},
{
value: 'jobs:read',
label: 'Read jobs and results',
description: 'Poll jobs by id and read their results'
},
{
value: 'users:read',
label: 'Read your identity',
description: 'Read the viewer username and email (whoami)'
},
{
value: 'resources:read',
label: 'Read resources',
description: 'Read resource values the viewer can access, including credentials'
},
{
value: 'variables:read',
label: 'Read variables',
description: 'Read variable values the viewer can access'
}
]
export function sdkScopeLabel(scope: string): string {
return FRONTEND_SDK_SCOPES.find((s) => s.value === scope)?.label ?? scope
}
export function sdkScopeDescription(scope: string): string | undefined {
return FRONTEND_SDK_SCOPES.find((s) => s.value === scope)?.description
}
function sdkConsentKey(workspace: string, path: string): string {
return `wm_sdk_consent:${workspace}:${path}`
}
/** True when a previously stored "do not ask again" consent covers every
* declared scope. A later deploy that adds scopes re-triggers the banner. */
export function hasStoredSdkConsent(workspace: string, path: string, scopes: string[]): boolean {
try {
const stored = JSON.parse(localStorage.getItem(sdkConsentKey(workspace, path)) ?? 'null')
return Array.isArray(stored) && scopes.every((s) => stored.includes(s))
} catch (_) {
return false
}
}
export function storeSdkConsent(workspace: string, path: string, scopes: string[]): void {
try {
localStorage.setItem(sdkConsentKey(workspace, path), JSON.stringify(scopes))
} catch (_) {}
}
@@ -121,7 +121,8 @@ export const react18Template = {
'/package.json': `{
"dependencies": {
"react": "18.3.1",
"react-dom": "18.3.1"
"react-dom": "18.3.1",
"windmill-client": "^1"
},
"devDependencies": {
"@types/react-dom": "^19.0.0",
@@ -149,7 +150,8 @@ export const vueTemplate = {
'/package.json': `{
"dependencies": {
"core-js": "3.26.1",
"vue": "3.5.13"
"vue": "3.5.13",
"windmill-client": "^1"
}
}`
}
@@ -163,7 +163,13 @@ export function unsandboxedRawAppHtml(
secret: string,
ctx: any,
baseUrl: string,
initialHash: string
initialHash: string,
/** When the viewer consented to the app's declared frontend-SDK scopes, the
* minted viewer token + connection settings, exposed as `window.process.env`
* (WM_TOKEN/BASE_URL/WM_WORKSPACE) BEFORE the bundle runs so a bundled
* `windmill-client` auto-configures at module load. Absent otherwise — the
* bundle then receives no credential and no API base. */
sdkEnv?: Record<string, string>
) {
return `<!DOCTYPE html>
<html>
@@ -172,6 +178,7 @@ export function unsandboxedRawAppHtml(
<title>App</title>
<link rel="stylesheet" href="${baseUrl}/api/w/${workspace}/apps_u/get_data/v/${secret}.css" />
<script>
${sdkEnv ? `window.process = { env: ${JSON.stringify(sdkEnv).replace(/</g, '\\u003c')} };` : ''}
window.ctx = ${ctx ? JSON.stringify(ctx) : 'undefined'};
(function () {
// Keep the parent URL hash in sync for shareable URLs.
+3 -2
View File
@@ -61,7 +61,7 @@
// Embedder side: validate access (main session cookie or shared JWT) and mint
// a scoped embed token for the opaque iframe (WIN-2006).
async function fetchEmbedToken(): Promise<{ token?: string }> {
async function fetchEmbedToken(opts?: { sdkConsent?: boolean }): Promise<{ token?: string }> {
if (parsedCustomPath.jwt) {
OpenAPI.TOKEN = 'jwt_ext_' + parsedCustomPath.jwt
}
@@ -69,8 +69,9 @@
if (typeof OpenAPI.TOKEN === 'string' && OpenAPI.TOKEN) {
headers['Authorization'] = `Bearer ${OpenAPI.TOKEN}`
}
const consent = opts?.sdkConsent ? '?sdk_consent=true' : ''
const res = await fetch(
`${OpenAPI.BASE}/apps_u/embed_token_by_custom_path/${parsedCustomPath.path}`,
`${OpenAPI.BASE}/apps_u/embed_token_by_custom_path/${parsedCustomPath.path}${consent}`,
{ headers }
)
if (!res.ok) {
@@ -29,12 +29,13 @@
// Embedder side: the logged-in member's session mints a scoped embed token for
// the opaque iframe, isolating the in-workspace app from their full session.
async function fetchEmbedToken(): Promise<{ token?: string }> {
async function fetchEmbedToken(opts?: { sdkConsent?: boolean }): Promise<{ token?: string }> {
const headers: Record<string, string> = {}
if (typeof OpenAPI.TOKEN === 'string' && OpenAPI.TOKEN) {
headers['Authorization'] = `Bearer ${OpenAPI.TOKEN}`
}
const res = await fetch(`${OpenAPI.BASE}/w/${workspace}/apps/embed_token/p/${path}`, {
const consent = opts?.sdkConsent ? '?sdk_consent=true' : ''
const res = await fetch(`${OpenAPI.BASE}/w/${workspace}/apps/embed_token/p/${path}${consent}`, {
headers
})
if (!res.ok) {
@@ -41,7 +41,7 @@
// Embedder side: validate access (using the main session cookie or the shared
// JWT) and mint a scoped embed token for the opaque iframe (WIN-2006).
async function fetchEmbedToken(): Promise<{ token?: string }> {
async function fetchEmbedToken(opts?: { sdkConsent?: boolean }): Promise<{ token?: string }> {
if (parsedSecret.jwt) {
OpenAPI.TOKEN = 'jwt_ext_' + parsedSecret.jwt
}
@@ -49,8 +49,9 @@
if (typeof OpenAPI.TOKEN === 'string' && OpenAPI.TOKEN) {
headers['Authorization'] = `Bearer ${OpenAPI.TOKEN}`
}
const consent = opts?.sdkConsent ? '?sdk_consent=true' : ''
const res = await fetch(
`${OpenAPI.BASE}/w/${workspace}/apps_u/embed_token/${parsedSecret.secret}`,
`${OpenAPI.BASE}/w/${workspace}/apps_u/embed_token/${parsedSecret.secret}${consent}`,
{ headers }
)
if (!res.ok) {