refactor: derive an app's address beside its policy, not inside it

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EYWxvwmteVyzcWenJfbnzs
This commit is contained in:
Ruben Fiszel
2026-09-13 19:19:17 +02:00
co-authored by Claude Opus 5
parent 7af10b05e0
commit 640da55702
20 changed files with 103 additions and 87 deletions
+10 -5
View File
@@ -447,9 +447,13 @@ async fn test_preserve_on_behalf_of(db: Pool<Postgres>) -> anyhow::Result<()> {
.await?;
let returned: serde_json::Value = resp.json().await?;
assert_eq!(
returned["policy"]["on_behalf_of_email"].as_str(),
returned["on_behalf_of_email"].as_str(),
Some("original@windmill.dev"),
"the response derives the address from the principal"
"the response derives the address from the principal, beside the policy"
);
assert!(
returned["policy"]["on_behalf_of_email"].is_null(),
"and never inside it, where a client echoing the policy back would resend it"
);
// ========================================
@@ -2951,9 +2955,9 @@ async fn test_app_draft_read_does_not_derive_an_address(db: Pool<Postgres>) -> a
let body: serde_json::Value = resp.json().await?;
assert_eq!(
body["policy"]["on_behalf_of_email"].as_str(),
body["on_behalf_of_email"].as_str(),
Some("original@windmill.dev"),
"the deployed policy carries the address derived from its principal"
"the deployed app carries the address derived from its principal"
);
// Asserted before the absence below, so that a response carrying no draft at all cannot
// pass this test by having nothing to derive from.
@@ -2963,7 +2967,8 @@ async fn test_app_draft_read_does_not_derive_an_address(db: Pool<Postgres>) -> a
"the draft is returned, principal and all: {body}"
);
assert!(
body["draft"]["policy"]["on_behalf_of_email"].is_null(),
body["draft"]["on_behalf_of_email"].is_null()
&& body["draft"]["policy"]["on_behalf_of_email"].is_null(),
"the draft's principal must not be resolved: {}",
body["draft"]
);
+4 -4
View File
@@ -33947,10 +33947,6 @@ components:
on_behalf_of:
type: string
description: The user or group the app runs as in anonymous or publisher mode (e.g. 'u/admin' or 'g/mygroup'). The authority for the app's identity.
on_behalf_of_email:
type: string
readOnly: true
description: Address of `on_behalf_of`, derived from it on every read and never stored. Absent when the policy names no identity. Read-only - sending it beside `on_behalf_of` is ignored, and sending it alone with `preserve_on_behalf_of` is rejected, so a deploy that means to keep an identity has to name it in `on_behalf_of`.
sandbox:
type: boolean
description: >
@@ -34161,6 +34157,10 @@ components:
type: boolean
bundle_secret:
type: string
on_behalf_of_email:
type: string
readOnly: true
description: Address of `policy.on_behalf_of`, derived from it on every read and never stored. Absent when the policy names no identity. Beside the policy rather than inside it so that a client echoing a policy back cannot send a value only the server may author.
labels:
type: array
items:
+28 -28
View File
@@ -255,6 +255,16 @@ pub struct AppWithLastVersion {
#[sqlx(skip)]
#[serde(skip_serializing_if = "Option::is_none")]
pub bundle_secret: Option<String>,
/// The address `policy.on_behalf_of` resolves to, derived per read and never stored — see
/// [`attach_on_behalf_of_email`], which is also the only thing that sets it.
///
/// Beside the policy rather than inside it on purpose. The policy is read, edited and written
/// back by a dozen callers; a field only the server may author, living in there, makes every
/// one of them responsible for stripping it before it goes out again, and makes the same
/// field serve both what the editor displays and what it submits.
#[sqlx(skip)]
#[serde(skip_serializing_if = "Option::is_none")]
pub on_behalf_of_email: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub labels: Option<Vec<String>>,
}
@@ -491,14 +501,11 @@ pub struct S3Key {
#[derive(Serialize, Deserialize, Debug, Clone, Default)]
pub struct Policy {
pub on_behalf_of: Option<String>,
/// The address `on_behalf_of` resolves to. Response-only: never stored and never read from
/// the request. [`attach_on_behalf_of_email`] puts it here, and states which reads may.
#[serde(skip_deserializing, skip_serializing_if = "Option::is_none")]
pub on_behalf_of_email: Option<String>,
/// The same key on the way in, taken only so that a request naming the identity by address
/// alone can be refused. Silently ignoring it would redeploy the app as whoever pushed it,
/// which is how a client written before the principal existed names an identity. An echo of
/// a read carries the principal too, so this is never the whole of what a caller sent.
/// An address a caller sent, taken only so that a request naming the identity by address
/// alone can be refused. The policy has no address of its own: it is derived per read onto
/// `AppWithLastVersion::on_behalf_of_email`, so nothing here is ever stored or served.
/// Silently ignoring it would redeploy the app as whoever pushed it, which is how a client
/// written before the principal existed names an identity.
#[serde(default, rename = "on_behalf_of_email", skip_serializing)]
pub submitted_on_behalf_of_email: Option<String>,
//paths:
@@ -1169,7 +1176,7 @@ async fn get_app(
// The deployed policy only. A draft overlaid below keeps whatever its editor last saved.
if let Some(app) = app_o.as_mut() {
attach_on_behalf_of_email(&mut app.app.policy, &w_id, &db).await?;
attach_on_behalf_of_email(&mut app.app, &w_id, &db).await?;
}
// No deployed row + `get_draft`: fall back to the draft table; see scripts.rs.
@@ -1221,7 +1228,7 @@ async fn get_app_lite(
tx.commit().await?;
let mut app = not_found_if_none(app_o, "App", path)?;
attach_on_behalf_of_email(&mut app.policy, &w_id, &db).await?;
attach_on_behalf_of_email(&mut app, &w_id, &db).await?;
Ok(Json(app))
}
@@ -1360,7 +1367,7 @@ async fn get_app_by_id(
check_scopes(&authed, || format!("apps:read:{}", &app.path))?;
attach_on_behalf_of_email(&mut app.policy, &w_id, &db).await?;
attach_on_behalf_of_email(&mut app, &w_id, &db).await?;
Ok(Json(app))
}
@@ -1414,7 +1421,7 @@ async fn get_public_app_by_secret(
app.bundle_secret = Some(compute_bundle_secret(&db, &w_id, &app.versions).await?);
}
attach_on_behalf_of_email(&mut app.policy, &w_id, &db).await?;
attach_on_behalf_of_email(&mut app, &w_id, &db).await?;
Ok(Json(app))
}
@@ -4583,7 +4590,6 @@ async fn upload_s3_file_from_app(
triggerables: None,
triggerables_v2: None,
on_behalf_of: None,
on_behalf_of_email: None,
submitted_on_behalf_of_email: None,
s3_inputs: Some(vec![S3Input {
file_key_regex: file_key_regex,
@@ -5009,7 +5015,6 @@ async fn get_on_behalf_authed_from_app(
triggerables: None,
triggerables_v2: None,
on_behalf_of: None,
on_behalf_of_email: None,
submitted_on_behalf_of_email: None,
s3_inputs: None,
allowed_s3_keys: Some(force_allowed_s3_keys),
@@ -5034,7 +5039,6 @@ async fn get_on_behalf_authed_from_app(
triggerables: None,
triggerables_v2: None,
on_behalf_of: None,
on_behalf_of_email: None,
submitted_on_behalf_of_email: None,
s3_inputs: None,
allowed_s3_keys: None,
@@ -5577,7 +5581,7 @@ fn refuse_address_only_identity(policy: &Policy, preserve: Option<bool>) -> Resu
Ok(())
}
/// Attach to a policy on its way out the address its principal resolves to.
/// Derive onto an app, on its way out, the address its policy's principal resolves to.
///
/// Derived here rather than left to the caller because the app bundle reads it as `ctx.author`
/// in the browser, where an anonymous viewer has no session to resolve `u/alice` with. Cached,
@@ -5595,27 +5599,23 @@ fn refuse_address_only_identity(policy: &Policy, preserve: Option<bool>) -> Resu
/// see this app. Every route that calls it does so after its own read check — the RLS
/// transaction that produced the row, `check_scopes`, or `authorize_app_viewer`.
pub async fn attach_on_behalf_of_email(
policy: &mut sqlx::types::Json<Box<RawValue>>,
app: &mut AppWithLastVersion,
w_id: &str,
db: &DB,
) -> Result<()> {
// Field-level, not a `Policy` round-trip: a legacy policy carries keys this struct has
// never had, and returning it shorn of them is not this function's job.
let mut value = serde_json::from_str::<serde_json::Value>(policy.0.get()).map_err(to_anyhow)?;
let Some(object) = value.as_object_mut() else {
return Ok(());
};
let Some(permissioned_as) = object
// Field-level, not a `Policy` parse: a legacy policy carries keys that struct has never had,
// and this reads one key rather than deciding what the policy is.
let Some(permissioned_as) = serde_json::from_str::<serde_json::Value>(app.policy.0.get())
.map_err(to_anyhow)?
.get("on_behalf_of")
.and_then(|v| v.as_str())
.map(str::to_string)
else {
return Ok(());
};
let email =
windmill_common::users::get_email_from_permissioned_as(&permissioned_as, w_id, db).await?;
object.insert("on_behalf_of_email".to_string(), json!(email));
*policy = sqlx::types::Json(serde_json::value::to_raw_value(&value).map_err(to_anyhow)?);
app.on_behalf_of_email = Some(
windmill_common::users::get_email_from_permissioned_as(&permissioned_as, w_id, db).await?,
);
Ok(())
}
+1 -4
View File
@@ -317,7 +317,7 @@ export function preserveOnBehalfOfFields(
return {};
}
log.info(
`Preserving ${deployedPolicy?.on_behalf_of_email ?? onBehalfOf} as permissioned_as for app ${remotePath}`
`Preserving ${onBehalfOf} as permissioned_as for app ${remotePath}`
);
return { preserve_on_behalf_of: true };
}
@@ -592,10 +592,7 @@ const command = new Command()
// Only spread remote.policy — spreading the full remote object would include
// `value` and trigger a new app version on every call. EditApp has all-Option
// fields, so a minimal body only updates the policy column.
// The read carries a derived `on_behalf_of_email`; a server old enough to still read it
// would see the previous holder's address beside the new principal and reject the pair.
const policy = { ...(remote.policy as any), on_behalf_of: `u/${username}` };
delete policy.on_behalf_of_email;
await wmill.updateApp({
workspace: workspace.workspaceId,
path: appPath,
+3 -3
View File
@@ -200,9 +200,9 @@ function triggerService(kind: TriggerDeployKind) {
* over that fallback, so carrying the name into a workspace that has a member of its own by that
* name would hand the app to them.
*
* The member's address comes from `usr` via `listUsers`, never from the app read's derived
* `on_behalf_of_email`: that one is served from a cache that may be a notify poll stale, and this
* answer is about to be persisted as the target's identity.
* The member's address comes from `usr` via `listUsers`, never from the address the app read
* derives: that one is served from a cache that may be a notify poll stale, and this answer is
* about to be persisted as the target's identity.
*
* Throws rather than guessing whenever the two workspaces disagree about who a name belongs to.
* Deploying as the pusher instead would silently widen what the app runs as.
+1 -3
View File
@@ -516,14 +516,12 @@ export async function deployItem(
// principal is always replaced by `onBehalfOf`, which **the caller must have made valid
// in the target**: `u/` names are per-workspace, so a caller reading the source has to map
// it (`preservedIdentity` in the CLI's merge does), and one reading the target can send it
// as is. The address the read derived goes too: it is the source's, and a server old
// enough to still read it would take the pair as naming two accounts.
// as is.
const app = {
...rawApp,
policy: {
...rawApp.policy,
on_behalf_of: onBehalfOf,
on_behalf_of_email: undefined,
},
};
if (alreadyExists) {
@@ -1005,7 +1005,7 @@
)
)
// Fetch summaries and on_behalf_of_email when comparison data loads
// Fetch summaries and the on-behalf-of identity when comparison data loads
$effect(() => {
if (comparison?.diffs) {
fetchSummaries(comparison.diffs)
@@ -68,6 +68,7 @@
app,
path,
policy,
onBehalfOfEmail = undefined,
summary,
labels,
deployedBaseline = undefined,
@@ -90,6 +91,11 @@
migrateApp(untrack(() => app))
// What the deploy drawer's identity picker last chose, preferred over the address the read
// derived so the editor names who the app *will* run as. Undefined until something is picked.
let pickedOnBehalfOfEmail = $state<string | undefined>(undefined)
const shownOnBehalfOfEmail = $derived(pickedOnBehalfOfEmail ?? onBehalfOfEmail)
// Migrated clone of the deployed baseline for the autosave `discardIf`. The
// live `stateApp` is `migrateApp`'d on mount, so the baseline must be too or
// an unedited draft would never compare equal. Captured once per mount (the
@@ -189,7 +195,7 @@
workspace: $workspaceStore,
mode: 'editor',
summary: $summaryStore,
author: untrack(() => policy).on_behalf_of_email
author: untrack(() => shownOnBehalfOfEmail)
})
const darkMode: Writable<boolean> = writable(document.documentElement.classList.contains('dark'))
@@ -884,6 +890,7 @@
{#if !$userStore?.operator}
{#if $appStore}
<AppEditorHeader
bind:onBehalfOfEmail={pickedOnBehalfOfEmail}
{newPath}
{newApp}
{labels}
@@ -928,6 +935,7 @@
appPath={path}
{breakpoint}
{policy}
onBehalfOfEmail={shownOnBehalfOfEmail}
isEditor
{context}
noBackend={false}
@@ -1132,7 +1140,7 @@
)}
use:initPanzoom
>
<GridEditor {policy} />
<GridEditor {policy} onBehalfOfEmail={shownOnBehalfOfEmail} />
</div>
{/if}
{#if !$appStore?.mobileViewOnSmallerScreens && $breakpoint === 'sm'}
@@ -64,7 +64,7 @@
import type { DiffDrawerI } from '$lib/components/diff_drawer'
import AppEditorHeaderDeploy from './AppEditorHeaderDeploy.svelte'
import { computeSecretUrl } from './appDeploy.svelte'
import { policyForDeploy, updatePolicy } from './appPolicy'
import { updatePolicy } from './appPolicy'
import { editInForkAllowed, editInForkLabel, openEditInFork } from '$lib/utils/editInFork'
import { isCloudHosted } from '$lib/cloud'
@@ -110,10 +110,14 @@
// (not `on:restore` forwarding): forwarding a `createEventDispatcher`
// event up through these runes-mode components silently drops it.
onRestore?: (restoredApp: any) => void
/** Address of the identity selected in the deploy drawer; a display value the editor
* shows, never part of what a deploy sends. */
onBehalfOfEmail?: string | undefined
}
let {
policy = $bindable(),
onBehalfOfEmail = $bindable(undefined),
fromHub = false,
diffDrawer = undefined,
savedApp = $bindable(undefined),
@@ -253,7 +257,7 @@
value: $app,
path,
summary: $summary,
policy: policyForDeploy(policy),
policy,
deployment_message: deploymentMsg,
custom_path: customPath,
preserve_on_behalf_of: preserveOnBehalfOf || undefined,
@@ -357,7 +361,7 @@
requestBody: {
value: $app!,
summary: $summary,
policy: policyForDeploy(policy),
policy,
path: npath,
deployment_message: deploymentMsg,
// custom_path requires admin so to accept update without it, we need to send as undefined when non-admin (when undefined, it will be ignored)
@@ -410,7 +414,7 @@
await AppService.updateApp({
workspace: $workspaceStore!,
path: $appPath,
requestBody: { policy: policyForDeploy(policy) }
requestBody: { policy }
})
if (message) {
sendUserToast(message)
@@ -812,6 +816,7 @@
</div>
{/snippet}
<AppEditorHeaderDeploy
bind:onBehalfOfEmail
{newPath}
{newApp}
{policy}
@@ -44,12 +44,17 @@
newPath,
hideSecretUrl = false,
preserveOnBehalfOf = $bindable(false),
onBehalfOfEmail = $bindable(undefined),
labels = $bindable(),
rawApp = false,
newApp = false,
operatingWorkspace = undefined
}: {
policy: any
/** Address of the identity currently selected in the deploy drawer, so the editor shows
* who the app will run as rather than who it last ran as. Separate from `policy` on
* purpose: this is a display value, and nothing a deploy sends. */
onBehalfOfEmail?: string | undefined
setPublishState: (message?: string) => void
appPath: string
customPath: string | undefined
@@ -145,6 +150,7 @@
}
let canPreserve = $derived(!!$userStore?.is_admin || !!$userStore?.is_super_admin || isDeployer)
let savedOnBehalfOf = $derived(savedApp?.policy?.on_behalf_of)
let savedOnBehalfOfEmail = $derived(savedApp?.on_behalf_of_email)
let onBehalfOfChoice: OnBehalfOfChoice = $state(undefined)
let customOnBehalfOfEmail: string = $state('')
let dirtyCustomPath = $state(false)
@@ -320,14 +326,17 @@
onBehalfOfChoice = choice
if (choice === 'me') {
policy.on_behalf_of = `u/${$userStore?.username}`
onBehalfOfEmail = $userStore?.email
customOnBehalfOfEmail = ''
preserveOnBehalfOf = false
} else if (choice === 'target') {
policy.on_behalf_of = savedOnBehalfOf
onBehalfOfEmail = savedOnBehalfOfEmail
customOnBehalfOfEmail = ''
preserveOnBehalfOf = true
} else if (choice === 'custom' && details) {
policy.on_behalf_of = details.permissionedAs
onBehalfOfEmail = details.email
customOnBehalfOfEmail = details.email
preserveOnBehalfOf = true
}
@@ -30,6 +30,9 @@
appPath?: string
breakpoint?: Writable<EditorBreakpoint>
policy?: Policy
/** Address `policy.on_behalf_of` resolves to, derived by the app read. Beside the policy,
* not in it, so a client echoing the policy back cannot send a server-authored value. */
onBehalfOfEmail?: string
summary?: string
workspace?: string
isEditor?: boolean
@@ -47,6 +50,7 @@
appPath = '',
breakpoint = writable('lg'),
policy = {},
onBehalfOfEmail = undefined,
summary = '',
workspace = $workspaceStore!,
isEditor = false,
@@ -78,7 +82,7 @@
workspace: untrack(() => workspace),
mode: 'viewer',
summary: untrack(() => summary),
author: untrack(() => policy).on_behalf_of_email,
author: untrack(() => onBehalfOfEmail),
app_path: untrack(() => appPath)
}
@@ -301,7 +305,7 @@
<h2 class="truncate">{summary}</h2>
<RecomputeAllComponents />
<div class="text-2xs text-secondary">
{policy.on_behalf_of ? `on behalf of ${policy.on_behalf_of_email}` : ''}
{policy.on_behalf_of ? `on behalf of ${onBehalfOfEmail ?? policy.on_behalf_of}` : ''}
</div>
</div>
</div>
@@ -30,9 +30,12 @@
interface Props {
policy: Policy
/** Address `policy.on_behalf_of` resolves to, derived by the app read. Beside the policy,
* not in it, so a client echoing the policy back cannot send a server-authored value. */
onBehalfOfEmail?: string
}
let { policy }: Props = $props()
let { policy, onBehalfOfEmail = undefined }: Props = $props()
const {
selectedComponent,
@@ -174,7 +177,7 @@
/>
</div>
<div>
{policy.on_behalf_of ? `Author ${policy.on_behalf_of_email}` : ''}
{policy.on_behalf_of ? `Author ${onBehalfOfEmail ?? policy.on_behalf_of}` : ''}
<Tooltip>
The scripts will be run on behalf of the author and a tight policy ensure security about
the possible inputs of the runnables.
@@ -196,6 +196,7 @@
appPath={app.path}
{breakpoint}
policy={app.policy}
onBehalfOfEmail={app.on_behalf_of_email}
isEditor={false}
replaceStateFn={(path) => goto(path)}
gotoFn={(path, opt) => (embedNav ? embedNav.navigateTop(path) : goto(path, opt))}
@@ -189,18 +189,6 @@ export async function updatePolicy(app: App, currentPolicy: Policy | undefined):
return next
}
/**
* The policy as a deploy should send it.
*
* A read derives `on_behalf_of_email` from the principal, and the editor keeps it to show who the
* app runs as — but sending it back beside a principal the user has just changed reads, to a peer
* that still stores the address, as a pair naming two accounts, and the deploy is refused. Built
* as a copy so the object the editor renders from keeps the address it displays.
*/
export function policyForDeploy<T extends Record<string, any>>(policy: T): T {
return { ...policy, on_behalf_of_email: undefined }
}
export async function processRunnable(
id: string,
runnable: Runnable,
@@ -143,6 +143,9 @@ export interface AppEditorProps {
app: App
path: string
policy: Policy
/** Address `policy.on_behalf_of` resolves to, derived by the app read. Beside the policy,
* not in it, so a client echoing the policy back cannot send a server-authored value. */
onBehalfOfEmail?: string
summary: string
/** Initial labels for the app, threaded from the loaded app data. */
labels?: string[]
@@ -74,7 +74,6 @@
import type { RawAppData } from './dataTableRefUtils'
import { editInForkAllowed, editInForkLabel, openEditInFork } from '$lib/utils/editInFork'
import { isCloudHosted } from '$lib/cloud'
import { policyForDeploy } from '$lib/components/apps/editor/appPolicy'
// async function hash(message) {
// try {
@@ -315,7 +314,7 @@
value: app,
path,
summary: summary,
policy: policyForDeploy(policy),
policy,
deployment_message: deploymentMsg,
custom_path: customPath,
preserve_on_behalf_of: preserveOnBehalfOf || undefined,
@@ -436,7 +435,7 @@
app: {
value: app!,
summary: summary,
policy: policyForDeploy(policy),
policy,
path: npath,
deployment_message: deploymentMsg,
preserve_on_behalf_of: preserveOnBehalfOf || undefined,
@@ -486,7 +485,7 @@
await AppService.updateApp({
workspace: opWorkspace!,
path: appPath,
requestBody: { policy: policyForDeploy(policy) }
requestBody: { policy }
})
if (message) {
sendUserToast(message)
+3 -4
View File
@@ -23,7 +23,6 @@ import {
normalizeRawAppData
} from '$lib/components/raw_apps/rawAppDraftValue'
import { stateSnapshot } from '$lib/svelte5Utils.svelte'
import { policyForDeploy } from '$lib/components/apps/editor/appPolicy'
/**
* Deploy an explicit raw-app value — one the user edited as JSON, or one
@@ -74,7 +73,7 @@ export async function deployRawAppValue({
// version can still carry the pre-`data` datatable shapes.
value: { files, runnables, data: normalizeRawAppData(plainValue) },
summary: summary ?? '',
policy: policyForDeploy(policy),
policy,
deployment_message: deploymentMessage,
// custom_path changes require admin (see deployRawAppDraft).
custom_path: isAdmin ? (customPath ?? '') : undefined,
@@ -141,7 +140,7 @@ export async function deployRawAppDraft(
path: targetPath,
value: rawAppValue,
summary,
policy: policyForDeploy(policy),
policy,
deployment_message: deploymentMessage,
custom_path: isAdmin ? (value.custom_path ?? '') : undefined,
// Preserve the policy's on_behalf_of: this draft-deploy path has no
@@ -161,7 +160,7 @@ export async function deployRawAppDraft(
path: targetPath,
value: rawAppValue,
summary,
policy: policyForDeploy(policy),
policy,
deployment_message: deploymentMessage,
custom_path: value.custom_path,
// Preserve the policy's on_behalf_of (see update branch above).
+3 -3
View File
@@ -206,7 +206,8 @@ export type DeployConflict = { hit: boolean }
* An app carries its identity inside its `policy` instead, and as a principal rather than an
* address — `deployItemIdentityIsPrincipal` says which kinds do. So `onBehalfOf` is already
* the `u/`/`g/` form for one, and it is stamped into the policy here because the published
* package leaves that untouched.
* package leaves that untouched. Nothing has to be stripped on the way out: the address the
* read derives arrives beside the policy, not in it.
*/
function makeProvider(
onBehalfOfPrincipal?: string,
@@ -230,8 +231,7 @@ function makeProvider(
...app,
policy: {
...app.policy,
on_behalf_of: onBehalfOf,
on_behalf_of_email: undefined
on_behalf_of: onBehalfOf
}
}
: app
@@ -474,6 +474,7 @@
newPath={app.value?.draft_path ?? app.path}
path={page.params.path ?? ''}
policy={app.policy}
onBehalfOfEmail={app.on_behalf_of_email}
bind:savedApp
{diffDrawer}
version={app.versions ? app.versions[app.versions.length - 1] : undefined}
@@ -5,7 +5,6 @@
import { AppService } from '$lib/gen'
import { userStore, workspaceStore } from '$lib/stores'
import { readFieldsRecursively } from '$lib/utils'
import { policyForDeploy } from '$lib/components/apps/editor/appPolicy'
import { goto } from '$lib/navigation'
import { sendUserToast } from '$lib/toast'
import DiffDrawer from '$lib/components/DiffDrawer.svelte'
@@ -121,10 +120,7 @@
runnables,
data,
summary,
// Shaped on the way into the row, not just on the way to a deploy: the address is
// derived per read, and a copy persisted here is the one thing that outlives the
// sweeps that exist to keep it out of a draft.
policy: policyForDeploy(policy),
policy,
custom_path: savedApp?.custom_path,
// Persist the typed path as `draft_path` only when it actually differs
// from the current path — a `draft_path` equal to the baseline is a
@@ -410,7 +406,7 @@
runnables,
data,
summary,
policy: policyForDeploy(policy),
policy,
custom_path: savedApp?.custom_path,
...(pendingDraftPath ? { draft_path: pendingDraftPath } : {})
} as RawAppDraft,