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:
AlexRV12
2026-10-08 08:40:45 +02:00
committed by GitHub
co-authored by Claude Opus 5.5
parent f40eea2a5f
commit 6e948b07d1
7 changed files with 197 additions and 34 deletions
@@ -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 {