mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-10-13 08:02:47 +00:00
feat: ask before minting the frontend sdk token in the raw app editor (#11604)
* feat: ask before minting the frontend sdk token in the raw app editor * fix: record editor sdk consent on continue so a late mint cannot override a decline Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix: only the newest editor sdk mint may start the preview Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
f40eea2a5f
commit
6e948b07d1
@@ -35,7 +35,11 @@
|
||||
import { EMBED_NAV_CONTEXT_KEY, type EmbedNav } from '../types'
|
||||
import { loadAppPreview } from './loadAppPreview'
|
||||
import RawAppSdkConsent from '$lib/components/raw_apps/RawAppSdkConsent.svelte'
|
||||
import { hasStoredSdkConsent, storeSdkConsent } from '$lib/components/raw_apps/sdkScopes'
|
||||
import {
|
||||
hasStoredSdkConsent,
|
||||
sdkConsentCovers,
|
||||
storeSdkConsent
|
||||
} from '$lib/components/raw_apps/sdkScopes'
|
||||
|
||||
type EmbedToken = {
|
||||
token?: string | null
|
||||
@@ -388,7 +392,7 @@
|
||||
appPath = resp.app_path ?? appPath
|
||||
workspaceId = resp.workspace_id ?? workspaceId
|
||||
const granted = resp.sdk_scopes ?? []
|
||||
if (!granted.every((s) => approved.includes(s))) {
|
||||
if (!sdkConsentCovers(approved, granted)) {
|
||||
// The app was redeployed with more scopes between the prompt and the
|
||||
// mint. Never inject a token carrying scopes the viewer wasn't shown:
|
||||
// drop it and ask again with the new set.
|
||||
|
||||
@@ -50,7 +50,14 @@
|
||||
import { createAppSelectedContext, type AppCodeSelectionElement } from '../copilot/chat/context'
|
||||
import { captureScale, MAX_IMAGE_EDGE } from '../copilot/chat/imageUtils'
|
||||
import { rawAppLintStore } from './lintStore'
|
||||
import { dbSchemas } from '$lib/stores'
|
||||
import { dbSchemas, userStore } from '$lib/stores'
|
||||
import RawAppSdkConsent from './RawAppSdkConsent.svelte'
|
||||
import {
|
||||
hasStoredSdkConsent,
|
||||
movePreviewSdkConsent,
|
||||
sdkConsentCovers,
|
||||
storeSdkConsent
|
||||
} from './sdkScopes'
|
||||
import {
|
||||
MousePointerSquareDashed,
|
||||
RefreshCw,
|
||||
@@ -414,6 +421,9 @@
|
||||
// carry our origin, so they travel wrapped: listeners that trust a same-origin
|
||||
// message must not see the isolated app's as their own.
|
||||
let previewControllerUrl: string | undefined = undefined
|
||||
// Editor-to-controller only, never relayed to the app: the consent prompt lives in
|
||||
// the editor, so the pop-out says why it is blank.
|
||||
const PREVIEW_NOTICE = 'wm:previewNotice'
|
||||
onDestroy(() => {
|
||||
if (previewControllerUrl) URL.revokeObjectURL(previewControllerUrl)
|
||||
})
|
||||
@@ -423,14 +433,19 @@
|
||||
[
|
||||
`<!DOCTYPE html><html><head><meta charset="utf-8"><title>App preview</title>
|
||||
<link rel="icon" href="${window.location.origin}/logo.svg">
|
||||
<style>html,body{margin:0;height:100%}iframe{display:block;border:0;width:100%;height:100%}
|
||||
<style>html,body{margin:0;height:100%}iframe{display:block;border:0;width:100%;height:100%}
|
||||
#n{position:fixed;inset:0;display:flex;align-items:center;justify-content:center;padding:16px;text-align:center;font:14px system-ui,sans-serif;color-scheme:light dark;background:Canvas;color:CanvasText}#n[hidden]{display:none}
|
||||
</style>
|
||||
<script>
|
||||
addEventListener('message', function (e) {
|
||||
var frame = document.querySelector('iframe').contentWindow
|
||||
if (e.source === frame && opener) opener.postMessage({ type: '${RAW_APP_PREVIEW_RELAY}', data: e.data }, location.origin, e.ports)
|
||||
if (e.source === frame && opener) opener.postMessage({ type: '${RAW_APP_PREVIEW_RELAY}', data: e.data }, location.origin, e.ports)
|
||||
else if (e.source === opener && e.data && e.data.type === '${PREVIEW_NOTICE}') {
|
||||
var n = document.getElementById('n')
|
||||
n.textContent = e.data.text || ''
|
||||
n.hidden = !e.data.text
|
||||
} else if (e.source === opener) frame.postMessage(e.data, '*')
|
||||
})
|
||||
})
|
||||
<\/script></head><body><div id="n" hidden></div><iframe src="${window.location.origin}${sandboxedShellUrl}" sandbox="${RAW_APP_SANDBOX_FLAGS}" referrerpolicy="no-referrer"></iframe></body></html>`
|
||||
],
|
||||
{ type: 'text/html' }
|
||||
@@ -1636,15 +1651,22 @@ addEventListener('message', function (e) {
|
||||
// every build, tokenless included: the shell reuses one realm across builds, so
|
||||
// omitting it would leave an old token in place.
|
||||
type PreviewSdk = { token: string; baseUrl: string; workspace: string }
|
||||
let previewSdk: PreviewSdk | undefined = undefined
|
||||
// Identifies the request whose answer is still wanted. Toggling scopes starts a
|
||||
// new mint while an older one is in flight, and an out-of-order answer would
|
||||
// otherwise hand the preview the wrong scope set — or restore a token after all
|
||||
let previewSdk: PreviewSdk | undefined = undefined
|
||||
// The setup the preview last started from; an unchanged one is not redone.
|
||||
let previewSdkKey: string | undefined = undefined
|
||||
// Bumped on every new setup: only the newest mint's answer is used. Toggling scopes
|
||||
// starts a new mint while an older one is in flight, and toggling back recreates the
|
||||
// same key, so a key check alone would let an out-of-order answer hand the preview
|
||||
// the wrong scope set, restore a removed token, or start a preview since declined.
|
||||
let previewSdkGeneration = 0
|
||||
// Holds the build back between dropping a credential and settling its
|
||||
// replacement, so the app mounts once — with the final credential — instead of
|
||||
// once tokenless and again tokenful, running mount-time side effects twice.
|
||||
let previewSdkPending = $state(false)
|
||||
// Holds the app back (with `previewSdkPending`) until the viewer answers.
|
||||
let sdkPrompt = $state<{ scopes: string[]; ws: string; declined: boolean } | undefined>(undefined)
|
||||
// What Continue granted in this editor session, kept the way a loaded deployed
|
||||
// app keeps its token: only a scope beyond it asks again.
|
||||
let sessionSdkConsent: { ws: string; scopes: string[] } | undefined = undefined
|
||||
|
||||
/** Discard the running app. The shell resets the DOM but keeps the JavaScript
|
||||
@@ -1690,6 +1712,7 @@ addEventListener('message', function (e) {
|
||||
// `sandboxed` too: toggling it swaps the shell, which is a new realm.
|
||||
const key = `${sandboxed}|${ws ?? ''}|${scopes.join(',')}`
|
||||
if (key === previewSdkKey) return
|
||||
previewSdkKey = key
|
||||
previewSdkGeneration++
|
||||
// Drop the old credential before asking for its replacement, never after:
|
||||
// a mint is asynchronous, and until it answers the running preview — and
|
||||
@@ -1697,24 +1720,68 @@ addEventListener('message', function (e) {
|
||||
// a token for the workspace we just left.
|
||||
const willMint = scopes.length > 0 && !!ws
|
||||
previewSdkPending = willMint
|
||||
previewSdk = undefined
|
||||
sdkPrompt = undefined
|
||||
restartPreviewRealm()
|
||||
restartPreviewRealm()
|
||||
if (!willMint) return
|
||||
// Ask before the app's code runs, as the deployed app does: the token acts
|
||||
// as whoever has the editor open.
|
||||
const granted =
|
||||
(sessionSdkConsent?.ws === ws && sdkConsentCovers(sessionSdkConsent.scopes, scopes)) ||
|
||||
untrack(() => hasStoredSdkConsent($userStore?.email ?? '', ws, path, scopes, true))
|
||||
if (granted) {
|
||||
mintPreviewSdkToken(scopes, ws)
|
||||
} else {
|
||||
sdkPrompt = { scopes, ws, declined: false }
|
||||
if (externalPreviewWindow && !externalPreviewWindow.closed) {
|
||||
untrack(() => select({ kind: 'preview' }))
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
$effect(() => {
|
||||
sdkPrompt?.declined
|
||||
untrack(syncExternalPreviewNotice)
|
||||
})
|
||||
|
||||
function syncExternalPreviewNotice() {
|
||||
if (!sandboxed || !externalPreviewWindow || externalPreviewWindow.closed) return
|
||||
const text = !sdkPrompt
|
||||
? ''
|
||||
: sdkPrompt.declined
|
||||
? 'The preview is blocked until you grant the permissions this app declares. You can review them in the editor window.'
|
||||
: 'This app is waiting for you to approve its permissions in the editor window.'
|
||||
externalPreviewWindow.postMessage({ type: PREVIEW_NOTICE, text }, window.location.origin)
|
||||
}
|
||||
|
||||
async function onSdkConsentContinue(dontAskAgain: boolean) {
|
||||
if (!sdkPrompt) return
|
||||
const { scopes, ws } = sdkPrompt
|
||||
sdkPrompt = undefined
|
||||
// Recorded on the click, as the deployed app does, so a scope edit reverted while
|
||||
// this mint is in flight is still approved and does not ask again.
|
||||
sessionSdkConsent = { ws, scopes }
|
||||
if (!(await mintPreviewSdkToken(scopes, ws))) return
|
||||
const viewer = $userStore?.email
|
||||
if (dontAskAgain && viewer) storeSdkConsent(viewer, ws, path, scopes, true)
|
||||
}
|
||||
|
||||
async function mintPreviewSdkToken(scopes: string[], ws: string) {
|
||||
const generation = previewSdkGeneration
|
||||
try {
|
||||
const token = await AppService.mintPreviewSdkToken({
|
||||
workspace: ws,
|
||||
requestBody: { path, scopes }
|
||||
})
|
||||
})
|
||||
if (generation !== previewSdkGeneration) return false
|
||||
applyPreviewSdk({ token, baseUrl: window.location.origin, workspace: ws })
|
||||
return true
|
||||
} catch (e) {
|
||||
// Already tokenless — the effect cleared the env before calling us — so
|
||||
// this only releases the build. The key stays set, so a failed mint is not
|
||||
// retried until the scopes or workspace actually change.
|
||||
console.warn('Could not mint a preview SDK token', e)
|
||||
console.warn('Could not mint a preview SDK token', e)
|
||||
if (generation === previewSdkGeneration) applyPreviewSdk(undefined)
|
||||
return false
|
||||
}
|
||||
}
|
||||
@@ -1762,6 +1829,7 @@ addEventListener('message', function (e) {
|
||||
// it always matches the editor's current state. Plain rebuilds use
|
||||
// `syncExternalPreview` alone (the theme hasn't changed).
|
||||
function feedExternalPreview() {
|
||||
postToExternalPreview({ type: 'setDarkMode', dark: darkMode, variant: darkVariant })
|
||||
syncExternalPreviewNotice()
|
||||
syncExternalPreview()
|
||||
}
|
||||
@@ -1863,13 +1931,20 @@ addEventListener('message', function (e) {
|
||||
|
||||
const requestRuntimeLogs: RawAppRuntimeLogRequester = async (limit) => {
|
||||
await buildTracker.wait()
|
||||
if (editorDestroyed) {
|
||||
if (editorDestroyed) {
|
||||
return {
|
||||
entries: undefined,
|
||||
buildError: undefined,
|
||||
buildPending: false,
|
||||
buildLogs: '',
|
||||
sdkConsentPending: false
|
||||
}
|
||||
}
|
||||
return {
|
||||
entries: await requestPreviewConsoleLogs(limit),
|
||||
buildError,
|
||||
buildPending: buildTracker.pending,
|
||||
buildPending: buildTracker.pending,
|
||||
buildLogs: logs,
|
||||
sdkConsentPending: sdkPrompt !== undefined
|
||||
}
|
||||
}
|
||||
@@ -2467,7 +2542,12 @@ addEventListener('message', function (e) {
|
||||
{version}
|
||||
{onTakeLatest}
|
||||
{draftBaseVersion}
|
||||
{onRestore}
|
||||
{onRestore}
|
||||
onSavedNewAppPath={(newAppPath) => {
|
||||
if (opWorkspace && $userStore?.email) {
|
||||
movePreviewSdkConsent($userStore.email, opWorkspace, path, newAppPath)
|
||||
}
|
||||
onSavedNewAppPath?.(newAppPath)
|
||||
}}
|
||||
{policy}
|
||||
{diffDrawer}
|
||||
@@ -2777,7 +2857,32 @@ addEventListener('message', function (e) {
|
||||
class="w-full flex-1 block"
|
||||
></iframe>
|
||||
{/key}
|
||||
{/if}
|
||||
{/if}
|
||||
{#if sdkPrompt}
|
||||
<!-- top-10 leaves the tab bar usable. -->
|
||||
<div class="absolute top-10 inset-x-0 bottom-0 z-30 bg-surface overflow-auto">
|
||||
{#if sdkPrompt.declined}
|
||||
<div class="px-4 mt-20 max-w-xl mx-auto flex flex-col items-start gap-4">
|
||||
<p class="text-sm text-secondary">
|
||||
The preview is blocked until you grant the permissions this app declares.
|
||||
</p>
|
||||
<Button
|
||||
variant="default"
|
||||
unifiedSize="md"
|
||||
onclick={() => sdkPrompt && (sdkPrompt.declined = false)}
|
||||
>
|
||||
Review permissions
|
||||
</Button>
|
||||
</div>
|
||||
{:else}
|
||||
<RawAppSdkConsent
|
||||
scopes={sdkPrompt.scopes}
|
||||
declineLabel="Decline"
|
||||
onContinue={onSdkConsentContinue}
|
||||
onDecline={() => sdkPrompt && (sdkPrompt.declined = true)}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
{:else if buildError}
|
||||
<!-- top-12 clears the tab bar; `before:bg-surface` backs the
|
||||
Alert's translucent red; `isolate` pins the pseudo's stacking context. -->
|
||||
|
||||
@@ -7,16 +7,19 @@
|
||||
let {
|
||||
scopes,
|
||||
onContinue,
|
||||
onDecline
|
||||
onDecline,
|
||||
declineLabel = 'Open without granting'
|
||||
}: {
|
||||
/** 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 prompt is skipped until the declared scopes grow. */
|
||||
onContinue: (dontAskAgain: boolean) => void
|
||||
/** Fired when the viewer declines: the app still renders, its frontend code
|
||||
* just gets no token (SDK calls fail). */
|
||||
/** Fired when the viewer declines; no token is minted. */
|
||||
onDecline: () => void
|
||||
/** Says what declining does where the prompt is shown: the deployed app
|
||||
* still renders tokenless, the editor preview stays blocked. */
|
||||
declineLabel?: string
|
||||
} = $props()
|
||||
|
||||
let dontAskAgain = $state(false)
|
||||
@@ -34,17 +37,17 @@
|
||||
<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}
|
||||
<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' }} />
|
||||
<div class="flex items-center gap-2">
|
||||
<Button variant="default" onclick={onDecline}>Open without granting</Button>
|
||||
<Button variant="default" onclick={onDecline}>{declineLabel}</Button>
|
||||
<Button variant="accent" onclick={() => onContinue(dontAskAgain)}>Continue</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { beforeEach, describe, expect, it } from 'vitest'
|
||||
|
||||
import { hasStoredSdkConsent, storeSdkConsent } from './sdkScopes'
|
||||
import { hasStoredSdkConsent, movePreviewSdkConsent, storeSdkConsent } from './sdkScopes'
|
||||
|
||||
describe('stored frontend SDK consent', () => {
|
||||
beforeEach(() => localStorage.clear())
|
||||
@@ -20,6 +20,17 @@ describe('stored frontend SDK consent', () => {
|
||||
expect(hasStoredSdkConsent('a@w.dev', 'other', 'u/a/app', ['users:read'])).toBe(false)
|
||||
})
|
||||
|
||||
it('keeps the editor preview and the deployed app apart, and moves preview consent', () => {
|
||||
storeSdkConsent('a@w.dev', 'ws', 'u/a/app', ['users:read'])
|
||||
expect(hasStoredSdkConsent('a@w.dev', 'ws', 'u/a/app', ['users:read'], true)).toBe(false)
|
||||
storeSdkConsent('a@w.dev', 'ws', 'u/a/draft_1', ['users:read'], true)
|
||||
expect(hasStoredSdkConsent('a@w.dev', 'ws', 'u/a/draft_1', ['users:read'])).toBe(false)
|
||||
|
||||
movePreviewSdkConsent('a@w.dev', 'ws', 'u/a/draft_1', 'u/a/real')
|
||||
expect(hasStoredSdkConsent('a@w.dev', 'ws', 'u/a/real', ['users:read'], true)).toBe(true)
|
||||
expect(hasStoredSdkConsent('a@w.dev', 'ws', 'u/a/draft_1', ['users:read'], true)).toBe(false)
|
||||
})
|
||||
|
||||
it('treats unreadable storage as no consent', () => {
|
||||
localStorage.setItem('wm_sdk_consent:a@w.dev:ws:u/a/app', 'not json')
|
||||
expect(hasStoredSdkConsent('a@w.dev', 'ws', 'u/a/app', ['users:read'])).toBe(false)
|
||||
|
||||
@@ -52,8 +52,17 @@ export function sdkScopeDescription(scope: string): string | undefined {
|
||||
// Keyed by viewer as well as app: this localStorage lives on the shared embedder
|
||||
// origin, so without the viewer one person's "do not ask again" would silently
|
||||
// suppress the prompt for the next person to use the same browser profile.
|
||||
function sdkConsentKey(viewer: string, workspace: string, path: string): string {
|
||||
return `wm_sdk_consent:${viewer}:${workspace}:${path}`
|
||||
// `preview` keeps the editor's consent apart from the deployed app's: the editor
|
||||
// runs a draft nobody may have reviewed, so trust given to one is not trust given
|
||||
// to the other.
|
||||
function sdkConsentKey(viewer: string, workspace: string, path: string, preview: boolean): string {
|
||||
return `wm_sdk_consent:${preview ? 'p:' : ''}${viewer}:${workspace}:${path}`
|
||||
}
|
||||
|
||||
/** True when `approved` (a consent the viewer gave) covers every scope the app now
|
||||
* declares. Fewer scopes need no new consent; one more does. */
|
||||
export function sdkConsentCovers(approved: unknown, scopes: string[]): boolean {
|
||||
return Array.isArray(approved) && scopes.every((s) => approved.includes(s))
|
||||
}
|
||||
|
||||
/** True when a previously stored "do not ask again" consent covers every
|
||||
@@ -62,13 +71,14 @@ export function hasStoredSdkConsent(
|
||||
viewer: string,
|
||||
workspace: string,
|
||||
path: string,
|
||||
scopes: string[]
|
||||
scopes: string[],
|
||||
preview = false
|
||||
): boolean {
|
||||
try {
|
||||
const stored = JSON.parse(
|
||||
localStorage.getItem(sdkConsentKey(viewer, workspace, path)) ?? 'null'
|
||||
localStorage.getItem(sdkConsentKey(viewer, workspace, path, preview)) ?? 'null'
|
||||
)
|
||||
return Array.isArray(stored) && scopes.every((s) => stored.includes(s))
|
||||
return sdkConsentCovers(stored, scopes)
|
||||
} catch (_) {
|
||||
return false
|
||||
}
|
||||
@@ -78,9 +88,29 @@ export function storeSdkConsent(
|
||||
viewer: string,
|
||||
workspace: string,
|
||||
path: string,
|
||||
scopes: string[]
|
||||
scopes: string[],
|
||||
preview = false
|
||||
): void {
|
||||
try {
|
||||
localStorage.setItem(sdkConsentKey(viewer, workspace, path), JSON.stringify(scopes))
|
||||
localStorage.setItem(sdkConsentKey(viewer, workspace, path, preview), JSON.stringify(scopes))
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
/** Follows the editor's app to its new path (first deploy of a draft, or a rename),
|
||||
* so a stored "do not ask again" consent neither is asked again nor stays behind
|
||||
* under the old path. A plain Continue is not stored and does not carry over. */
|
||||
export function movePreviewSdkConsent(
|
||||
viewer: string,
|
||||
workspace: string,
|
||||
from: string,
|
||||
to: string
|
||||
): void {
|
||||
if (from === to) return
|
||||
try {
|
||||
const fromKey = sdkConsentKey(viewer, workspace, from, true)
|
||||
const stored = localStorage.getItem(fromKey)
|
||||
if (stored === null) return
|
||||
localStorage.setItem(sdkConsentKey(viewer, workspace, to, true), stored)
|
||||
localStorage.removeItem(fromKey)
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
@@ -92,6 +92,8 @@ export type RawAppPreviewLogs = {
|
||||
/** Files were sent to the UI Builder and its build has not reported back in time. */
|
||||
buildPending: boolean
|
||||
buildLogs: string
|
||||
/** The app is held back until the user answers its SDK permission prompt, so it is not running. */
|
||||
sdkConsentPending: boolean
|
||||
}
|
||||
export type RawAppRuntimeLogRequester = (limit: number) => Promise<RawAppPreviewLogs>
|
||||
|
||||
|
||||
@@ -1459,6 +1459,14 @@ setGetRuntimeLogsHandler(async ({ sessionId: callerSessionId, limit, appPath })
|
||||
toolResult: report
|
||||
}
|
||||
}
|
||||
if (previewLogs?.sdkConsentPending) {
|
||||
return {
|
||||
aiResult:
|
||||
'The app built, but the preview is waiting for the user to approve the permissions the app declares in policy.frontend_sdk_scopes, so its code is not running. This is not a bug in the app: do not change the code. Ask the user to approve the permissions in the preview, then call get_app_runtime_logs again.',
|
||||
uiMessage: 'Waiting for permission approval',
|
||||
toolResult: 'Waiting for permission approval'
|
||||
}
|
||||
}
|
||||
const entries = previewLogs?.entries
|
||||
if (entries === undefined) {
|
||||
return {
|
||||
|
||||
Reference in New Issue
Block a user