Files
windmill/backend/windmill-api/src/apps.rs
T
0f7dd86e5c feat: persistent in-editor drafts via UserDraft (#9121)
* refactor(frontend): remove localStorage-backed autosave drafts

Strip the per-editor localStorage autosave for flows, apps and raw apps,
along with the associated restore toasts and diff actions, so we can
replace them with a unified UserDraft service in a follow-up. The
backend DraftService (DB-backed drafts) is untouched.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(frontend): add UserDraft service for per-workspace local drafts

Introduces UserDraft, a key-value store keyed by
`{workspace}/{itemKind}/{path}` and backed by localStorage. Supports
save/get/remove plus a reactive use() handle so multiple component
instances observing the same draft stay in sync via a shared $state
loaded through useLocalStorageValue. Designed to host drafts for
scripts, flows, apps, raw apps, resources, variables, and all trigger
kinds.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* tests

* nit schedule_ prefix

* feat(frontend): persist deep mutations in useLocalStorageValue

Track the serialized value alongside the $state and add an $effect that
deep-reads it (via readFieldsRecursively). When a deep mutation produces
a serialization that differs from the last persisted blob, write it to
localStorage. The setter keeps writing synchronously so callers reading
localStorage right after assignment still see the new value; the effect
no-ops on those because lastSerialized was already updated by the setter.
Undefined values are persisted as a removal.

UserDraft no longer needs its own removeItem workarounds for undefined
values — useLocalStorageValue handles that uniformly now.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(frontend): add defaultValue + empty-path handling to UserDraft

UserDraft.use() accepts an opts.defaultValue used when no localStorage
entry exists yet. It is not persisted on first read — only an actual
mutation writes through.

Empty paths (new items) bypass localStorage entirely. The entry still
lives in the in-memory Map so multiple components on the same /add page
share state, but save/get/remove/use never read or write localStorage
with an empty path. Once the item is saved and the route navigates to
its new URL, a fresh use() on the non-empty path takes over.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(frontend): wire script editor to UserDraft

The script editor's top-level state now lives in UserDraft.use(), keyed
on the route's path (page.params.path on /scripts/edit, '' on /scripts/add).
Deep edits inside ScriptBuilder persist automatically; deploy and draft
restore now call UserDraft.remove to clear the local autosave alongside
the backend draft.

Replaces the URL-hash autosave that ScriptBuilder used to write via
replaceStateFn — that prop is now gone, the encodeScriptState debounce
is gone, and Triggers no longer takes a saveSessionDraft callback.
Viewing a specific historical hash (?hash=...) is kept draft-free by
passing '' as the path.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(frontend): wire flow editor to UserDraft

flows/add and flows/edit drive the flow value through a StateStore
adapter backed by UserDraft.use, so every edit auto-persists at
userdraft/w/{ws}/flow/{path} without touching FlowBuilder's internal
.val convention. On returning visits the local autosave wins and a
toast offers a diff against the latest backend draft/deployed version;
on a fresh visit the backend value is written into the handle. Deploy,
save-as-draft rename, restore-draft and restore-deployed each call
UserDraft.remove on the route path so the local autosave doesn't
outlive the action.

Adds UserDraft.has() for "is there already a local draft?" detection
in the load path.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(frontend): wire app editor to UserDraft

AppEditor registers a UserDraft.use<App> handle for its current path
(empty path for /apps/add stays in-memory) and a single $effect
deep-tracks the internal stateApp and forwards every mutation to the
handle. useLocalStorageValue's lastSerialized check then dedupes the
actual localStorage writes per tick, so even fast drag/resize loops
only persist when the JSON output really changes.

/apps/edit overlays a local autosave from UserDraft.get on top of the
backend value when one exists, with the existing "Discard / Show diff"
toast wired to UserDraft.remove. Deploy, save-as-draft, restore-draft
and restore-deployed all call UserDraft.remove on the relevant path,
including the JSON editor save paths.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(frontend): wire raw app editor to UserDraft

/apps_raw/edit owns the canonical raw-app state (files, runnables,
data, summary) in four $state vars; a single $effect deep-tracks them
and forwards the bundle to a UserDraft.use<RawAppDraft> handle so each
mutation tick persists at userdraft/w/{ws}/raw_app/{path} (deduped by
useLocalStorageValue's serialized check). On load the route overlays
the local autosave on top of backend.draft/deployed and offers a
"Discard / Show diff" toast when they diverge; matching local entries
are silently dropped. Deploy, save-as-draft rename, restore-draft and
restore-deployed each call UserDraft.remove on the route path.

/apps_raw/add keeps the same shape (UserDraft.use with empty path)
so the draft is in-memory only and we drop it explicitly when the
initial save creates the real path.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(frontend): wire resource editor to UserDraft

ResourceEditor registers a UserDraft.use<ResourceState> handle keyed
on the initialPath (empty for new resources, in-memory only). A
$effect deep-tracks the current workspace's edit state and forwards
mutations to the handle; on bootstrap and lazy backend-fetch the
local autosave wins over the backend value when they diverge. After
a successful save() we call UserDraft.remove so the local autosave
doesn't outlive the deploy. Cross-workspace deploys always start from
the live backend value rather than the local draft.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(frontend): wire variable editor to UserDraft

VariableEditor persists the current workspace's edit state via
UserDraft.save on every mutation, keyed on editPath ('' for new
variables → in-memory only). Backend fetches now overlay a matching
local autosave when one exists, and initNew() rehydrates from the
in-memory empty-path entry so opening a fresh "Add variable" drawer
keeps any unsaved work from the previous open. After a successful
save we drop the corresponding entry.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* editor external changes sync

* fix(frontend): don't UserDraft.remove flows while route is still mounted

The /flows/add and /flows/edit routes drive FlowBuilder from a flowStore
whose getter reads flowHandle.draft directly. Calling UserDraft.remove
synchronously before goto() therefore wiped the in-memory entry, made
flowStore.val collapse to emptyFlow(), and tripped
UnsavedConfirmationModal against the just-saved value — even though the
deploy/save-draft itself succeeded.

Drop those explicit removes in onSaveInitial, /add onDeploy, and
/edit onDeploy. The empty-path entry self-cleans on unmount via
onDestroy ref counting; for the non-empty edit path the next visit's
load-time diff will silently overwrite localStorage when the local
autosave matches the deployed value. Restore-draft/restore-deployed
keep their explicit remove because they navigate to the same route
(no modal) and loadFlow immediately rehydrates the handle.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* Revert "fix(frontend): don't UserDraft.remove flows while route is still mounted"

This reverts commit 079ebef72b.

* Only remove from localStorage

* feat(frontend): saveInitialValue option on useLocalStorageValue

The first time a value flows into a UserDraft.use() handle — typically
the editor route loading the backend value via flowHandle.draft =
backendFlow — is the baseline, not a user edit. Persisting it on the
spot puts a copy of the backend into localStorage on every page open
and produces spurious "local autosave" toasts on next visit when the
serialization round-trips differently.

useLocalStorageValue now takes options.saveInitialValue (default true,
backward compatible). When false, the first time the serialised form
of the state changes — via the setter or via a deep mutation — the
lastSerialized cache is updated but localStorage is not touched. Every
write after that persists normally. UserDraft.use() passes false.

Tests updated to reflect the new contract (first write is the
baseline) and a regression test added for the second-write-persists
behaviour.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(frontend): persist full multi-workspace bundle for resources/variables

ResourceEditor and VariableEditor can stage edits for several target
workspaces in a single drawer session (see deployTo / states[ws] map).
The previous UserDraft wiring only persisted states[$workspaceStore] —
the user's session workspace — so any edit made under a different
target workspace tab disappeared on refresh.

Persist the entire `states: Record<wsId, State>` bundle as the draft
value instead. On lazy-fetch we pick the local state for that ws if
present and divergent from the backend; on bootstrap for new
resources/variables we restore states for every workspace the user
had staged. The localStorage key still lives under the user's session
workspace via UserDraft, but its contents now cover all target
workspaces from that session.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(frontend): bake parent_hash into the initial script load

loadScript() assigned the backend value to scriptHandle.draft and then
deep-mutated parent_hash on the next line. Under
useLocalStorageValue's saveInitialValue=false contract only the very
first write is the baseline — the parent_hash mutation right after
counted as a second write and was persisted to localStorage, so
opening an existing script would silently write a draft entry even
though the user hadn't touched anything.

Combine `parent_hash` (and the topHash override) into a single
bakedBaseline so each branch of loadScript performs exactly one
assignment to scriptHandle.draft. Mirrored across the local-autosave
branch's discard callbacks too.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(frontend): wire SqsTrigger editor to UserDraft

Persist the trigger's getSaveCfg() output to
userdraft/w/{ws}/schedule_sqs/{path} on every edit, overlay any
existing local autosave on top of the backend value when openEdit
loads the trigger, and clear the entry on successful update.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(frontend): wire KafkaTrigger editor to UserDraft

Same pattern as the Sqs trigger: persist getSaveCfg() on every edit,
overlay any local autosave on top of the backend value when openEdit
loads the trigger, drop the entry on successful update.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(frontend): wire NatsTrigger editor to UserDraft

Same pattern as the Kafka trigger: persist getSaveCfg() on every edit,
overlay any local autosave on top of the backend value when openEdit
loads the trigger (with initialConfig/originalConfig snapshotted from
backend first so hasChanged correctly reports the overlay as unsaved),
drop the entry on successful update.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(frontend): wire MqttTrigger editor to UserDraft

Same pattern: persist getSaveCfg() on edits, overlay local autosave
in openEdit (with initialConfig/originalConfig snapshotted from
backend first), drop the entry on successful update.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(frontend): wire GcpTrigger editor to UserDraft

Same pattern as the other triggers.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(frontend): wire AzureTrigger editor to UserDraft

Same pattern as the other triggers.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(frontend): wire WebsocketTrigger editor to UserDraft

Same pattern as the other triggers.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(frontend): wire PostgresTrigger editor to UserDraft

Same pattern as the other triggers.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(frontend): wire EmailTrigger editor to UserDraft

Same pattern as the other triggers.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(frontend): wire HTTP RouteEditor to UserDraft

Same pattern as the other triggers, keyed on schedule_http.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(frontend): wire ScheduleEditor to UserDraft

Same pattern, keyed on schedule_schedule. ScheduleEditor doesn't track
an originalConfig (its saveDisabled doesn't compare against a baseline)
so ordering is simpler — initialConfig snapshotted from backend, local
autosave overlaid after.

This completes UserDraft wiring across all 11 trigger editors.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* refactor(frontend): rename schedule_* UserDraft kinds to trigger_*

The schedule_ prefix grouped all the trigger editors under what looked
like a "scheduler" namespace; trigger_ is what these actually are
(triggers — including the cron-style schedule). Mechanical rename
across UserDraftItemKind, every trigger editor's UserDraft.save/get/
remove calls, and the one test that asserted on the localStorage key.

Behaviour-only impact: existing localStorage keys under
userdraft/w/{ws}/schedule_{kind}/{path} from older builds will be
ignored on next open (no schema migration). Users will lose any
unsaved trigger drafts persisted before this change.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* refactor(frontend): wrap UserDraft localStorage payload as { value }

localStorage entries now look like {"value": <draft>} instead of just
<draft>. The wrapping is invisible at the API boundary — UserDraft.use,
.save, .get, .remove all still operate on the unwrapped draft value —
but it leaves room to add metadata (timestamps, originating user,
schema version, ...) later without breaking existing entries.

Internals:
- StoredDraft<V> = { value: V } is what we serialise to localStorage
  and what useLocalStorageValue's $state holds.
- wrap()/unwrap() helpers gate the boundary; the handle returned by
  use() unwraps on get and wraps on set.
- readPersisted() defensively drops entries whose payload isn't a
  { value: ... } object, so pre-migration drafts written by earlier
  commits on this branch are simply ignored (has() returns false,
  get() returns undefined) rather than confusingly surfacing as
  undefined-shaped drafts.

Test data switched from { value: X } (which collides confusingly with
the wrapper shape) to plain primitives / objects, plus a regression
test for the pre-migration ignore behaviour. 28 tests pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(backend): expose freshness for UserDraft staleness check

Variable
- Add `edited_at TIMESTAMPTZ NOT NULL DEFAULT now()` + `edited_by VARCHAR(50)` to the `variable` table (parity with `resource`); set them on INSERT and on every UPDATE.
- Surface them on `ListableVariable` so `getVariable` / `listVariable` return them.

DB drafts (script, flow, app/raw_app)
- The `*WithDraft` endpoints now also return `draft.created_at` as `draft_created_at`. The draft value alone wasn't enough to tell whether a teammate (or another tab) had pushed a fresh draft while local autosave was in flight; the new field is the staleness signal.
- Wired in `get_script_by_path_w_draft` (`ScriptWDraft.draft_created_at`, including the `prefetch_cached` forwarding), `get_flow_by_path_w_draft` (`FlowWDraft.draft_created_at`), and `get_app_w_draft` (`AppWithLastVersionAndDraft.draft_created_at`). OpenAPI updated to match.

The frontend will read these in a follow-up to implement the local-draft staleness check; this commit only widens the API surface.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(frontend): track remote rev metadata on UserDraft entries

Extends StoredDraft<V> with two optional rev fields used by the
forthcoming staleness modal:

- remoteRev — the deployed version's id/hash/timestamp at the moment
  the local draft was created. Compared against the latest deployed
  rev on reload.
- remoteDraftRev — the DB-draft created_at at the moment the local
  draft was created. Only meaningful for kinds that have a DB draft
  (script, flow, app, raw_app). Checked first so a teammate's draft
  push is detected before the "deployed version moved" case.

API additions on the handle returned by UserDraft.use():

- handle.meta — read the rev metadata currently stored.
- handle.setDraftAndMeta(value, meta) — atomic write of value + meta in
  a single state.val assignment. Editor routes use this on load so the
  baseline rev rides along with the value without consuming the
  saveInitialValue=false dedup slot twice.
- handle.setMeta(meta) — update just the rev metadata after the user
  picks "Keep current draft" in the staleness modal.
- handle.draft = X — unchanged surface; now preserves existing rev
  metadata across user edits.

Plus UserDraft.getMeta() and UserDraft.save() preserves any persisted
rev metadata when called without a live handle.

7 new tests cover the metadata surface; all 35 pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(frontend): staleness modal for the script editor's local autosave

Replace the script editor's toast-based "Discard / Show diff" pattern
with a dedicated modal that surfaces *why* the local autosave is out
of date: a new DB draft on the server, or a new deployed version.

Adds `checkStaleness` (UserDraftMeta vs current backend revs, draft-rev
priority) and a `setMeta({ force: true })` mode so the "Keep current
draft" acknowledgement persists even when it happens to be the
entry's first state mutation — under `saveInitialValue: false` an
ack-only setMeta would otherwise be skipped and the modal would
re-fire on next mount.

The modal lives at LocalDraftStaleModal.svelte; the script editor
wires it as a template for the remaining editors. Other editors
(flows, apps, raw_apps, resources, variables, triggers) still use
the previous toast pattern and will be migrated in follow-up
commits.

* feat(frontend): staleness modal for flow, app, and raw-app editors

Migrates the flow, app, and raw_app editor routes to the same
`LocalDraftStaleModal` flow already used by scripts: compare the
recorded meta against the current `version` / `versions[last]` and
`draft_created_at`; on mismatch, surface the choice in a modal.

Adds `UserDraft.saveMeta` for routes that don't hold a live handle
(the app editor reads via `UserDraft.get` and the handle lives in
the child `AppEditor` component). It writes meta directly to
localStorage and tolerates the no-entry case.

* feat(frontend): migrate legacy localStorage autosave entries

Apps and flows used to autosave under un-scoped keys (`flow`/`flow-{path}`,
`app`/`app-{path}`, `rawapp`/`rawapp-{path}`) with a base64-encoded
state envelope. This adds a one-off migration that rewrites surviving
legacy entries under the workspace-scoped `userdraft/w/{ws}/{kind}/{path}`
keys with the new `{ value }` wrapper, transforms the payload where the
shape differs (drops the flow view-state envelope, defaults the new
raw-app `summary` field), and drops the source key.

The migration lives in its own file (`userDraftLegacyMigration.ts`)
so the new UserDraft service stays free of legacy decoders. Idempotent
via a `userdraft/legacy_migrated_v1` sentinel; runs from the logged-in
root layout once a workspace is known. Defensive shape checks avoid
clobbering co-resident apps that happen to use the same key prefixes.

* nit remove comments

* refactor(frontend): per-workspace UserDraft handles in Resource/Variable editors

Earlier commits in this PR wired the resource and variable editors to a
single multi-workspace bundle stored under the user's session workspace
key — which mixed workspaces in one localStorage entry and required a
custom multi-key fix-up pass to persist edits for other workspaces.

Reset both editors to their pre-PR shape and apply the minimal change:
the per-workspace `Record<string, ResourceState>` (resp. `VariableState`)
becomes `Record<string, UserDraftHandle<…>>`, with one handle per
workspace created via `UserDraft.use(…, { workspace: ws })`. The handle
keys its own localStorage entry under that workspace, so cross-workspace
edits stay cleanly separated and reactivity flows through the handle's
`draft` accessor — `bind:` on form fields just works.

Adds `manualRelease: true` + `handle.release()` to `UserDraft.use` so
the editors can register handles lazily inside an effect (Svelte 5
forbids `onDestroy` outside component init). The editors register a
single top-level `onDestroy` that releases every collected handle.

After a successful save, the per-workspace autosave is cleared via
`UserDraft.remove(itemKind, path, { workspace })`.

* refactor(frontend): seed per-workspace handles via UserDraft.use defaultValue

ensureHandle was doing a post-hoc `if (h.draft === undefined) h.draft = baseline`,
which relies on the saveInitialValue=false skip to swallow that seeding
write. Hand the baseline to `UserDraft.use({ defaultValue })` instead —
useLocalStorageValue uses it as the initial $state value when localStorage
is empty, so lastSerialized is correct out of the gate and no setter call
is needed.

* feat(frontend): persist empty-path drafts across reloads

Empty paths used to be in-memory only (via the `isLocalOnly` short-circuit)
because we worried about collisions between concurrent /add tabs. The user
asked for the trade-off to flip: a /flows/add or /scripts/add reload should
restore the user's work, while explicitly clicking "+ Flow / + Script / …"
should always open a clean editor.

- Drop `isLocalOnly` from UserDraft so empty-path entries persist under
  `userdraft/w/{ws}/{kind}/` like any other path. The existing per-kind
  refcounting and saveInitialValue=false behavior already handle them
  correctly — the change is just lifting the bypass.
- Each /add page now calls `UserDraft.remove(kind, '')` synchronously
  when `?nodraft=true` is present in the URL, before the handle is
  created.
- The two "+" entry points that lacked the `?nodraft=true` flag
  (CreateActionsScript's plain `<a href>` and CreateActionsFlow's
  YAML/JSON import paths) now include it, so every fresh-start path goes
  through the wipe.
- Tests updated: the "empty path (in-memory only)" block becomes
  "empty path (persists across reloads)" and asserts the new behavior.

* refactor(frontend): drop legacy-migration shape guard

We assume Windmill is the only app on the origin, so the
isPlausibleLegacyValue per-kind shape check was just dead weight.
Keep the cheap "decoded is an object" guard for malformed payloads.

* docs(frontend): refresh stale "in-memory only" comments around empty paths

Empty-path UserDraft entries persist now. Drop the leftover "in-memory
only" comments on the /add pages' handle creation, and rewrite the
EditorHeader save-initial-draft comments to describe why the UserDraft.remove
call is still needed: the draft was promoted to a real path on the
backend, so the prior-path autosave must not shadow a future "+ App" /
"+ Flow" / … visit.

* fix(frontend): strip ?nodraft=true from /add URLs synchronously

The previous cleanup ran in afterNavigate, which (a) fires asynchronously
— a quick reload between mount and the callback would re-wipe the
freshly-started draft — and (b) did `url.search = ''`, nuking sibling
params like ?template, ?hub, and ?wac.

Move the URL cleanup to the same synchronous block that calls
UserDraft.remove on nodraft, using `window.history.replaceState` so it
lands before paint. Only the `nodraft` key is removed — other params
survive.

* feat(frontend): toast when editor opens on a local autosave

When a route loads its local autosave (differs from backend, no
staleness alarm), surface "Restored from local storage" with up to
two reset actions:
- "Reset to saved draft": drop the autosave, reapply the backend DB
  draft. Only shown when the backend has a DB draft.
- "Reset to deployed": drop the autosave, delete the DB draft on the
  backend (if any), reload from the deployed version. Only shown when
  the item has a deployed version.

The toast title + label wording + per-state inclusion live in a
single helper (`$lib/userDraftToast`). Each editor passes its own
reset callbacks since the side effects differ per route (handle vs
UserDraft.get/save, redraw counters, loadXxx helpers).

Wired to scripts/edit, flows/edit, apps/edit, apps_raw/edit. Resource
and variable editors don't have DB drafts and use per-workspace
handles — a follow-up will tailor a single-action version.

* feat(frontend): load URL-encoded scripts on /scripts/add

The "Fork" action on run/[...run] and several workspace-settings
helper-script templates base64-JSON-encode a NewScript into the URL
hash on `/scripts/add#...`. Until now /scripts/add silently dropped
that payload — both call sites landed on a blank editor.

Decode `page.url.hash` at module top, and if it parses to an object,
apply it as `scriptHandle.draft` and surface "Loaded from URL". The
URL value wins over local autosave, ?template, ?hub, and YAML imports
because the hash represents an explicit "open this script" intent.

Parsing is inlined rather than reusing `decodeState` so an unrelated
hash (e.g. a future route anchor) doesn't fire its default "Impossible
to parse state" error toast.

* feat(frontend): strip URL hash from /scripts/add after consumption

The URL-encoded script is a one-shot seed (Fork preview, workspace
handler templates, hub publish) — keeping the hash in the bar after
loading meant a reload would re-apply the original payload and wipe
whatever the user edited since landing.

After applying `urlScript` and firing the "Loaded from URL" toast,
clear `location.hash` via `window.history.replaceState`. The user's
edits then flow into the normal autosave path (UserDraft empty-path
entry), and a reload restores those edits instead of the seed.

* feat(frontend): load URL-encoded scripts on /scripts/edit + consume-once

Mirror the URL-hash seed mechanism from /scripts/add to /scripts/edit
for parity: decode the base64-JSON-encoded NewScript payload from the
URL hash, apply it over the bakedBaseline as the editor's initial
state, send "Loaded from URL", and strip the hash immediately via
window.history.replaceState so a reload restores the user's autosave
rather than re-injecting the seed.

The seed wins over local autosave + backend draft + deployed —
UserDraft.remove(script, draftPath) drops the stale autosave on disk
before setDraftAndMeta writes the seeded value, so the user's
subsequent edits will overwrite cleanly.

Skipped when ?hash= is in the URL (historical-version view, which is
read-only relative to drafts) and when the hash fragment isn't a
parseable encoded payload.

No callers build /scripts/edit#<encoded> URLs today — this lands the
mechanism for future symmetry with /scripts/add.

* fix(frontend): "Reset to deployed" loop on Restored-from-local toast

UserDraft.remove only clears localStorage — the entry's reactive cell
stays alive as long as some component holds a handle. The toast
callback was relying on remove+loadXxx to reset state, but loadXxx
then read the *in-memory* autosave through the still-alive entry,
matched it against the now-deployed reference, and re-fired the same
toast. Forever.

Drop the in-memory state explicitly before the load:
- scripts/flows/apps_raw (route-level handle): `handle.setDraftAndMeta(undefined, {})`
- apps (handle lives in the AppEditor child): set `app = undefined`
  to unmount AppEditor — its onDestroy releases the handle and the
  entry's refcount drops to 0, destroying the entry.

ScriptBuilder / FlowBuilder / RawAppEditor briefly unmount while the
reload fetches; the flash is the user-visible "loading" cue.

* fix(backend): convert draft.created_at to TIMESTAMPTZ

The new `*WithDraft` endpoints surface `draft.created_at` as
`Option<chrono::DateTime<Utc>>` for the frontend's staleness check,
which requires `TIMESTAMPTZ`. The column was originally created as
plain `TIMESTAMP`, so SQLx fails to deserialize any row that has a
non-null draft and the handler returns HTTP 400 instead of 200 —
caught by `test_draft_endpoints` in the integration tests.

Migrate the column to `TIMESTAMPTZ`, interpreting existing values as
UTC (matching `now()`'s behaviour on a UTC server). No compile-time
sqlx queries reference the column, so the offline cache stays valid.

* fix(frontend): settings drawer auto-opening on /scripts/edit

ScriptBuilder's metadataOpen flag fires when `initialPath == ''` (the
heuristic for "new script, expected on /scripts/add"). The route's
`let initialPath = $state('')` left it empty until applyBaseline ran
later inside loadScript.

Pre-PR, the editor was gated on a route-level `script` $state that
started undefined, so ScriptBuilder didn't mount until loadScript's
synchronous block set both `script` and `initialPath` in the same
tick. With UserDraft.use reading localStorage synchronously, the gate
(`scriptHandle.draft`) is satisfied at mount time and ScriptBuilder
mounts with the still-empty initialPath, popping the drawer open.

Seed initialPath from page.params.path synchronously so ScriptBuilder
sees the path on its first render. Falls back to '' for the historical
`?hash=` view to preserve the existing behaviour there.

* fix(backend): refresh draft.created_at on every upsert

The draft upsert was `ON CONFLICT (...) DO UPDATE SET value = EXCLUDED.value`,
so subsequent draft writes left `created_at` frozen at the first INSERT.
The frontend's UserDraft staleness check reads that timestamp as
`remoteDraftRev`; with it frozen, an updated remote draft looked
identical to the originally-baselined one and the "newer draft was
saved on the server" modal never fired.

Touch `created_at` on conflict too. The column's semantic widens from
"first write time" to "last write time", which is what every reader of
the field actually wants — the staleness signal is the only consumer.

SQLx offline cache regenerated to match the new query text.

* fix(frontend): persist trigger drafts in script-editor autosave

The triggers in ScriptBuilder live in a dedicated `triggersState`
$state, separate from the `script` object that the UserDraft handle
deep-tracks. Pre-PR the per-builder localStorage autosave bridged the
two by snapshotting `triggersState.getDraftTriggersSnapshot()` into
the payload on every write — that bridge was dropped when we removed
the per-builder autosave in favour of UserDraft.

Add an $effect that deep-reads triggersState and mirrors the snapshot
back into `script.draft_triggers`. The UserDraft handle (already
deep-tracking `script`) then persists the trigger drafts as part of
the script autosave, restoring the prior behaviour.

* feat(frontend): debounce option on useLocalStorageValue + 500 ms in UserDraft.use

Adds `debounce: number` to `useLocalStorageValue`'s options. When set,
repeated mutations within the window collapse into a single
localStorage write fired by a plain `setTimeout`. The in-memory
`$state` is updated on every change so readers of `.val` always see
the latest value; only the persistence side-effect is deferred.

No `onDestroy` flush — the timer is independent of the Svelte
lifecycle, so SPA route teardown doesn't drop the pending write
(the callback still fires later as long as the JS context is alive).
A hard browser tab close within the window does drop it; that's an
acceptable trade-off vs the complexity of `beforeunload` listeners
and the leak/refcount issues they create alongside `useLocalStorageValue`'s
keyed instances.

`UserDraft.use` opts in with `debounce: 500` so a typing storm in the
script/flow/app editor produces one localStorage write per 500 ms
instead of one per keystroke.

Tests switch to `vi.useFakeTimers()` and a `flushPersist()` helper to
keep the synchronous `expect(localStorage…)` assertions working. New
test verifies the coalescing behaviour end-to-end.

* fix(frontend): tighten legacy-migration key matching

The legacy migration was consuming any localStorage key starting with
`app-`, `flow-`, or `rawapp-`, with no constraint on what followed and
no shape check on the decoded payload. Two failure modes called out
in review:

1. A future feature (or third-party extension) picking a name like
   `app-recent` would silently lose data on first migration run.
2. A stray key that happened to base64-decode to valid JSON but
   wasn't a real legacy draft would still get promoted to the new
   format, surfacing later as a phantom "Restored from local storage"
   toast on the next edit.

Two guards:

- `LEGACY_PATH_SHAPE = /^[uf]\/[^/]+\/.+$/`: after a `<prefix>-` match,
  the remainder must look like a Windmill item path (`u/owner/name`
  or `f/folder/name`, possibly with deeper segments). Bare-prefix
  empty-path entries (`app` / `flow` / `rawapp` for `/add` autosaves)
  still match the exact branch and don't go through the shape gate.
- `isPlausibleLegacyValue`: after decode, require the payload to
  carry the field the legacy writers actually produced
  (`flow.flow` for flows, any of `summary|value|policy|path` for
  apps, any of `files|runnables|data` for raw apps).

Both are belt-and-suspenders: nothing else currently uses these key
prefixes, but enforcing the shape locally keeps the migration safe
against future namespace collisions.

* fix(backend): drop AT TIME ZONE 'UTC' from draft.created_at migration

The original migration forced `USING created_at AT TIME ZONE 'UTC'`,
which tags every existing wall-clock value as UTC. That matches the
common case (Postgres on a UTC server, which the Docker image and most
managed offerings default to), but on a non-UTC operator's deployment
it shifts all pre-migration timestamps by the server's tz offset.

Drop the USING clause. Postgres's default `TIMESTAMP -> TIMESTAMPTZ`
cast reinterprets each existing value in the session's current
timezone — which is the same timezone under which the original
`INSERT ... DEFAULT now()` values were truncated to TIMESTAMP, so
the conversion correctly recovers the original instant regardless of
the operator's timezone. Same semantics on UTC servers, correct
semantics on non-UTC servers.

Down migration updated symmetrically.

* docs(frontend): clarify staleness modal copy

The four route-level editors (scripts/flows/apps/apps_raw) keep the
user's local draft visible behind the modal so they can glance at it
before choosing. The old body text described the situation (server
has moved on, local autosave is behind) but didn't say what's
actually on screen or how each action maps to it.

New body leads with "The editor is showing your local autosave" and
spells out each action: "Load latest replaces what's on screen; Keep
current leaves it alone." Same copy for both `cause = 'draft'` and
`cause = 'version'`, branching only on what the user is "behind"
relative to.

* refactor(frontend): drop dead updateDraftCallback from Triggers constructor

None of the eight `new Triggers(...)` call sites pass an update
callback any more — the bridge was a leftover from the pre-UserDraft
era when ScriptBuilder ran its own localStorage autosave and had to
be notified on every triggers mutation. The unified UserDraft handle
now deep-tracks `script.draft_triggers` via the $effect in
ScriptBuilder, so the callback channel is dead weight.

Removes the third constructor parameter, the private field, and the
six `this.#updateDraftCallback?.()` invocations across setters and
mutators.

* docs: review nits — variable.edited_at backfill, UserDraft toast/modal headers

Three low-priority callouts:

- Document the variable.edited_at backfill in the migration. All
  existing rows get a single `now()` timestamp from the column
  DEFAULT; the staleness check only consumes the field as an opaque
  rev string and never displays/sorts on it, so the collision is
  harmless — but worth saying out loud.
- Add module headers to userDraftToast.ts and LocalDraftStaleModal.svelte
  explaining how this layer sits above the per-browser UserDraft
  autosave and is distinct from the backend DraftService (the
  server-side "Save as draft" feature surfaced as `*.draft`).

* refactor(frontend): replace UserDraft.release() with useMany()

Public surface change:
- New `UserDraft.useMany(getSpecs: () => UserDraftSpec<V>[])` returns a
  reactive array of handles. The reconcile loop acquires entries for
  added specs, releases entries for removed specs, and re-uses cached
  handles for unchanged keys so caller-captured references stay stable.
- `UserDraft.use(kind, path, opts?)` becomes a 1-len wrapper around
  `useMany`. The spec getter is `untrack`ed so reactive opts
  (`$workspaceStore` etc.) are still captured-once — current `use()`
  semantics unchanged.
- `UserDraftHandle.release()` and the `manualRelease` option are gone.
  Component teardown is handled by a single internal `onDestroy` that
  releases every entry `useMany` acquired.

ResourceEditor + VariableEditor migrated:
- Replaced `Record<ws, Handle>` + manual `ensureHandle`/`release` with
  a `workspaceSpecs: $state<Array<{ws, defaultValue}>>` plus a
  derived `Record<ws, Handle>` that pairs each ws with its parallel
  handle from `useMany`. `ensureHandle(ws)` is now just a push to
  the specs array; `VariableEditor.reset()` clears it. The reconcile
  loop handles acquisition/release end-to-end.

Tests:
- Dropped the `manualRelease`/`release` test; the option no longer
  exists.
- Added a `useMany` test asserting per-spec entries, isolated
  workspace-scoped localStorage keys, and a single onDestroy
  registration covering every acquired entry.

Implementation note: I tried wrapping `useLocalStorageValue` in
`$effect.root` to give the entry's `$state`/`$effect` an independent
scope (in case `useMany`'s reconcile effect tore down nested effects
across cycles). But `$effect.root`'s callback wasn't running
synchronously in the test runtime (vitest + svelte-vite plugin), and
the original `use()` implementation called `useLocalStorageValue`
directly without issue. Reverted to the direct call; the
nested-scope concern stays theoretical.

* fix(frontend): isolate UserDraft entries via $effect.root

The previous commit landed `useMany` calling `useLocalStorageValue`
directly. That works for the `use()` 1-spec wrapper (whose getter is
untracked, so the reconcile `$effect` never re-runs), but for dynamic
specs (ResourceEditor / VariableEditor) it leaks the persist `$effect`
into the reconcile `$effect`'s scope — meaning the second spec change
would destroy the first entry's deep-mutation persist loop.

Wrap the `useLocalStorageValue` creation in `$effect.root` so the
entry's reactivity lives in its own scope. Stash the returned
disposer on the entry and invoke it when the refcount hits 0.

The vitest runtime's `$effect.root` returns its disposer but never
runs the callback (a test-env quirk, not a production behaviour).
Kept a documented fallback that calls `useLocalStorageValue` directly
when the callback doesn't populate `stateRef`. In tests that path
parents the persist `$effect` to the test scope and lives long
enough; in production `$effect.root` runs the callback synchronously
per the Svelte 5 spec and the fallback is unreachable.

* chore(frontend): drop leftover console.log in setDraftConfig

Co-authored-by: Diego Imbert <diegoimbert@users.noreply.github.com>
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(frontend): wire ?nodraft=true to actually skip the local autosave on /edit

The flows/apps/apps_raw `/edit` routes had a `?nodraft=true` handler
that just stripped the param from the URL via `afterNavigate` —
nothing behind it. The original pre-PR semantics (and what every
caller assumes) was "skip the localStorage autosave on this load."

Mirror the synchronous wipe pattern already in /add: when nodraft is
present, call `UserDraft.remove(kind, path)` and strip the flag from
the URL via `window.history.replaceState`, before the UserDraft handle
is created. The handle then reads an empty entry and the editor opens
on the backend version. A plain reload (no nodraft) restores the
autosave normally.

Removed the redundant `afterNavigate` blocks. Dropped the now-unused
`afterNavigate` import in all three; apps/edit still imports
`replaceState` (used downstream), so only that name stayed.

* feat(frontend): GC UserDraft entries older than 30 days

Without a sweep, a heavy user accumulates one localStorage entry per
(workspace, kind, path) they ever touched. The pre-PR single-key
autosave self-capped at one entry per editor; this one needs an
explicit GC pass.

Mechanism:
- Stamp every persist with `lastWrittenAt: Date.now()`. Added at four
  sites: `useLocalStorageValue`'s new `transformBeforePersist`
  option (covers both setter and deep-mutation persists),
  `UserDraft.save`'s no-handle fallback, `persistDirect` (force-meta
  writes), and the legacy migration. Done at persist time, not in
  `wrap()`, so deep mutations bump the clock too — `wrap()` runs only
  on `.draft =` assignments, which would leave the timestamp stale for
  bind-mutated editor sessions.
- `gcUserDrafts(maxAgeMs = 30d)` walks every `userdraft/w/...` key,
  removes the ones older than the cutoff. Entries written before this
  field existed (pre-PR or pre-this-commit) get backfilled with the
  current time on first sweep so a 30-day clock starts fresh; the
  alternative — sweeping on sight — would wipe work that the legacy
  migration just rescued.
- Wired into the logged-in layout: runs once on mount and every 30 min
  via `setInterval` (cleaned up in the effect's return).

Tests use `vi.setSystemTime` to drive the clock; assertions on the
stored payload now go through a `storedShape` helper that strips
`lastWrittenAt` before string-comparing, so the existing
`expect(...).toBe(wrapped(...))` style still reads cleanly. New tests
cover the sweep, the backfill behaviour, the default 30d window, and
a custom `maxAgeMs`.

* fix(frontend): break useMany reconcile feedback loop

The reconcile effect read `handles.length` / `handles[i]` for the
"unchanged?" early-exit optimisation and then `handles.splice(...)`
to publish the new array. Reading `handles` inside the effect
registered it as a dependency; the subsequent splice re-fired the
effect; ad infinitum (Svelte threw
`effect_update_depth_exceeded`).

Wrap the comparison reads in `untrack` so the effect's only
tracked dependency stays `getSpecs()`. The splice still fires the
downstream readers of `handles` (the whole point of `useMany`'s
reactivity); it just doesn't re-enter its own producer.

* fix(frontend): untrack the splice's own .length read in useMany reconcile

The previous fix wrapped only the comparison reads in `untrack`, but
`handles.splice(0, handles.length, ...next)` still reads `.length`
under the effect's tracking scope — same feedback loop, same
`effect_update_depth_exceeded`.

Move the whole "compare + splice" block inside `untrack`. The
downstream notification on splice still fires (untrack suppresses
dependency subscriptions on the producer side, not write
notifications), so consumers of `handles` still re-render.

* nit

* fix(frontend): drop in-memory handle before reloading after DB-draft discard

When the "Script/flow loaded from latest saved draft" toast's
"Reset to deployed" action ran, it:
1. Deleted the DB draft via DraftService.deleteDraft.
2. Called UserDraft.remove (clears localStorage only).
3. Called goto + loadScript / loadFlow.

But the handle's in-memory state still held the now-deleted DB draft
and its meta (remoteDraftRev pointing at the gone draft's created_at).
On the reload, the editor's loadScript/loadFlow saw `localDraft !=
undefined` and ran the staleness check, which compared
`meta.remoteDraftRev = <old timestamp>` against
`currentDraftRev = undefined`. Verdict: "version" stale → spurious
"A newer version was deployed on the server" modal, even though
nothing on the server actually moved. The editor visibly froze
behind the modal because the in-memory state wasn't refreshed.

Drop the in-memory state with `handle.setDraftAndMeta(undefined, {})`
before the reload — same fix already applied to the
"Restored from local storage > Reset to deployed" toast action.

apps/edit and apps_raw/edit's "discard draft" actions don't call
DraftService.deleteDraft (they just swap the in-memory view to the
deployed branch), so they don't hit this codepath.

* fix(frontend): drop in-memory handle in DiffDrawer restoreDraft/restoreDeployed

Same UserDraft.remove-without-clearing-in-memory bug as the previous
two commits, this time in the DiffDrawer's "Restore to draft" /
"Restore to deployed" buttons on all four /edit routes. The handler
deletes the DB draft (in the deployed case), wipes the localStorage
entry, navigates, and reloads — but the route's UserDraft handle
still holds the old draft + meta in memory, so the reload's
staleness check compares the stale meta against the freshly fetched
backend and surfaces a spurious "newer version was deployed" modal.

- scripts/edit, flows/edit, apps_raw/edit: route-level handle —
  `handle.setDraftAndMeta(undefined, {})` before the reload.
- apps/edit: the handle lives in the AppEditor child, so force a
  remount by setting `app = undefined; redraw++` before goto/loadApp
  (matches the existing pattern from the toast's onResetToDeployed).

* fix(frontend): legacy app migration matches actual stored shape

Legacy AppEditor wrote `encodeState($appStore)` — the inner App value
(grid/fullscreen/theme/unusedInlineScripts/hiddenInlineScripts), not the
wrapping AppWithLastVersion. The plausibility check was matching the
wrapping fields, so real legacy app entries were filtered out and never
migrated to the new userdraft/w/{ws}/app/{path} keys.

* fix(frontend): untrack meta-preservation reads in UserDraft setters

`set draft`, `setMeta`, `UserDraft.save`, and `UserDraft.saveMeta` all
read `state.val` before writing it (to preserve existing rev metadata).
When called from inside a `$effect` — as AppEditor does to mirror its
reactive `$state` into the handle — the read subscribes the effect to
the entry's `$state` cell that the write then mutates, producing an
`effect_update_depth_exceeded` loop. Wrap the reads in `untrack` so
mirrors don't self-trigger.

* fix(frontend): apps detect drift + restore on /apps/add reload

Two related issues in the app editor's UserDraft wiring:

1. Drift wasn't detected on first deploy/draft after starting an
   autosave. The route only backfilled meta on a reload that found a
   local diff — so the first external change after editing slipped
   through with empty `previousMeta`. AppEditor now receives the
   load-time revs as `initialRevs` and seeds them into the handle's
   meta on the first mirror, capturing the rev at autosave-creation
   time.

2. /apps/add didn't restore from LS on plain reload. The route
   always initialised `value` to `emptyApp()` and the AppEditor's
   `stateApp` captured the prop unconditionally, so the LS autosave
   was shadowed. `stateApp` now falls back to `appDraftHandle.draft`
   when present; the template/hub/import branches explicitly
   `UserDraft.remove('app', '')` to keep "start fresh from this
   content" semantics.

Also work around `useLocalStorageValue`'s `saveInitialValue: false`
skip slot — in the mirror pattern the slot survived past mount and
swallowed the user's first edit. Consume it up-front with a
wipe-then-restore pair so subsequent edits persist normally.

* feat(frontend): restored-from-local toast in resource/variable editors

Resource and variable editors silently loaded LS autosaves over the
backend value, leaving users with no signal that the form wasn't
reflecting deployed state. Both now fire the standard
`notifyRestoredFromLocal` toast (with a "Reset to deployed" action
that re-seeds the handle from the just-fetched backend) the first
time a lazy-fetch finds the local draft diverging from the remote.

* fix(frontend): add UserDraft.discard so "Reset to deployed" doesn't re-persist

The "Reset to deployed" toast action in resource/variable editors
called UserDraft.save with the backend value to repaint the form. That
left a duplicate-of-backend autosave in localStorage which would
silently restore on every subsequent reload, defeating the reset.

New UserDraft.discard(itemKind, path, fallback) clears LS AND resets
any live handle's in-memory state to the fallback, skipping the next
persist so the fallback doesn't round-trip back into storage. Backed
by a new `skipNextWriteOnce()` method on useLocalStorageValue's return.

* fix(frontend): use UserDraft.discard in apps reset flows

The apps editor route doesn't hold the UserDraft handle — AppEditor
(the child remounted by {#key redraw}) does. When a reset action ran
`UserDraft.remove` + `redraw++`, Svelte could mount the new AppEditor
before the old one's onDestroy released its handle, leaving the
entry's in-memory state.val populated with the stale autosave. The
new AppEditor would then re-acquire that entry and shadow the
just-emptied localStorage.

Switch every reset path (stale modal Load latest, restored-from-local
toast, DiffDrawer restoreDraft/restoreDeployed) to `UserDraft.discard`
so the in-memory cell is cleared synchronously alongside LS. Also
plumb `currentRevs` updates so the next mount's initialRevs reflects
the acked state.

* fix(frontend): /flows/add restores autosave on plain reload

`loadFlow()` initialised the local `flow` variable to `emptyFlow()`,
then passed it to `initFlow` which writes it to `flowStore.val` (=
`flowHandle.draft = flow`). On a bare /flows/add reload (no
template/hub/import/fork/urlHash) the assignment overwrote the
persisted autosave with the empty baseline. Seed `flow` from
`flowHandle.draft` instead, and keep `emptyFlow()` as the explicit
"start fresh" baseline for template/hub branches.

* nit rename

* fix(frontend): snapshot UserDraft proxy before structuredClone in resource save

`states[ws].draft` is now a Svelte $state proxy (it flows through
UserDraft's useLocalStorageValue cell). `structuredClone` can't clone a
proxy and threw "Failed to execute 'structuredClone' on 'Window'",
blocking resource saves. Snapshot to a plain object via
`$state.snapshot` before assigning the dirty baseline.

* fix(frontend): raw app deploy toast crash + harden Toast against bad type

RawAppEditorHeader's catch blocks called `sendUserToast(msg, e)`,
passing an Error as the `_type` arg. `classes[<Error>]` is undefined so
`color.descriptionClass` threw — and because the toast renders in the
root layout, it crashed the whole page on raw app deploy/create. Fixed
both call sites to the proper `(msg, true)` error form.

Also hardened Toast.svelte: coerce any non-AlertType `type` to 'error'
so a future miscall degrades to a plain error toast instead of taking
down the page.

* fix(frontend): /apps_raw/add restores autosave on plain reload

The route initialised files/runnables/data/summary to hardcoded
defaults, and the $effect mirror then wrote those defaults over the
persisted empty-path autosave. Seed the $state from
`draftHandle.draft` instead; import/template/hub branches
`UserDraft.remove('raw_app', '')` for explicit "start fresh"
semantics. Also consume useLocalStorageValue's saveInitialValue=false
skip slot (wipe-then-restore) so the user's first edit isn't dropped.

* feat(frontend): staleness modal in resource/variable editors

Resource/variable editors only showed the restored-from-local toast;
they never surfaced the staleness modal when the backend item moved on
since the local autosave was written. Wire LocalDraftStaleModal +
checkStaleness using the backend `edited_at` as `remoteRev` (these
items have no DB-draft concept). Meta is backfilled on reload for
legacy autosaves and seeded on the first real edit via a guarded
effect, so an external edit is detectable as drift. Per-workspace
detection; the modal is a singleton driven by `pendingStale`.

* feat(frontend): restored-from-local toast in standalone trigger editors

The schedule/postgres/http/kafka/websocket/email/sqs/nats/gcp/azure/
mqtt editors silently overlaid the local UserDraft autosave on top of
the backend config in `openEdit`, with no signal that the form wasn't
showing deployed state. Each now snapshots the just-loaded backend
config, then fires `notifyRestoredFromLocal` with a "Reset to
deployed" action that drops the LS entry and re-applies the snapshot.

* fix(frontend): trigger autosave no longer false-restores on plain open

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* refactor(frontend): live UserDraft handle for trigger editors

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* refactor(frontend): live UserDraft sync for raw app editors

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* refactor(frontend): extract useTriggerDraftSync composable

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* docs(frontend): trim rot-prone comments in UserDraft

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* in /script, put code state in URL

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>
Co-authored-by: Diego Imbert <diegoimbert@users.noreply.github.com>
2026-05-20 14:58:26 +00:00

3407 lines
114 KiB
Rust

use std::{collections::HashMap, sync::Arc};
/*
* Author: Ruben Fiszel
* Copyright: Windmill Labs, Inc 2022
* This file and its contents are licensed under the AGPLv3 License.
* Please see the included NOTICE for copyright information and
* LICENSE-AGPL for a copy of the license.
*/
use crate::{
auth::{get_end_user_email, OptTokened},
db::{ApiAuthed, DB},
jobs::RunJobQuery,
users::{require_owner_of_path, require_path_read_access_for_preview, OptAuthed},
utils::{check_scopes, WithStarredInfoQuery},
webhook_util::{WebhookMessage, WebhookShared},
HTTP_CLIENT,
};
#[cfg(feature = "parquet")]
use crate::{
job_helpers_oss::{
download_s3_file_internal, get_random_file_name, get_s3_resource,
get_workspace_s3_resource_and_check_paths, upload_file_from_req, DownloadFileQuery,
},
users::fetch_api_authed_from_permissioned_as,
};
use axum::response::Response;
use axum::{
body::Body,
extract::{Extension, Json, Multipart, Path, Query},
response::IntoResponse,
routing::{delete, get, post},
Router,
};
use futures::future::{FutureExt, TryFutureExt};
use hyper::StatusCode;
#[cfg(feature = "parquet")]
use itertools::Itertools;
use lazy_static::lazy_static;
use magic_crypt::MagicCryptTrait;
#[cfg(feature = "parquet")]
use regex::Regex;
use serde::{Deserialize, Serialize};
use serde_json::{json, value::RawValue};
use sha2::{Digest, Sha256};
use sql_builder::{bind::Bind, SqlBuilder};
use sqlx::{types::Uuid, FromRow};
use std::str;
use windmill_audit::audit_oss::{audit_log, AuditAuthorable};
use windmill_audit::ActionKind;
use windmill_common::{
apps::{AppScriptId, ListAppQuery, APP_WORKSPACED_ROUTE},
auth::TOKEN_PREFIX_LEN,
cache::{self, future::FutureCachedExt},
db::{DbWithOptAuthed, UserDB},
error::{to_anyhow, Error, JsonResult, Result},
jobs::{
get_payload_tag_from_prefixed_path, resolve_delete_after_secs, schedule_job_deletion,
JobPayload, RawCode,
},
users::username_to_permissioned_as,
utils::{
http_get_from_hub, not_found_if_none, paginate, query_elems_from_hub, require_admin,
Pagination, RunnableKind, StripPath,
},
variables::{build_crypt, build_crypt_with_key_suffix, encrypt},
worker::{to_raw_value, CLOUD_HOSTED},
workspaces::{check_deploy_rules, RuleCheckResult},
HUB_BASE_URL,
};
#[cfg(feature = "parquet")]
use windmill_object_store::object_store_reexports::{Attribute, Attributes};
use windmill_store::resources::get_resource_value_interpolated_internal;
use windmill_git_sync::{handle_deployment_metadata, DeployedObject};
use windmill_queue::{push, PushArgs, PushArgsOwned, PushIsolationLevel};
#[cfg(feature = "parquet")]
use hmac::Mac;
#[cfg(feature = "parquet")]
use windmill_common::{jwt, oauth2::HmacSha256, variables::get_workspace_key};
#[cfg(feature = "parquet")]
use windmill_types::s3::{S3Object, S3Permission};
pub fn workspaced_service(raw_app_body_limit: usize) -> Router {
Router::new()
.route("/list", get(list_apps))
.route("/list_search", get(list_search_apps))
.route("/get/p/{*path}", get(get_app))
.route("/get/lite/{*path}", get(get_app_lite))
.route("/get/draft/{*path}", get(get_app_w_draft))
.route("/secret_of/{*path}", get(get_secret_id))
.route(
"/secret_of_latest_version/{*path}",
get(get_latest_version_secret_id),
)
.route("/get/v/{*id}", get(get_app_by_id))
.route("/get_data/v/{*id}", get(get_raw_app_data))
.route("/exists/{*path}", get(exists_app))
.route("/update/{*path}", post(update_app))
.route(
"/update_raw/{*path}",
post(update_app_raw).layer(axum::extract::DefaultBodyLimit::max(raw_app_body_limit)),
)
.route("/delete/{*path}", delete(delete_app))
.route("/create", post(create_app))
.route(
"/create_raw",
post(create_app_raw).layer(axum::extract::DefaultBodyLimit::max(raw_app_body_limit)),
)
.route("/history/p/{*path}", get(get_app_history))
.route("/get_latest_version/{*path}", get(get_latest_version))
.route(
"/history_update/a/{id}/v/{version}",
post(update_app_history),
)
.route(
"/list_paths_from_workspace_runnable/{runnable_kind}/{*path}",
get(list_paths_from_workspace_runnable),
)
.route(
"/custom_path_exists/{*custom_path}",
get(custom_path_exists),
)
.route("/sign_s3_objects", post(sign_s3_objects))
}
pub fn unauthed_service() -> Router {
Router::new()
.route("/execute_component/{*path}", post(execute_component))
.route("/upload_s3_file/{*path}", post(upload_s3_file_from_app))
.route("/delete_s3_file", delete(delete_s3_file_from_app))
.route("/download_s3_file/{*path}", get(download_s3_file_from_app))
.route("/public_app/{secret}", get(get_public_app_by_secret))
.route("/public_resource/{*path}", get(get_public_resource))
.route("/get_data/v/{*id}", get(get_raw_app_data))
}
pub fn global_service() -> Router {
Router::new()
.route("/hub/list", get(list_hub_apps))
.route("/hub/get/{id}", get(get_hub_app_by_id))
.route("/hub/get_raw/{id}", get(get_hub_raw_app_by_id))
}
#[derive(FromRow, Deserialize, Serialize)]
pub struct ListableApp {
pub id: i64,
pub workspace_id: String,
pub path: String,
pub summary: String,
pub version: i64,
pub extra_perms: serde_json::Value,
pub execution_mode: String,
pub starred: bool,
pub edited_at: Option<chrono::DateTime<chrono::Utc>>,
pub has_draft: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub draft_only: Option<bool>,
#[sqlx(default)]
#[serde(skip_serializing_if = "Option::is_none")]
pub deployment_msg: Option<String>,
#[serde(skip_serializing_if = "is_false")]
pub raw_app: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub labels: Option<Vec<String>>,
}
fn is_false(b: &bool) -> bool {
!b
}
// #[derive(FromRow, Serialize, Deserialize)]
// pub struct AppVersion {
// pub id: i64,
// pub app_id: Uuid,
// pub value: sqlx::types::Json<Box<RawValue>>,
// pub created_by: String,
// pub created_at: chrono::DateTime<chrono::Utc>,
// }
#[derive(Debug, Serialize, Deserialize, FromRow)]
pub struct AppWithLastVersion {
pub id: i64,
pub path: String,
pub summary: String,
pub policy: sqlx::types::Json<Box<RawValue>>,
pub versions: Vec<i64>,
pub value: sqlx::types::Json<Box<RawValue>>,
pub created_by: String,
pub created_at: chrono::DateTime<chrono::Utc>,
pub extra_perms: Option<serde_json::Value>,
#[serde(skip_serializing_if = "Option::is_none")]
pub custom_path: Option<String>,
pub raw_app: bool,
#[sqlx(skip)]
#[serde(skip_serializing_if = "Option::is_none")]
pub bundle_secret: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub labels: Option<Vec<String>>,
}
#[derive(Serialize, FromRow)]
pub struct AppWithLastVersionAndStarred {
#[sqlx(flatten)]
#[serde(flatten)]
pub app: AppWithLastVersion,
#[serde(skip_serializing_if = "Option::is_none")]
pub starred: Option<bool>,
}
#[derive(Serialize, Deserialize, FromRow)]
pub struct AppWithLastVersionAndDraft {
#[sqlx(flatten)]
#[serde(flatten)]
pub app: AppWithLastVersion,
#[serde(skip_serializing_if = "Option::is_none")]
pub draft: Option<sqlx::types::Json<Box<RawValue>>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub draft_only: Option<bool>,
/// Timestamp at which the most recent DB draft was created.
#[serde(skip_serializing_if = "Option::is_none")]
pub draft_created_at: Option<chrono::DateTime<chrono::Utc>>,
}
#[derive(Serialize)]
pub struct AppHistory {
pub app_id: i64,
pub version: i64,
#[serde(skip_serializing_if = "Option::is_none")]
pub deployment_msg: Option<String>,
}
#[derive(Deserialize)]
pub struct AppHistoryUpdate {
pub deployment_msg: Option<String>,
}
pub type StaticFields = HashMap<String, Box<RawValue>>;
pub type OneOfFields = HashMap<String, Vec<Box<RawValue>>>;
pub type AllowUserResources = Vec<String>;
#[derive(Serialize, Deserialize, Debug, PartialEq, Copy, Clone, Default)]
#[serde(rename_all = "lowercase")]
pub enum ExecutionMode {
#[default]
Anonymous,
Publisher,
Viewer,
}
#[derive(Serialize, Deserialize, Debug, Clone, Default)]
pub struct PolicyTriggerableInputs {
static_inputs: StaticFields,
one_of_inputs: OneOfFields,
#[serde(default)]
allow_user_resources: AllowUserResources,
#[serde(default, skip_serializing_if = "Option::is_none")]
delete_after_secs: Option<i32>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
sensitive_inputs: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
tag: Option<String>,
}
#[derive(Serialize, Deserialize, Debug, Clone)]
pub struct S3Input {
allowed_resources: Vec<String>,
allow_user_resources: bool,
allow_workspace_resource: bool,
file_key_regex: String,
}
#[derive(Serialize, Deserialize, Debug, Clone)]
pub struct S3Key {
s3_path: String,
#[serde(skip_serializing_if = "Option::is_none")]
storage: Option<String>,
}
#[derive(Serialize, Deserialize, Debug, Clone, Default)]
pub struct Policy {
pub on_behalf_of: Option<String>,
pub on_behalf_of_email: Option<String>,
//paths:
// - script/<path>
// - flow/<path>
// - rawscript/<sha256>
#[serde(skip_serializing_if = "Option::is_none")]
pub triggerables: Option<HashMap<String, StaticFields>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub triggerables_v2: Option<HashMap<String, PolicyTriggerableInputs>>,
pub execution_mode: ExecutionMode,
pub s3_inputs: Option<Vec<S3Input>>,
pub allowed_s3_keys: Option<Vec<S3Key>>,
}
#[derive(Deserialize)]
pub struct CreateApp {
pub path: String,
pub summary: String,
pub value: sqlx::types::Json<Box<RawValue>>,
pub policy: Policy,
pub draft_only: Option<bool>,
pub deployment_message: Option<String>,
pub custom_path: Option<String>,
pub preserve_on_behalf_of: Option<bool>,
#[serde(default)]
pub labels: Option<Vec<String>>,
}
#[derive(Serialize, Deserialize)]
pub struct EditApp {
pub path: Option<String>,
pub summary: Option<String>,
pub value: Option<sqlx::types::Json<Box<RawValue>>>,
pub policy: Option<Policy>,
pub deployment_message: Option<String>,
pub custom_path: Option<String>,
pub preserve_on_behalf_of: Option<bool>,
#[serde(default)]
pub labels: Option<Vec<String>>,
}
#[derive(Serialize, FromRow)]
pub struct SearchApp {
path: String,
value: sqlx::types::Json<Box<RawValue>>,
}
async fn list_search_apps(
authed: ApiAuthed,
Path(w_id): Path<String>,
Extension(user_db): Extension<UserDB>,
) -> JsonResult<Vec<SearchApp>> {
#[cfg(feature = "enterprise")]
let n = 1000;
#[cfg(not(feature = "enterprise"))]
let n = 3;
let mut tx = user_db.begin(&authed).await?;
let rows = sqlx::query_as::<_, SearchApp>(
"SELECT path, app_version.value from app LEFT JOIN app_version ON app_version.id = versions[array_upper(versions, 1)] WHERE workspace_id = $1 LIMIT $2",
)
.bind(&w_id)
.bind(n)
.fetch_all(&mut *tx)
.await?
.into_iter()
.collect::<Vec<_>>();
tx.commit().await?;
Ok(Json(rows))
}
async fn list_apps(
authed: ApiAuthed,
Extension(user_db): Extension<UserDB>,
Path(w_id): Path<String>,
Query(pagination): Query<Pagination>,
Query(lq): Query<ListAppQuery>,
) -> JsonResult<Vec<ListableApp>> {
let (per_page, offset) = paginate(pagination);
let mut sqlb = SqlBuilder::select_from("app")
.fields(&[
"app.id",
"app.workspace_id",
"app.path",
"app.summary",
"app.versions[array_upper(app.versions, 1)] as version",
"app.policy->>'execution_mode' as execution_mode",
"app_version.created_at as edited_at",
"app.extra_perms",
"favorite.path IS NOT NULL as starred",
"draft.path IS NOT NULL as has_draft",
"draft_only",
"app_version.raw_app",
"app.labels",
])
.left()
.join("favorite")
.on(
"favorite.favorite_kind = 'app' AND favorite.workspace_id = app.workspace_id AND favorite.path = app.path AND favorite.usr = ?"
.bind(&authed.username),
)
.left()
.join("app_version")
.on(
"app_version.id = versions[array_upper(versions, 1)]"
)
.left()
.join("draft")
.on(
"draft.path = app.path AND draft.workspace_id = app.workspace_id AND draft.typ = 'app'"
)
.order_desc("favorite.path IS NOT NULL")
.order_by("app_version.created_at", true)
.and_where("app.workspace_id = ?".bind(&w_id))
.offset(offset)
.limit(per_page)
.clone();
if lq.starred_only.unwrap_or(false) {
sqlb.and_where_is_not_null("favorite.path");
}
if let Some(path_start) = &lq.path_start {
sqlb.and_where_like_left("app.path", path_start);
}
if let Some(path_exact) = &lq.path_exact {
sqlb.and_where_eq("app.path", "?".bind(path_exact));
}
if !lq.include_draft_only.unwrap_or(false) || authed.is_operator {
sqlb.and_where("app.draft_only IS NOT TRUE");
}
if let Some(label) = &lq.label {
for l in label.split(',') {
sqlb.and_where("app.labels @> ARRAY[?]".bind(&l.trim()));
}
}
if lq.with_deployment_msg.unwrap_or(false) {
sqlb.join("deployment_metadata dm")
.left()
.on("dm.app_version = app.versions[array_upper(app.versions, 1)]")
.fields(&["dm.deployment_msg"]);
}
let sql = sqlb.sql().map_err(|e| Error::internal_err(e.to_string()))?;
let mut tx = user_db.begin(&authed).await?;
let rows = sqlx::query_as::<_, ListableApp>(&sql)
.fetch_all(&mut *tx)
.await?;
tx.commit().await?;
Ok(Json(rows))
}
async fn get_raw_app_data(
Path((w_id, secret_with_ext)): Path<(String, String)>,
Extension(db): Extension<DB>,
) -> Result<Response> {
#[cfg(all(feature = "enterprise", feature = "parquet"))]
let object_store = windmill_object_store::get_object_store().await;
// tracing::info!("secret_with_ext: {}", secret_with_ext);
let mut splitted = secret_with_ext.split('.');
let secret_id = splitted.next().unwrap_or("");
if secret_id.is_empty() {
return Err(Error::BadRequest("Invalid secret".to_string()));
}
let id = get_id_from_secret(
&db,
&w_id,
secret_id.to_string(),
Some(BUNDLE_SECRET_PREFIX),
)
.await?;
let file_type = splitted.next().unwrap_or("");
let file_type = if file_type == "css" {
"css"
} else if file_type == "js" {
"js"
} else {
return Err(Error::BadRequest(
"Invalid file type, only .css and .js are supported".to_string(),
));
};
// tracing::info!("file_type: {}", file_type);
#[allow(unused_assignments)]
let mut body: Option<Body> = None;
#[cfg(all(feature = "enterprise", feature = "parquet"))]
if let Some(os) = object_store {
let path = format!("/app_bundles/{}/{}.{}", w_id, id, file_type);
match os
.get(&windmill_object_store::object_store_reexports::Path::from(
path,
))
.await
{
Ok(result) => {
let stream = result
.bytes()
.await
.map_err(windmill_object_store::object_store_error_to_error)?;
tracing::info!("stream: {}", stream.len());
body = Some(Body::from(stream));
}
Err(windmill_object_store::object_store_reexports::ObjectStoreError::NotFound {
..
}) => {
// S3 key not found, fall through to DB lookup below
}
Err(e) => {
return Err(windmill_object_store::object_store_error_to_error(e));
}
}
}
if body.is_none() {
let get_raw_app_file = sqlx::query_scalar!(
"SELECT data FROM app_bundles WHERE app_version_id = $1 AND file_type = $2 AND w_id = $3",
id,
file_type,
&w_id,
)
.fetch_optional(&db)
.await?;
if let Some(file) = get_raw_app_file {
body = Some(Body::from(file));
}
}
if let Some(body) = body {
// let stream = tokio_util::io::ReaderStream::new(file);
let res = Response::builder().header(
http::header::CONTENT_TYPE,
if file_type == "css" {
"text/css"
} else {
"text/javascript"
},
);
Ok(res.body(body).unwrap())
} else {
return Err(Error::NotFound("File not found".to_string()));
}
}
// async fn get_app_version(
// authed: ApiAuthed,
// Extension(user_db): Extension<UserDB>,
// Path((w_id, path)): Path<(String, StripPath)>,
// ) -> JsonResult<i64> {
// let path = path.to_path();
// let mut tx = user_db.begin(&authed).await?;
// let version_o = sqlx::query_scalar!(
// "SELECT app.versions[array_upper(app.versions, 1)] as version FROM app
// WHERE app.path = $1 AND app.workspace_id = $2",
// path,
// &w_id,
// )
// .fetch_optional(&mut *tx)
// .await?
// .flatten();
// tx.commit().await?;
// let version = not_found_if_none(version_o, "App", path)?;
// Ok(Json(version))
// }
async fn get_app(
authed: ApiAuthed,
Extension(user_db): Extension<UserDB>,
Path((w_id, path)): Path<(String, StripPath)>,
Query(query): Query<WithStarredInfoQuery>,
) -> JsonResult<AppWithLastVersionAndStarred> {
let path = path.to_path();
check_scopes(&authed, || format!("apps:read:{}", path))?;
let mut tx = user_db.begin(&authed).await?;
let app_o = if query.with_starred_info.unwrap_or(false) {
sqlx::query_as::<_, AppWithLastVersionAndStarred>(
"SELECT app.id, app.path, app.summary, app.versions, app.policy, app.custom_path,
app.extra_perms, app_version.value,
app_version.created_at, app_version.created_by, favorite.path IS NOT NULL as starred, app_version.raw_app, app.labels
FROM app
JOIN app_version
ON app_version.id = app.versions[array_upper(app.versions, 1)]
LEFT JOIN favorite
ON favorite.favorite_kind = 'app'
AND favorite.workspace_id = app.workspace_id
AND favorite.path = app.path
AND favorite.usr = $3
WHERE app.path = $1 AND app.workspace_id = $2",
)
.bind(path.to_owned())
.bind(&w_id)
.bind(&authed.username)
.fetch_optional(&mut *tx)
.await?
} else {
sqlx::query_as::<_, AppWithLastVersionAndStarred>(
"SELECT app.id, app.path, app.summary, app.versions, app.policy, app.custom_path,
app.extra_perms, app_version.value,
app_version.created_at, app_version.created_by, NULL as starred, app_version.raw_app, app.labels
FROM app, app_version
WHERE app.path = $1 AND app.workspace_id = $2 AND app_version.id = app.versions[array_upper(app.versions, 1)]",
)
.bind(path.to_owned())
.bind(&w_id)
.fetch_optional(&mut *tx)
.await?
};
tx.commit().await?;
let app = not_found_if_none(app_o, "App", path)?;
Ok(Json(app))
}
async fn get_app_lite(
authed: ApiAuthed,
Extension(user_db): Extension<UserDB>,
Path((w_id, path)): Path<(String, StripPath)>,
) -> JsonResult<AppWithLastVersion> {
let path = path.to_path();
check_scopes(&authed, || format!("apps:read:{}", path))?;
let mut tx = user_db.begin(&authed).await?;
let app_o = sqlx::query_as::<_, AppWithLastVersion>(
"SELECT app.id, app.path, app.summary, app.versions, app.policy, app.custom_path,
app.extra_perms, coalesce(app_version_lite.value::json, app_version.value) as value,
app_version.created_at, app_version.created_by, NULL as starred, app_version.raw_app, app.labels
FROM app, app_version
LEFT JOIN app_version_lite ON app_version_lite.id = app_version.id
WHERE app.path = $1 AND app.workspace_id = $2 AND app_version.id = app.versions[array_upper(app.versions, 1)]",
)
.bind(path.to_owned())
.bind(&w_id)
.fetch_optional(&mut *tx)
.await?;
tx.commit().await?;
let app = not_found_if_none(app_o, "App", path)?;
Ok(Json(app))
}
async fn get_app_w_draft(
authed: ApiAuthed,
Extension(user_db): Extension<UserDB>,
Path((w_id, path)): Path<(String, StripPath)>,
) -> JsonResult<AppWithLastVersionAndDraft> {
let path = path.to_path();
check_scopes(&authed, || format!("apps:read:{}", path))?;
let mut tx = user_db.begin(&authed).await?;
let app_o = sqlx::query_as::<_, AppWithLastVersionAndDraft>(
r#"
SELECT
app.id,
app.path,
app.summary,
app.versions,
app.policy,
app.custom_path,
app.extra_perms,
app_version.value,
app_version.created_at,
app_version.created_by,
app.draft_only,
draft.value AS "draft",
draft.created_at AS "draft_created_at",
app_version.raw_app,
app.labels
FROM app
INNER JOIN app_version
ON app_version.id = app.versions[array_upper(app.versions, 1)]
LEFT JOIN draft
ON app.path = draft.path
AND draft.workspace_id = $2
AND draft.typ = 'app'
WHERE app.path = $1
AND app.workspace_id = $2
"#,
)
.bind(path.to_owned())
.bind(&w_id)
.fetch_optional(&mut *tx)
.await?;
tx.commit().await?;
let app = not_found_if_none(app_o, "App", path)?;
Ok(Json(app))
}
async fn get_app_history(
authed: ApiAuthed,
Extension(user_db): Extension<UserDB>,
Path((w_id, path)): Path<(String, StripPath)>,
) -> JsonResult<Vec<AppHistory>> {
let path = path.to_path();
check_scopes(&authed, || format!("apps:read:{}", &path))?;
let mut tx = user_db.begin(&authed).await?;
let query_result = sqlx::query!(
"SELECT a.id as app_id, av.id as version_id, dm.deployment_msg as deployment_msg
FROM app a LEFT JOIN app_version av ON a.id = av.app_id LEFT JOIN deployment_metadata dm ON av.id = dm.app_version
WHERE a.workspace_id = $1 AND a.path = $2
ORDER BY created_at DESC",
w_id,
path,
).fetch_all(&mut *tx).await?;
tx.commit().await?;
let result: Vec<AppHistory> = query_result
.into_iter()
.map(|row| AppHistory {
app_id: row.app_id,
version: row.version_id,
deployment_msg: row.deployment_msg,
})
.collect();
return Ok(Json(result));
}
async fn get_latest_version(
authed: ApiAuthed,
Extension(user_db): Extension<UserDB>,
Path((w_id, path)): Path<(String, StripPath)>,
) -> JsonResult<Option<AppHistory>> {
let path = path.to_path();
check_scopes(&authed, || format!("apps:read:{}", path))?;
let mut tx = user_db.begin(&authed).await?;
let row = sqlx::query!(
"SELECT a.id as app_id, av.id as version_id, dm.deployment_msg as deployment_msg
FROM app a LEFT JOIN app_version av ON a.id = av.app_id LEFT JOIN deployment_metadata dm ON av.id = dm.app_version
WHERE a.workspace_id = $1 AND a.path = $2
ORDER BY created_at DESC",
w_id,
path,
).fetch_optional(&mut *tx).await?;
tx.commit().await?;
if let Some(row) = row {
let result = AppHistory {
app_id: row.app_id,
version: row.version_id,
deployment_msg: row.deployment_msg,
};
return Ok(Json(Some(result)));
} else {
return Ok(Json(None));
}
}
async fn update_app_history(
authed: ApiAuthed,
Extension(user_db): Extension<UserDB>,
Path((w_id, app_id, app_version)): Path<(String, i64, i64)>,
Json(app_history_update): Json<AppHistoryUpdate>,
) -> Result<()> {
let mut tx = user_db.begin(&authed).await?;
let app_path = sqlx::query_scalar!("SELECT path FROM app WHERE id = $1", app_id)
.fetch_optional(&mut *tx)
.await?;
let Some(app_path) = app_path else {
tx.commit().await?;
return Err(Error::NotFound(
format!("App with ID {app_id} not found").to_string(),
));
};
check_scopes(&authed, || format!("apps:write:{}", &app_path))?;
sqlx::query!(
"INSERT INTO deployment_metadata (workspace_id, path, app_version, deployment_msg) VALUES ($1, $2, $3, $4) ON CONFLICT (workspace_id, path, app_version) WHERE app_version IS NOT NULL DO UPDATE SET deployment_msg = EXCLUDED.deployment_msg",
w_id,
app_path,
app_version,
app_history_update.deployment_msg,
)
.fetch_optional(&mut *tx)
.await?;
tx.commit().await?;
return Ok(());
}
async fn custom_path_exists(
Extension(db): Extension<DB>,
Path((w_id, custom_path)): Path<(String, String)>,
) -> JsonResult<bool> {
let as_workspaced_route = APP_WORKSPACED_ROUTE.load(std::sync::atomic::Ordering::Relaxed);
let exists =
sqlx::query_scalar!(
"SELECT EXISTS(SELECT 1 FROM app WHERE custom_path = $1 AND ($2::TEXT IS NULL OR workspace_id = $2))",
custom_path,
if *CLOUD_HOSTED || as_workspaced_route { Some(&w_id) } else { None }
)
.fetch_one(&db)
.await?.unwrap_or(false);
Ok(Json(exists))
}
async fn get_app_by_id(
authed: ApiAuthed,
Extension(user_db): Extension<UserDB>,
Path((w_id, id)): Path<(String, i64)>,
) -> JsonResult<AppWithLastVersion> {
let mut tx = user_db.begin(&authed).await?;
let app_o = sqlx::query_as::<_, AppWithLastVersion>(
"SELECT app.id, app.path, app.summary, app.versions, app.policy, app.custom_path,
app.extra_perms, app_version.value,
app_version.created_at, app_version.created_by, app_version.raw_app, app.labels
FROM app, app_version
WHERE app_version.id = $1 AND app.id = app_version.app_id AND app.workspace_id = $2",
)
.bind(&id)
.bind(&w_id)
.fetch_optional(&mut *tx)
.await?;
tx.commit().await?;
let app = not_found_if_none(app_o, "App", id.to_string())?;
check_scopes(&authed, || format!("apps:read:{}", &app.path))?;
Ok(Json(app))
}
async fn get_public_app_by_secret(
OptAuthed(opt_authed): OptAuthed,
Extension(user_db): Extension<UserDB>,
Extension(db): Extension<DB>,
Path((w_id, secret)): Path<(String, String)>,
) -> JsonResult<AppWithLastVersion> {
let id = get_id_from_secret(&db, &w_id, secret, None).await?;
let app_o = sqlx::query_as::<_, AppWithLastVersion>(
"SELECT app.id, app.path, app.summary, app.versions, app.policy, app.custom_path,
null as extra_perms, coalesce(app_version_lite.value::json, app_version.value::json) as value,
app_version.created_at, app_version.created_by, app_version.raw_app, app.labels
FROM app, app_version
LEFT JOIN app_version_lite ON app_version_lite.id = app_version.id
WHERE app.id = $1 AND app.workspace_id = $2 AND app_version.id = app.versions[array_upper(app.versions, 1)]")
.bind(&id)
.bind(&w_id)
.fetch_optional(&db)
.await?;
let mut app = not_found_if_none(app_o, "App", id.to_string())?;
let policy = serde_json::from_str::<Policy>(app.policy.0.get()).map_err(to_anyhow)?;
if !matches!(policy.execution_mode, ExecutionMode::Anonymous) {
if opt_authed.is_none() {
return Err(Error::NotAuthorized(
"App visibility does not allow public access and you are not logged in".to_string(),
));
} else {
let authed = opt_authed.unwrap();
let mut tx = user_db.begin(&authed).await?;
let is_visible = sqlx::query_scalar!(
"SELECT EXISTS(SELECT 1 FROM app WHERE id = $1 AND workspace_id = $2)",
id,
&w_id
)
.fetch_one(&mut *tx)
.await?;
tx.commit().await?;
if !is_visible.unwrap_or(false) {
return Err(Error::NotAuthorized(
"App visibility does not allow public access and you are logged in but you have no read-access to that app".to_string(),
));
}
}
}
// Compute bundle_secret for raw apps
if app.raw_app {
app.bundle_secret = Some(compute_bundle_secret(&db, &w_id, &app.versions).await?);
}
Ok(Json(app))
}
async fn get_id_from_secret(
db: &DB,
w_id: &str,
secret: String,
prefix: Option<&str>,
) -> Result<i64> {
let mc = build_crypt(db, w_id).await?;
let decrypted = mc
.decrypt_bytes_to_bytes(&(hex::decode(secret)?))
.map_err(|e| Error::internal_err(e.to_string()))?;
let mut bytes = str::from_utf8(&decrypted).map_err(to_anyhow)?;
if let Some(prefix) = prefix {
if !bytes.starts_with(prefix) {
return Err(Error::BadRequest("Invalid secret".to_string()));
}
bytes = bytes.strip_prefix(prefix).unwrap_or("");
}
let id: i64 = bytes.parse().map_err(to_anyhow)?;
Ok(id)
}
async fn get_public_resource(
Extension(db): Extension<DB>,
Path((w_id, path)): Path<(String, StripPath)>,
) -> JsonResult<Option<serde_json::Value>> {
let path = path.to_path();
// This endpoint is unauthenticated (anonymous public apps must fetch their
// theme and form schemas). Both branches MUST stay tightly constrained to
// the non-sensitive resource types they serve, otherwise an unauthenticated
// caller could read the raw value of any resource at the given path.
let res = if path.starts_with("f/app_themes/") {
sqlx::query_scalar!(
"SELECT value from resource WHERE path = $1 AND workspace_id = $2 AND resource_type = 'app_theme'",
path.to_owned(),
&w_id
)
.fetch_optional(&db)
.await?
} else {
sqlx::query_scalar!(
"SELECT value from resource WHERE path = $1 AND workspace_id = $2 AND resource_type = 'json_schema'",
path.to_owned(),
&w_id
)
.fetch_optional(&db)
.await?
};
Ok(Json(res.flatten()))
}
async fn get_secret_id(
authed: ApiAuthed,
Extension(user_db): Extension<UserDB>,
Extension(db): Extension<DB>,
Path((w_id, path)): Path<(String, StripPath)>,
) -> Result<String> {
let path = path.to_path();
check_scopes(&authed, || format!("apps:read:{}", path))?;
let mut tx = user_db.begin(&authed).await?;
let id_o = sqlx::query_scalar!(
"SELECT app.id FROM app
WHERE app.path = $1 AND app.workspace_id = $2",
path,
&w_id
)
.fetch_optional(&mut *tx)
.await?;
tx.commit().await?;
let id = not_found_if_none(id_o, "App", path.to_string())?;
let mc = build_crypt(&db, &w_id).await?;
let hx = hex::encode(mc.encrypt_str_to_bytes(id.to_string()));
Ok(hx)
}
const BUNDLE_SECRET_PREFIX: &str = "bundle_";
pub async fn compute_bundle_secret(db: &DB, w_id: &str, versions: &[i64]) -> Result<String> {
let version_id = versions
.last()
.ok_or_else(|| Error::internal_err("App has no versions".to_string()))?;
let mc = build_crypt(db, w_id).await?;
let hx =
hex::encode(mc.encrypt_str_to_bytes(format!("{}{}", BUNDLE_SECRET_PREFIX, version_id)));
Ok(hx)
}
async fn get_latest_version_secret_id(
authed: ApiAuthed,
Extension(user_db): Extension<UserDB>,
Extension(db): Extension<DB>,
Path((w_id, path)): Path<(String, StripPath)>,
) -> Result<String> {
let path = path.to_path();
check_scopes(&authed, || format!("apps:read:{}", path))?;
let mut tx = user_db.begin(&authed).await?;
let id_o = sqlx::query_scalar!(
"SELECT app.versions[array_upper(app.versions, 1)] FROM app
WHERE app.path = $1 AND app.workspace_id = $2",
path,
&w_id
)
.fetch_optional(&mut *tx)
.await?
.flatten();
tx.commit().await?;
let id = not_found_if_none(id_o, "App", path.to_string())?;
let mc = build_crypt(&db, &w_id).await?;
let hx = hex::encode(mc.encrypt_str_to_bytes(format!("{}{}", BUNDLE_SECRET_PREFIX, id)));
Ok(hx)
}
async fn store_raw_app_file<'a>(
w_id: &str,
id: &i64,
file_type: &str,
data: bytes::Bytes,
tx: &mut sqlx::Transaction<'a, sqlx::Postgres>,
) -> Result<()> {
#[cfg(all(feature = "enterprise", feature = "parquet"))]
{
let object_store = windmill_object_store::get_object_store().await;
let path: String = format!("/app_bundles/{}/{}.{}", w_id, id, file_type);
if let Some(os) = object_store {
if let Err(e) = os
.put(
&windmill_object_store::object_store_reexports::Path::from(path.clone()),
data.into(),
)
.await
{
tracing::error!("Failed to put snapshot to s3 at {path}: {:?}", e);
return Err(windmill_common::error::Error::ExecutionErr(format!(
"Failed to put {path} to s3"
)));
}
tracing::info!("Successfully put snapshot to s3 at {path}");
return Ok(());
}
}
sqlx::query!(
"INSERT INTO app_bundles (app_version_id, w_id, file_type, data) VALUES ($1, $2, $3, $4)",
id,
w_id,
file_type,
data.to_vec()
)
.execute(&mut **tx)
.await?;
Ok(())
}
macro_rules! process_app_multipart {
($authed:expr, $user_db:expr, $db:expr, $w_id:expr, $path:expr, $multipart:expr, $internal_fn:expr) => {
async {
let mut saved_app = None;
let mut uploaded_js = false;
let request_size_limit_mb = *crate::REQUEST_SIZE_LIMIT.read().await / (1024 * 1024);
let raw_app_limit_mb = request_size_limit_mb * 5;
let mut multipart = $multipart;
while let Some(field) = multipart
.next_field()
.await
.map_err(|e| Error::BadRequest(format!("failed to read multipart field: {e}. Could be due to the request size limit for raw app bundles which is {raw_app_limit_mb}MB (adjustable in instance settings)")))?
{
let name = field
.name()
.ok_or_else(|| Error::BadRequest("multipart field missing name".to_string()))?
.to_string();
let data = field.bytes().await.map_err(|e| {
Error::BadRequest(format!("failed to read multipart stream: {e}. Could be due to the request size limit for raw app bundles which is {raw_app_limit_mb}MB (adjustable in instance settings)"))
})?;
if name == "app" {
let app = serde_json::from_slice(&data).map_err(to_anyhow)?;
let (ntx, npath, nid) = $internal_fn(
$authed.clone(),
$db.clone(),
$user_db.clone(),
$w_id,
$path,
true,
app,
)
.await?;
saved_app = Some((npath, nid, ntx));
} else if name == "js" {
if let Some((_npath, id, tx)) = saved_app.as_mut() {
store_raw_app_file($w_id, &id, "js", data, tx).await?;
uploaded_js = true;
} else {
return Err(Error::BadRequest(
"App payload need to be created first".to_string(),
));
}
} else if name == "css" {
if let Some((_npath, id, tx)) = saved_app.as_mut() {
store_raw_app_file($w_id, &id, "css", data, tx).await?;
} else {
return Err(Error::BadRequest(
"App payload need to be created first".to_string(),
));
}
} else {
return Err(Error::BadRequest(format!("Unsupported field: {}", name)));
}
}
if !uploaded_js {
return Err(Error::BadRequest("js or css file not uploaded".to_string()));
}
if let Some((npath, id, tx)) = saved_app {
tx.commit().await?;
Ok((npath, id))
} else {
Err(Error::BadRequest("App not created".to_string()))
}
}
};
}
async fn create_app_raw<'a>(
authed: ApiAuthed,
Extension(user_db): Extension<UserDB>,
Extension(db): Extension<DB>,
Extension(webhook): Extension<WebhookShared>,
Path(w_id): Path<String>,
multipart: Multipart,
) -> Result<(StatusCode, String)> {
if authed.is_operator {
return Err(Error::NotAuthorized(
"Operators cannot create apps for security reasons".to_string(),
));
}
if let RuleCheckResult::Blocked(msg) = check_deploy_rules(
&w_id,
AuditAuthorable::username(&authed),
&authed.groups,
authed.is_admin,
&db,
)
.await?
{
return Err(Error::PermissionDenied(msg));
}
let (path, _id) = process_app_multipart!(
authed,
user_db,
db,
&w_id,
"",
multipart,
|authed, db, user_db, w_id, _path, raw_app, app| create_app_internal(
authed, db, user_db, w_id, raw_app, app
)
)
.await?;
check_scopes(&authed, || format!("apps:write:{}", path))?;
webhook.send_message(
w_id.clone(),
WebhookMessage::CreateApp { workspace: w_id, path: path.clone() },
);
Ok((StatusCode::CREATED, path))
}
async fn list_paths_from_workspace_runnable(
authed: ApiAuthed,
Extension(user_db): Extension<UserDB>,
Path((w_id, runnable_kind, path)): Path<(String, RunnableKind, StripPath)>,
) -> JsonResult<Vec<String>> {
let mut tx = user_db.begin(&authed).await?;
let runnables = sqlx::query_scalar!(
r#"SELECT a.path
FROM workspace_runnable_dependencies wru
JOIN app a
ON wru.app_path = a.path AND wru.workspace_id = a.workspace_id
WHERE wru.runnable_path = $1 AND wru.runnable_is_flow = $2 AND wru.workspace_id = $3"#,
path.to_path(),
matches!(runnable_kind, RunnableKind::Flow),
w_id
)
.fetch_all(&mut *tx)
.await?;
tx.commit().await?;
Ok(Json(runnables))
}
async fn create_app(
authed: ApiAuthed,
Extension(user_db): Extension<UserDB>,
Extension(db): Extension<DB>,
Extension(webhook): Extension<WebhookShared>,
Path(w_id): Path<String>,
Json(app): Json<CreateApp>,
) -> Result<(StatusCode, String)> {
if authed.is_operator {
return Err(Error::NotAuthorized(
"Operators cannot create apps for security reasons".to_string(),
));
}
let path = app.path.clone();
check_scopes(&authed, || format!("apps:write:{}", &path))?;
if let RuleCheckResult::Blocked(msg) = check_deploy_rules(
&w_id,
AuditAuthorable::username(&authed),
&authed.groups,
authed.is_admin,
&db,
)
.await?
{
return Err(Error::PermissionDenied(msg));
}
let (new_tx, _path, _id) = create_app_internal(authed, db, user_db, &w_id, false, app).await?;
new_tx.commit().await?;
webhook.send_message(
w_id.clone(),
WebhookMessage::CreateApp { workspace: w_id, path: path.clone() },
);
Ok((StatusCode::CREATED, path))
}
/// Actionable error when a custom path is already taken. In global mode (not
/// CLOUD_HOSTED and `app_workspaced_route` off) custom paths are unique across
/// the whole instance, so the conflicting copy may live in another workspace
/// (e.g. the same app deployed/git-synced to staging and prod) — name it so
/// the operator knows exactly what to remove.
fn custom_path_conflict_error(
custom_path: &str,
conflict_path: &str,
conflict_workspace: &str,
scoped: bool,
) -> Error {
if scoped {
Error::BadRequest(format!(
"Custom path '{}' is already used by app '{}' in this workspace",
custom_path, conflict_path
))
} else {
Error::BadRequest(format!(
"Custom path '{}' is already used by app '{}' in workspace '{}'. \
Custom paths must be unique across the whole instance unless the \
'app_workspaced_route' instance setting is enabled.",
custom_path, conflict_path, conflict_workspace
))
}
}
async fn create_app_internal<'a>(
authed: ApiAuthed,
db: sqlx::Pool<sqlx::Postgres>,
user_db: UserDB,
w_id: &String,
raw_app: bool,
mut app: CreateApp,
) -> Result<(sqlx::Transaction<'a, sqlx::Postgres>, String, i64)> {
if *CLOUD_HOSTED {
let nb_apps =
sqlx::query_scalar!("SELECT COUNT(*) FROM app WHERE workspace_id = $1", &w_id)
.fetch_one(&db)
.await?;
if nb_apps.unwrap_or(0) >= 1000 {
return Err(Error::BadRequest(
"You have reached the maximum number of apps (1000) on cloud. Check your usage in Workspace Settings > General > Cloud Quotas. Contact support@windmill.dev to increase the limit"
.to_string(),
));
}
if app.summary.len() > 300 {
return Err(Error::BadRequest(
"Summary must be less than 300 characters on cloud".to_string(),
));
}
}
let mut tx = user_db.clone().begin(&authed).await?;
let should_preserve = app.preserve_on_behalf_of.unwrap_or(false)
&& windmill_common::can_preserve_on_behalf_of(&authed)
&& app.policy.on_behalf_of.is_some();
if !should_preserve {
let folder_default = if windmill_common::can_preserve_on_behalf_of(&authed) {
windmill_common::folders::resolve_folder_default_permissioned_as(&db, w_id, &app.path)
.await?
} else {
None
};
if let Some(default_permissioned_as) = folder_default {
let default_email = windmill_common::users::get_email_from_permissioned_as(
&default_permissioned_as,
w_id,
&db,
)
.await?;
app.policy.on_behalf_of = Some(default_permissioned_as);
app.policy.on_behalf_of_email = Some(default_email);
} else {
app.policy.on_behalf_of = Some(username_to_permissioned_as(&authed.username));
app.policy.on_behalf_of_email = Some(authed.email.clone());
}
}
let path = app.path.clone();
if &app.path == "" {
return Err(Error::BadRequest("App path cannot be empty".to_string()));
}
let exists = sqlx::query_scalar!(
"SELECT EXISTS(SELECT 1 FROM app WHERE path = $1 AND workspace_id = $2)",
&app.path,
w_id
)
.fetch_one(&mut *tx)
.await?
.unwrap_or(false);
if exists {
return Err(Error::BadRequest(format!(
"App with path {} already exists",
&app.path
)));
}
if let Some(custom_path) = &app.custom_path {
require_admin(authed.is_admin, &authed.username)?;
let scoped =
*CLOUD_HOSTED || APP_WORKSPACED_ROUTE.load(std::sync::atomic::Ordering::Relaxed);
let conflict = sqlx::query!(
"SELECT workspace_id, path FROM app WHERE custom_path = $1 AND ($2::TEXT IS NULL OR workspace_id = $2) LIMIT 1",
custom_path,
if scoped { Some(w_id) } else { None }
)
.fetch_optional(&mut *tx)
.await?;
if let Some(conflict) = conflict {
return Err(custom_path_conflict_error(
custom_path,
&conflict.path,
&conflict.workspace_id,
scoped,
));
}
}
sqlx::query!(
"DELETE FROM draft WHERE path = $1 AND workspace_id = $2 AND typ = 'app'",
&app.path,
&w_id
)
.execute(&mut *tx)
.await?;
let id = sqlx::query_scalar!(
"INSERT INTO app
(workspace_id, path, summary, policy, versions, draft_only, custom_path, labels)
VALUES ($1, $2, $3, $4, '{}', $5, $6, $7) RETURNING id",
w_id,
app.path,
app.summary,
json!(app.policy),
app.draft_only,
app.custom_path
.as_ref()
.map(|s| if s.is_empty() { None } else { Some(s) })
.flatten(),
app.labels.as_deref() as Option<&[String]>
)
.fetch_one(&mut *tx)
.await?;
let v_id = sqlx::query_scalar!(
"INSERT INTO app_version
(app_id, value, created_by, raw_app)
VALUES ($1, $2::text::json, $3, $4) RETURNING id",
id,
//to preserve key orders
serde_json::to_string(&app.value).unwrap(),
authed.username,
raw_app
)
.fetch_one(&mut *tx)
.await?;
sqlx::query!(
"UPDATE app SET versions = array_append(versions, $1::bigint) WHERE id = $2",
v_id,
id
)
.execute(&mut *tx)
.await?;
audit_log(
&mut *tx,
&authed,
"apps.create",
ActionKind::Create,
w_id,
Some(&app.path),
None,
)
.await?;
if should_preserve {
if let Some(ref obo_email) = app.policy.on_behalf_of_email {
if obo_email != &authed.email {
audit_log(
&mut *tx,
&authed,
"apps.on_behalf_of",
ActionKind::Create,
w_id,
Some(&app.path),
Some([("on_behalf_of", obo_email.as_str()), ("action", "create")].into()),
)
.await?;
}
}
}
let mut args: HashMap<String, Box<serde_json::value::RawValue>> = HashMap::new();
if let Some(dm) = &app.deployment_message {
args.insert("deployment_message".to_string(), to_raw_value(&dm));
}
let tx = PushIsolationLevel::Transaction(tx);
let (dependency_job_uuid, new_tx) = push(
&db,
tx,
w_id,
JobPayload::AppDependencies {
path: app.path.clone(),
version: v_id,
debouncing_settings: Default::default(),
},
PushArgs { args: &args, extra: None },
&authed.username,
&authed.email,
windmill_common::users::username_to_permissioned_as(&authed.username),
authed.token_prefix.as_deref(),
None,
None,
None,
None,
None,
None,
false,
false,
None,
true,
None,
None,
None,
None,
Some(&authed.clone().into()),
false,
None,
None,
None,
)
.await?;
tracing::info!("Pushed app dependency job {}", dependency_job_uuid);
Ok((new_tx, path, v_id))
}
async fn list_hub_apps(Extension(db): Extension<DB>) -> impl IntoResponse {
let (status_code, headers, body) = query_elems_from_hub(
&HTTP_CLIENT,
&format!("{}/searchUiData?approved=true", **HUB_BASE_URL.load()),
None,
&db,
)
.await?;
Ok::<_, Error>((status_code, headers, body))
}
pub async fn get_hub_app_by_id(
Path(id): Path<i32>,
Extension(db): Extension<DB>,
) -> JsonResult<Box<serde_json::value::RawValue>> {
let value = http_get_from_hub(
&HTTP_CLIENT,
&format!("{}/apps/{}/json", **HUB_BASE_URL.load(), id),
false,
None,
Some(&db),
)
.await?
.json()
.await
.map_err(to_anyhow)?;
Ok(Json(value))
}
pub async fn get_hub_raw_app_by_id(
Path(id): Path<i32>,
Extension(db): Extension<DB>,
) -> JsonResult<Box<serde_json::value::RawValue>> {
let value = http_get_from_hub(
&HTTP_CLIENT,
&format!("{}/raw_apps/{}/json", **HUB_BASE_URL.load(), id),
false,
None,
Some(&db),
)
.await?
.json()
.await
.map_err(to_anyhow)?;
Ok(Json(value))
}
async fn delete_app(
authed: ApiAuthed,
Extension(db): Extension<DB>,
Extension(user_db): Extension<UserDB>,
Extension(webhook): Extension<WebhookShared>,
Path((w_id, path)): Path<(String, StripPath)>,
) -> Result<String> {
let path = path.to_path();
check_scopes(&authed, || format!("apps:write:{}", path))?;
if path == "g/all/setup_app" && w_id == "admins" {
return Err(Error::BadRequest(
"Cannot delete the global setup app".to_string(),
));
}
if let RuleCheckResult::Blocked(msg) = check_deploy_rules(
&w_id,
AuditAuthorable::username(&authed),
&authed.groups,
authed.is_admin,
&db,
)
.await?
{
return Err(Error::PermissionDenied(msg));
}
// Check if it's a raw app before deletion
let is_raw_app = sqlx::query_scalar!(
"SELECT app_version.raw_app FROM app
JOIN app_version ON app_version.id = app.versions[array_upper(app.versions, 1)]
WHERE app.path = $1 AND app.workspace_id = $2",
path,
&w_id
)
.fetch_optional(&db)
.await?
.unwrap_or(false);
let mut tx = user_db.begin(&authed).await?;
// Capture all related data for trashbin before deleting (CASCADE will remove app_version, etc.)
let trash_app: Option<serde_json::Value> =
sqlx::query_scalar("SELECT to_jsonb(t) FROM app t WHERE path = $1 AND workspace_id = $2")
.bind(path)
.bind(&w_id)
.fetch_optional(&mut *tx)
.await?;
let trash_app_versions: Vec<serde_json::Value> = sqlx::query_scalar(
"SELECT to_jsonb(t) FROM app_version t WHERE app_id = (SELECT id FROM app WHERE path = $1 AND workspace_id = $2)",
)
.bind(path)
.bind(&w_id)
.fetch_all(&mut *tx)
.await?;
let trash_drafts: Vec<serde_json::Value> = sqlx::query_scalar(
"SELECT to_jsonb(t) FROM draft t WHERE path = $1 AND workspace_id = $2 AND typ = 'app'",
)
.bind(path)
.bind(&w_id)
.fetch_all(&mut *tx)
.await?;
sqlx::query!(
"DELETE FROM draft WHERE path = $1 AND workspace_id = $2 AND typ = 'app'",
path,
&w_id
)
.execute(&mut *tx)
.await?;
sqlx::query!(
"DELETE FROM app WHERE path = $1 AND workspace_id = $2",
path,
&w_id
)
.execute(&mut *tx)
.await?;
if let Some(app_data) = trash_app {
let mut trash_data = serde_json::json!({"row": app_data});
if !trash_app_versions.is_empty() {
trash_data["app_versions"] = serde_json::Value::Array(trash_app_versions);
}
if !trash_drafts.is_empty() {
trash_data["drafts"] = serde_json::Value::Array(trash_drafts);
}
windmill_common::trashbin::move_to_trash(
&mut *tx,
&w_id,
"app",
path,
trash_data,
&authed.username,
)
.await?;
}
audit_log(
&mut *tx,
&authed,
"apps.delete",
ActionKind::Delete,
&w_id,
Some(path),
None,
)
.await?;
tx.commit().await?;
let deployed_object = if is_raw_app {
DeployedObject::RawApp {
path: path.to_string(),
parent_path: Some(path.to_string()),
version: 0, // dummy version as it will not get inserted in db
}
} else {
DeployedObject::App {
path: path.to_string(),
parent_path: Some(path.to_string()),
version: 0, // dummy version as it will not get inserted in db
}
};
handle_deployment_metadata(
&authed.email,
&authed.username,
&db,
&w_id,
deployed_object,
Some(format!("App '{}' deleted", path)),
true,
None,
)
.await?;
sqlx::query!(
"DELETE FROM deployment_metadata WHERE path = $1 AND workspace_id = $2 AND app_version IS NOT NULL",
path,
w_id
)
.execute(&db)
.await
.map_err(|e| {
Error::internal_err(format!(
"error deleting deployment metadata for script with path {path} in workspace {w_id}: {e:#}"
))
})?;
webhook.send_message(
w_id.clone().clone(),
WebhookMessage::DeleteApp { workspace: w_id, path: path.to_owned() },
);
Ok(format!("app {} deleted", path))
}
async fn update_app(
authed: ApiAuthed,
Extension(db): Extension<DB>,
Extension(user_db): Extension<UserDB>,
Extension(webhook): Extension<WebhookShared>,
Path((w_id, path)): Path<(String, StripPath)>,
Json(ns): Json<EditApp>,
) -> Result<String> {
if authed.is_operator {
return Err(Error::NotAuthorized(
"Operators cannot update apps for security reasons".to_string(),
));
}
// create_app_internal(authed, user_db, db, &w_id, &mut app).await?;
let path = path.to_path();
check_scopes(&authed, || format!("apps:write:{}", path))?;
if let RuleCheckResult::Blocked(msg) = check_deploy_rules(
&w_id,
AuditAuthorable::username(&authed),
&authed.groups,
authed.is_admin,
&db,
)
.await?
{
return Err(Error::PermissionDenied(msg));
}
let opath = path.to_string();
let (new_tx, npath, _v_id) =
update_app_internal(authed, db, user_db, &w_id, path, false, ns).await?;
new_tx.commit().await?;
webhook.send_message(
w_id.clone(),
WebhookMessage::UpdateApp {
workspace: w_id.clone(),
old_path: opath.clone(),
new_path: npath.clone(),
},
);
Ok(format!("app {} updated (npath: {:?})", opath, npath))
}
async fn update_app_raw<'a>(
authed: ApiAuthed,
Extension(user_db): Extension<UserDB>,
Extension(db): Extension<DB>,
Extension(webhook): Extension<WebhookShared>,
Path((w_id, path)): Path<(String, StripPath)>,
multipart: Multipart,
) -> Result<String> {
if authed.is_operator {
return Err(Error::NotAuthorized(
"Operators cannot update apps for security reasons".to_string(),
));
}
if let RuleCheckResult::Blocked(msg) = check_deploy_rules(
&w_id,
AuditAuthorable::username(&authed),
&authed.groups,
authed.is_admin,
&db,
)
.await?
{
return Err(Error::PermissionDenied(msg));
}
let path = path.to_path();
check_scopes(&authed, || format!("apps:write:{}", path))?;
let opath = path.to_string();
let (npath, _id) = process_app_multipart!(
authed,
user_db,
db,
&w_id,
path,
multipart,
update_app_internal
)
.await?;
webhook.send_message(
w_id.clone(),
WebhookMessage::UpdateApp {
workspace: w_id.clone(),
old_path: opath.to_owned(),
new_path: npath.clone(),
},
);
Ok(format!("app {} updated (npath: {:?})", opath, npath))
}
// async fn create_app_internal<'a>(
// authed: ApiAuthed,
// db: sqlx::Pool<sqlx::Postgres>,
// user_db: UserDB,
// w_id: &String,
// app: &mut CreateApp,
// )
async fn update_app_internal<'a>(
authed: ApiAuthed,
db: sqlx::Pool<sqlx::Postgres>,
user_db: UserDB,
w_id: &str,
path: &str,
raw_app: bool,
ns: EditApp,
) -> Result<(sqlx::Transaction<'a, sqlx::Postgres>, String, i64)> {
use sql_builder::prelude::*;
let mut tx = user_db.clone().begin(&authed).await?;
let mut preserved_on_behalf_of: Option<String> = None;
let npath = if ns.policy.is_some()
|| ns.path.is_some()
|| ns.summary.is_some()
|| ns.custom_path.is_some()
|| ns.labels.is_some()
{
let mut sqlb = SqlBuilder::update_table("app");
sqlb.and_where_eq("path", "?".bind(&path));
sqlb.and_where_eq("workspace_id", "?".bind(&w_id));
sqlb.set("draft_only", "NULL");
if let Some(npath) = &ns.path {
if npath != path {
require_owner_of_path(&authed, path)?;
let exists = sqlx::query_scalar!(
"SELECT EXISTS(SELECT 1 FROM app WHERE path = $1 AND workspace_id = $2)",
npath,
w_id
)
.fetch_one(&mut *tx)
.await?
.unwrap_or(false);
if exists {
return Err(Error::BadRequest(format!(
"App with path {} already exists",
npath
)));
}
}
sqlb.set_str("path", npath);
}
if let Some(nsummary) = &ns.summary {
sqlb.set_str("summary", nsummary);
}
if let Some(ncustom_path) = &ns.custom_path {
require_admin(authed.is_admin, &authed.username)?;
let scoped =
*CLOUD_HOSTED || APP_WORKSPACED_ROUTE.load(std::sync::atomic::Ordering::Relaxed);
if ncustom_path.is_empty() {
sqlb.set("custom_path", "NULL");
} else {
// Same predicate as before (the check is correct): the app's
// own row in this workspace is excluded, so a single-workspace
// edit still works. In global mode a copy of this app in
// another workspace is a genuine conflict (one global route) —
// surface which workspace so it can be resolved.
let conflict = sqlx::query!(
"SELECT workspace_id, path FROM app WHERE custom_path = $1 AND ($2::TEXT IS NULL OR workspace_id = $2) AND NOT (path = $3 AND workspace_id = $4) LIMIT 1",
ncustom_path,
if scoped { Some(w_id) } else { None },
path,
w_id
)
.fetch_optional(&mut *tx)
.await?;
if let Some(conflict) = conflict {
return Err(custom_path_conflict_error(
ncustom_path,
&conflict.path,
&conflict.workspace_id,
scoped,
));
}
sqlb.set_str("custom_path", ncustom_path);
}
}
if let Some(mut npolicy) = ns.policy {
let should_preserve = ns.preserve_on_behalf_of.unwrap_or(false)
&& windmill_common::can_preserve_on_behalf_of(&authed)
&& npolicy.on_behalf_of.is_some();
if should_preserve {
if let Some(ref obo_email) = npolicy.on_behalf_of_email {
if obo_email != &authed.email {
preserved_on_behalf_of = Some(obo_email.clone());
}
}
} else {
npolicy.on_behalf_of = Some(username_to_permissioned_as(&authed.username));
npolicy.on_behalf_of_email = Some(authed.email.clone());
}
sqlb.set(
"policy",
quote(serde_json::to_string(&json!(npolicy)).map_err(|e| {
Error::BadRequest(format!("failed to serialize policy: {}", e))
})?),
);
}
sqlb.returning("path");
let sql = sqlb.sql().map_err(|e| Error::internal_err(e.to_string()))?;
let npath_o: Option<String> = sqlx::query_scalar(&sql).fetch_optional(&mut *tx).await?;
let npath_val = not_found_if_none(npath_o, "App", path)?;
if let Some(nlabels) = &ns.labels {
sqlx::query!(
"UPDATE app SET labels = $1 WHERE path = $2 AND workspace_id = $3",
nlabels as &[String],
&npath_val,
w_id
)
.execute(&mut *tx)
.await?;
}
npath_val
} else {
path.to_owned()
};
let v_id = if let Some(nvalue) = &ns.value {
let app_id = sqlx::query_scalar!(
"SELECT id FROM app WHERE path = $1 AND workspace_id = $2",
npath,
w_id
)
.fetch_one(&mut *tx)
.await?;
let v_id = sqlx::query_scalar!(
"INSERT INTO app_version
(app_id, value, created_by, raw_app)
VALUES ($1, $2::text::json, $3, $4) RETURNING id",
app_id,
//to preserve key orders
serde_json::to_string(&nvalue).unwrap(),
authed.username,
raw_app
)
.fetch_one(&mut *tx)
.await?;
sqlx::query!(
"UPDATE app SET versions = array_append(versions, $1::bigint) WHERE path = $2 AND workspace_id = $3",
v_id,
npath,
w_id
)
.execute(&mut *tx)
.await?;
v_id
} else {
let v_id = sqlx::query_scalar!(
"SELECT app.versions[array_upper(app.versions, 1)] FROM app WHERE path = $1 AND workspace_id = $2",
npath,
w_id
)
.fetch_one(&mut *tx)
.await?;
if let Some(v_id) = v_id {
v_id
} else {
return Err(Error::BadRequest(format!(
"App with path {} not found",
npath
)));
}
};
sqlx::query!(
"DELETE FROM draft WHERE path = $1 AND workspace_id = $2 AND typ = 'app'",
path,
&w_id
)
.execute(&mut *tx)
.await?;
audit_log(
&mut *tx,
&authed,
"apps.update",
ActionKind::Update,
w_id,
Some(&npath),
None,
)
.await?;
if let Some(on_behalf_of) = preserved_on_behalf_of {
audit_log(
&mut *tx,
&authed,
"apps.on_behalf_of",
ActionKind::Update,
w_id,
Some(&npath),
Some(
[
("on_behalf_of", on_behalf_of.as_str()),
("action", "update"),
]
.into(),
),
)
.await?;
}
let tx = PushIsolationLevel::Transaction(tx);
let mut args: HashMap<String, Box<serde_json::value::RawValue>> = HashMap::new();
if let Some(dm) = ns.deployment_message {
args.insert("deployment_message".to_string(), to_raw_value(&dm));
}
args.insert("parent_path".to_string(), to_raw_value(&path));
let (dependency_job_uuid, new_tx) = push(
&db,
tx,
w_id,
JobPayload::AppDependencies {
path: npath.clone(),
version: v_id,
debouncing_settings: Default::default(),
},
PushArgs { args: &args, extra: None },
&authed.username,
&authed.email,
windmill_common::users::username_to_permissioned_as(&authed.username),
authed.token_prefix.as_deref(),
None,
None,
None,
None,
None,
None,
false,
false,
None,
true,
None,
None,
None,
None,
Some(&authed.clone().into()),
false,
None,
None,
None,
)
.await?;
tracing::info!("Pushed app dependency job {}", dependency_job_uuid);
Ok((new_tx, npath, v_id))
}
#[derive(Debug, Deserialize, Clone)]
pub struct ExecuteApp {
/// The app version to execute. Fallback to `path` if not provided.
pub version: Option<i64>,
/// The app script id (from the `app_script` table) to execute.
pub id: Option<i64>,
pub args: HashMap<String, Box<RawValue>>,
// - script: script/<path>
// - flow: flow/<path>
pub path: Option<String>,
pub component: String,
pub raw_code: Option<RawCode>,
// if set, the app is executed as viewer with the given static fields
pub force_viewer_static_fields: Option<StaticFields>,
pub force_viewer_one_of_fields: Option<OneOfFields>,
pub force_viewer_allow_user_resources: Option<AllowUserResources>,
pub force_viewer_sensitive_inputs: Option<Vec<String>>,
pub force_viewer_delete_after_secs: Option<i32>,
/// Runnable query parameters (e.g., memory_id for chat-enabled flows)
pub run_query_params: Option<RunJobQuery>,
/// Map of relative-import script path -> temp storage hash. Only honored for
/// inline-script (raw_code, preview) execution so `wmill app dev` resolves
/// those imports from not-yet-deployed local content instead of deployed.
pub temp_script_refs: Option<HashMap<String, String>>,
}
fn digest(code: &str) -> String {
let mut hasher = Sha256::new();
hasher.update(code);
let result = hasher.finalize();
format!("rawscript/{:x}", result)
}
async fn get_on_behalf_details_from_policy_and_authed(
policy: &Policy,
opt_authed: &Option<ApiAuthed>,
) -> Result<(String, String, String)> {
let (username, permissioned_as, email) = match policy.execution_mode {
ExecutionMode::Anonymous => {
let username = opt_authed
.as_ref()
.map(|a| a.username.clone())
.unwrap_or_else(|| "anonymous".to_string());
let (permissioned_as, email) = get_on_behalf_of(&policy)?;
(username, permissioned_as, email)
}
ExecutionMode::Publisher => {
let username = opt_authed
.as_ref()
.map(|a| a.username.clone())
.ok_or_else(|| {
Error::BadRequest(
"publisher execution mode requires authentication".to_string(),
)
})?;
let (permissioned_as, email) = get_on_behalf_of(&policy)?;
(username, permissioned_as, email)
}
ExecutionMode::Viewer => {
let (username, email) = opt_authed
.as_ref()
.map(|a| (a.username.clone(), a.email.clone()))
.ok_or_else(|| {
Error::BadRequest("Required to be authed in viewer mode".to_string())
})?;
(
username.clone(),
username_to_permissioned_as(&username),
email,
)
}
};
Ok((username, permissioned_as, email))
}
/// Convert the triggerables from the old format to the new format.
fn empty_triggerables(mut policy: Policy) -> Policy {
use std::mem::take;
if let Some(triggerables) = take(&mut policy.triggerables) {
let mut triggerables_v2 = take(&mut policy.triggerables_v2).unwrap_or_default();
for (k, static_inputs) in triggerables.into_iter() {
triggerables_v2.insert(
k,
PolicyTriggerableInputs { static_inputs, ..Default::default() },
);
}
policy.triggerables_v2 = Some(triggerables_v2);
}
policy
}
async fn execute_component(
OptAuthed(opt_authed): OptAuthed,
tokened: OptTokened,
Extension(db): Extension<DB>,
Extension(user_db): Extension<UserDB>,
Path((w_id, path)): Path<(String, StripPath)>,
Json(mut payload): Json<ExecuteApp>,
) -> Result<String> {
// Only honor temp_script_refs for the inline-script preview path:
// preview/editor mode (force_viewer_static_fields set, == `is_preview`),
// raw_code present, and no deployed app_script id — i.e. `wmill app dev`.
let temp_script_refs = payload.temp_script_refs.take();
let inject_temp_refs = temp_script_refs.is_some()
&& payload.force_viewer_static_fields.is_some()
&& payload.raw_code.is_some()
&& payload.id.is_none();
match (payload.path.is_some(), payload.raw_code.is_some()) {
(false, false) => {
return Err(Error::BadRequest(
"path or raw_code is required".to_string(),
))
}
(true, true) => {
return Err(Error::BadRequest(
"path and raw_code cannot be set at the same time".to_string(),
))
}
_ => {}
};
let path = path.to_path();
let (arc_policy, policy): (Arc<Policy>, Policy);
let policy_triggerables_default = Default::default();
// Preview mode means the request was issued from the editor; the editing
// user is already trusted by the policy check, so client-supplied `tag`
// on the inline script is honored. In any other case we must read the
// tag from the deployed policy and ignore the request body.
let is_preview = payload.force_viewer_static_fields.is_some();
// Preview mode runs request-supplied code as a `Viewer`-mode job (the
// app-editor equivalent of `/jobs/run/preview`), so it enforces the same
// guards. Operators must never run preview jobs. `jobs:run` is required
// because this route is reachable with an `apps:run`-scoped token (the
// route maps to the `apps` scope domain), which must not be able to escalate
// to arbitrary code execution. The client-supplied inline `raw_code.tag`
// must stay within the caller's allowed worker tags. A preview can also
// *reference* an existing runnable the caller may not be allowed to read — a
// deployed script/flow via `payload.path` or a persisted `app_script` via
// `payload.id`, both resolved with the root DB handle — so those (and only
// those) are confined to paths the caller can read. Inline `raw_code` is not
// path-gated: a non-operator member can already run arbitrary inline code
// via `/jobs/run/preview`.
if is_preview {
let authed = opt_authed.as_ref().ok_or_else(|| {
Error::NotAuthorized("App component preview requires authentication".to_string())
})?;
if authed.is_operator {
return Err(Error::NotAuthorized(
"Operators cannot run preview jobs for security reasons".to_string(),
));
}
check_scopes(authed, || format!("jobs:run"))?;
if let Some(p) = payload.path.as_deref() {
let runnable_path = p
.strip_prefix("script/")
.or_else(|| p.strip_prefix("flow/"))
.unwrap_or(p);
require_path_read_access_for_preview(authed, &Some(runnable_path.to_string()))?;
}
if let Some(id) = payload.id {
let owner_path = sqlx::query_scalar!(
"SELECT a.path FROM app_script s JOIN app a ON a.id = s.app
WHERE s.id = $1 AND a.workspace_id = $2",
id,
&w_id,
)
.fetch_optional(&db)
.await?
.ok_or_else(|| {
Error::NotAuthorized(format!(
"App script {id} does not belong to an app in this workspace"
))
})?;
require_path_read_access_for_preview(authed, &Some(owner_path))?;
}
}
// Two cases here:
// 1. The component is executed from the editor (i.e. in "preview" mode), then:
// - The policy is set to default (in `Viewer` execution mode).
// - The policy triggerables are built by the frontend and retrieved from the request
// payload.
// - In case of inline script, the `RawCode` from the request is pushed as is to the
// job queue.
// 2. Otherwise (i.e. "run" mode):
// - The policy and triggerables are fetched from the database.
// - In case of inline script, if an entry exists in the `app_script` table, push
// an `AppScript` job payload, as in (.1) otherwise.
let (policy, policy_triggerables) = match payload {
// 1. "preview" mode.
ExecuteApp {
force_viewer_static_fields: Some(static_inputs),
force_viewer_one_of_fields,
force_viewer_allow_user_resources,
force_viewer_sensitive_inputs,
force_viewer_delete_after_secs,
..
} => (
&Policy { execution_mode: ExecutionMode::Viewer, ..Default::default() },
&PolicyTriggerableInputs {
static_inputs,
one_of_inputs: force_viewer_one_of_fields.unwrap_or_default(),
allow_user_resources: force_viewer_allow_user_resources.unwrap_or_default(),
delete_after_secs: force_viewer_delete_after_secs,
sensitive_inputs: force_viewer_sensitive_inputs.unwrap_or_default(),
tag: None,
},
),
// 2. "run" mode.
_ => {
// Policy is fetched from the database on app `path` and `workspace_id`.
let policy_fut = sqlx::query_scalar!(
"SELECT policy as \"policy: sqlx::types::Json<Box<RawValue>>\"
FROM app WHERE app.path = $1 AND app.workspace_id = $2 LIMIT 1",
path,
&w_id,
)
.fetch_optional(&db)
.map_err(Into::<Error>::into)
.map(|policy_o| Result::Ok(not_found_if_none(policy_o?, "App", path)?))
.map(|policy| Result::Ok(serde_json::from_str(policy?.get())?))
.map_ok(empty_triggerables);
// 1. The app `version` is provided: cache the fetched policy.
// 2. Otherwise, always fetch the policy from the database.
let policy = if let Some(id) = payload.version {
let cache = cache::anon!({ u64 => Arc<Policy> } in "policy" <= 1000);
arc_policy = policy_fut.map_ok(Arc::new).cached(cache, id as u64).await?;
&*arc_policy
} else {
policy = policy_fut.await?;
&policy
};
// Compute the path for the triggerables map:
// - flow: `flow/<payload.path>`
// - script: `script/<payload.path>`
// - inline script: `rawscript/<sha256(raw_code.content)>`
let path = match &payload {
// flow or script: just use the `payload.path`.
ExecuteApp { path: Some(path), .. } => path,
// inline script: without entry in the `app_script` table.
ExecuteApp { raw_code: Some(raw_code), id: None, .. } => &digest(&raw_code.content),
// inline script: with an entry in the `app_script` table.
ExecuteApp { raw_code: Some(_), id: Some(id), .. } => {
let cache = cache::anon!({ u64 => Arc<String> } in "appscriptpath" <= 10000);
// `id` is unique, cache the result.
&*sqlx::query_scalar!(
"SELECT format('rawscript/%s', code_sha256) as \"path!: String\"
FROM app_script WHERE id = $1 LIMIT 1",
id
)
.fetch_one(&db)
.map_err(Into::<Error>::into)
.map_ok(Arc::new)
.cached(cache, *id as u64)
.await?
}
_ => unreachable!(),
};
// Retrieve the triggerables from the policy on `path` or `<component>:<path>`.
let triggerables_v2 = policy
.triggerables_v2
.as_ref()
.ok_or_else(|| Error::BadRequest(format!("Policy is missing triggerables")))?;
let policy_triggerables = triggerables_v2
.get(path) // start with `path` in case we can avoid the next` format!`.
.or_else(|| triggerables_v2.get(&format!("{}:{}", payload.component, &path)))
.or(match policy.execution_mode {
ExecutionMode::Viewer => Some(&policy_triggerables_default),
_ => None,
})
.ok_or_else(|| Error::BadRequest(format!("Path {path} forbidden by policy")))?;
(policy, policy_triggerables)
}
};
// Check rate limit for anonymous (public) executions
if matches!(policy.execution_mode, ExecutionMode::Anonymous) && opt_authed.is_none() {
if let Some(limit) = crate::workspaces::get_public_app_rate_limit(&db, &w_id).await? {
if limit > 0 {
crate::public_app_rate_limit::check_and_increment(&w_id, limit)?;
}
}
}
// Execution is publisher and an user is authenticated: check if the user is authorized to
// execute the app.
if let (ExecutionMode::Publisher, Some(authed)) = (policy.execution_mode, opt_authed.as_ref()) {
lazy_static! {
/// Cache for the permit to execute an app component.
static ref PERMIT_CACHE: cache::Cache<[u8; 32], bool> = cache::Cache::new(1000);
}
// Avoid allocation for the permit key using a sha256 hash of:
// - the user email,
// - the application path,
// - the workspace id.
let permit_key: [u8; 32] = [authed.email.as_bytes(), path.as_bytes(), &w_id.as_bytes()]
.iter()
.fold(Sha256::new(), |hasher, bytes| hasher.chain_update(bytes))
.finalize()
.into();
let permit_fut = PERMIT_CACHE.get_or_insert_async(&permit_key, async {
let mut tx = user_db.clone().begin(authed).await?;
// Permissions are checked by the database; just fetch a row from app using `user_db`:
let row = sqlx::query_scalar!(
"SELECT 1 FROM app WHERE path = $1 AND workspace_id = $2 LIMIT 1",
path,
&w_id,
)
.fetch_optional(&mut *tx)
.await?;
tx.commit().await?;
Result::Ok(row.is_some_and(|x| x.is_some()))
});
if !permit_fut.await? {
return Err(Error::NotAuthorized(format!(
"Missing read permissions on the `{}` app to execute `{}` runnable",
path, payload.component
)));
}
}
let (username, permissioned_as, email) =
get_on_behalf_details_from_policy_and_authed(&policy, &opt_authed).await?;
let resolved_delete_secs =
resolve_delete_after_secs(None, policy_triggerables.delete_after_secs);
let (mut args, job_id) = build_args(
policy,
policy_triggerables,
payload.args,
opt_authed.as_ref(),
&user_db,
&db,
&w_id,
)
.await?;
if inject_temp_refs {
if let Some(refs) = temp_script_refs {
args.extra
.get_or_insert_with(HashMap::new)
.insert("_TEMP_SCRIPT_REFS".to_string(), to_raw_value(&refs));
}
}
let is_flow = payload
.path
.as_ref()
.map(|p| p.starts_with("flow/"))
.unwrap_or(false);
// Tag for inline-script jobs is read from the deployed policy in run mode;
// only preview mode (editor) honors the client-supplied tag. This applies to
// both the `id`-bearing app_script path and the legacy `rawscript/<sha>`
// path that has no app_script entry yet.
let resolved_inline_tag = |client_tag: Option<String>| -> Option<String> {
if is_preview {
client_tag
} else {
policy_triggerables.tag.clone()
}
.filter(|t| !t.is_empty())
};
let (job_payload, tag, on_behalf_of) = match (payload.path, payload.raw_code, payload.id) {
// flow or script:
(Some(path), None, None) => get_payload_tag_from_prefixed_path(&path, &db, &w_id).await?,
// inline script: "preview" mode, or run mode without an entry in the
// `app_script` table (legacy `rawscript/<sha>`-keyed triggerables).
(None, Some(raw_code), None) => {
let tag = resolved_inline_tag(raw_code.tag.clone());
(JobPayload::Code(raw_code), tag, None)
}
// inline script: run mode (deployed app) with an entry in `app_script`.
(None, Some(RawCode { language, path, cache_ttl, tag, .. }), Some(id)) => (
JobPayload::AppScript { id: AppScriptId(id), cache_ttl, language, path },
resolved_inline_tag(tag),
None,
),
_ => unreachable!(),
};
// Preview honors the client-supplied inline tag (`resolved_inline_tag`), so
// — like `/jobs/run/preview` — confine it to worker tags the caller may use
// (a `if_jobs:filter_tags`-restricted token must not escape its filter).
// `is_preview` implies an authed caller (the guard above returns otherwise).
if is_preview {
if let Some(authed) = opt_authed.as_ref() {
crate::jobs::check_tag_available_for_workspace(&db, &w_id, &tag, authed).await?;
}
}
// Identity is already resolved to the requesting user in preview mode (the
// policy is forced to `ExecutionMode::Viewer`, so the job runs as the
// caller). The enqueue stays root-isolated as before — switching the insert
// to user-RLS is not what contains the bypass (the auth guards above are)
// and would add unnecessary breakage risk to the legitimate editor flow.
let tx = PushIsolationLevel::IsolatedRoot(db.clone());
let (email, permissioned_as) = if let Some(on_behalf_of) = on_behalf_of.as_ref() {
(
on_behalf_of.email.as_str(),
on_behalf_of.permissioned_as.clone(),
)
} else {
(email.as_str(), permissioned_as)
};
let end_user_email =
get_end_user_email(&db, opt_authed.as_ref(), tokened.token.as_deref()).await;
let (uuid, mut tx) = push(
&db,
tx,
&w_id,
job_payload,
PushArgs { args: &args.args, extra: args.extra },
&username,
email,
permissioned_as,
opt_authed
.and_then(|a| a.token_prefix)
.or_else(|| tokened.token.map(|t| t[0..TOKEN_PREFIX_LEN].to_string()))
.as_deref(),
None,
None,
None,
None,
None,
job_id,
false,
false,
None,
true,
tag,
None,
None,
None,
None,
false,
end_user_email,
None,
None,
)
.await?;
// Apply runnable query parameters if provided
if let Some(ref run_query) = payload.run_query_params {
if is_flow {
crate::jobs::process_flow_run_query_params(&mut tx, uuid, run_query).await?;
}
}
tx.commit().await?;
if let Some(secs) = resolved_delete_secs {
if let Err(e) = schedule_job_deletion(&db, uuid, &w_id, secs).await {
tracing::error!("Failed to schedule deletion for app job {uuid} after {secs}s: {e:#}");
}
}
Ok(uuid.to_string())
}
#[cfg(not(feature = "parquet"))]
async fn upload_s3_file_from_app() -> Result<()> {
return Err(Error::BadRequest(
"This endpoint requires the parquet feature to be enabled".to_string(),
));
}
#[cfg(not(feature = "parquet"))]
async fn delete_s3_file_from_app() -> Result<()> {
return Err(Error::BadRequest(
"This endpoint requires the parquet feature to be enabled".to_string(),
));
}
#[cfg(feature = "parquet")]
#[derive(Debug, Deserialize, Clone)]
struct UploadFileToS3Query {
file_key: Option<String>,
file_extension: Option<String>,
s3_resource_path: Option<String>,
content_type: Option<String>,
content_disposition: Option<String>,
force_viewer_file_key_regex: Option<String>,
force_viewer_allow_user_resources: Option<bool>,
force_viewer_allow_workspace_resource: Option<bool>,
force_viewer_allowed_resources: Option<String>,
}
#[cfg(feature = "parquet")]
#[derive(Serialize, Deserialize)]
struct S3DeleteTokenClaims {
file_key: String,
on_behalf_of_email: String,
permissioned_as: String,
username: String,
s3_resource_path: Option<String>,
workspace: String,
pub exp: usize,
}
#[cfg(feature = "parquet")]
#[derive(Deserialize)]
struct S3TokenRequestBody {
s3_objects: Vec<S3Object>,
}
#[cfg(feature = "parquet")]
async fn sign_s3_objects(
Extension(db): Extension<DB>,
Path(w_id): Path<String>,
Json(body): Json<S3TokenRequestBody>,
) -> Result<Json<Vec<S3Object>>> {
let workspace_key = get_workspace_key(&w_id, &db).await?;
let futures = body.s3_objects.into_iter().map(|s3_object| async {
let exp = (chrono::Utc::now() + chrono::Duration::hours(12)).timestamp();
let mut message = format!("file_key={}&exp={}", s3_object.s3.clone(), exp);
if let Some(ref storage) = s3_object.storage {
message = format!("{}&storage={}", message, storage);
}
let mut max = HmacSha256::new_from_slice(workspace_key.as_bytes())
.map_err(|err| Error::internal_err(format!("Failed to create hmac: {}", err)))?;
max.update(message.as_bytes());
let result = max.finalize();
let signature = hex::encode(result.into_bytes());
let presigned = format!("exp={}&sig={}", exp, signature);
Ok::<_, Error>(S3Object { presigned: Some(presigned), ..s3_object })
});
let signed_s3_objects = futures::future::try_join_all(futures).await?;
Ok(Json(signed_s3_objects))
}
#[cfg(not(feature = "parquet"))]
async fn sign_s3_objects() -> Result<()> {
return Err(Error::BadRequest(
"This endpoint requires the parquet feature to be enabled".to_string(),
));
}
#[cfg(feature = "parquet")]
#[derive(Serialize)]
struct AppUploadFileResponse {
file_key: String,
delete_token: String,
}
#[cfg(feature = "parquet")]
async fn upload_s3_file_from_app(
OptAuthed(opt_authed): OptAuthed,
Extension(db): Extension<DB>,
Path((w_id, path)): Path<(String, StripPath)>,
Query(query): Query<UploadFileToS3Query>,
request: axum::extract::Request,
) -> JsonResult<AppUploadFileResponse> {
let policy = if let Some(file_key_regex) = query.force_viewer_file_key_regex {
// `force_viewer_*` lets the caller supply a synthetic upload policy that
// bypasses the deployed app's file_key_regex / resource restrictions.
// It is intended for the app editor's preview path, so it must enforce
// the same guards as `execute_component`'s preview mode (PR #9235):
// authed caller, not an operator, and `apps:write` scope to make sure
// an `apps:run`-scoped token cannot pick its own policy.
let authed = opt_authed.as_ref().ok_or_else(|| {
Error::NotAuthorized("App S3 preview upload requires authentication".to_string())
})?;
if authed.is_operator {
return Err(Error::NotAuthorized(
"Operators cannot run app S3 previews for security reasons".to_string(),
));
}
check_scopes(authed, || format!("apps:write:{}", path.to_path()))?;
Some(Policy {
execution_mode: ExecutionMode::Viewer,
triggerables: None,
triggerables_v2: None,
on_behalf_of: None,
on_behalf_of_email: None,
s3_inputs: Some(vec![S3Input {
file_key_regex: file_key_regex,
allow_user_resources: query.force_viewer_allow_user_resources.unwrap_or(false),
allow_workspace_resource: query
.force_viewer_allow_workspace_resource
.unwrap_or(false),
allowed_resources: query
.force_viewer_allowed_resources
.map(|s| s.split(',').map(|s| s.to_string()).collect())
.unwrap_or_default(),
}]),
allowed_s3_keys: None,
})
} else {
let policy_o = sqlx::query_scalar!(
"SELECT policy from app WHERE path = $1 AND workspace_id = $2",
&path.0,
&w_id
)
.fetch_optional(&db)
.await?;
policy_o
.map(|p| serde_json::from_value::<Policy>(p).map_err(to_anyhow))
.transpose()?
};
let user_db = UserDB::new(db.clone());
let (s3_resource_opt, file_key, on_behalf_of_email, permissioned_as, username) = if policy
.as_ref()
.is_some_and(|p| p.s3_inputs.is_some())
{
let policy = policy.unwrap();
let s3_inputs = policy.s3_inputs.as_ref().unwrap();
let (username, permissioned_as, email) =
get_on_behalf_details_from_policy_and_authed(&policy, &opt_authed).await?;
let on_behalf_authed = fetch_api_authed_from_permissioned_as(
permissioned_as.clone(),
email.clone(),
&w_id,
&db,
Some(username.clone()),
)
.await?;
if let Some(file_key) = query.file_key {
// file key is provided => requires workspace, user or list policy and must match the regex
let matching_s3_inputs = if let Some(ref s3_resource_path) = query.s3_resource_path {
s3_inputs
.iter()
.filter(|s3_input| {
s3_input.allowed_resources.contains(s3_resource_path)
|| s3_input.allow_user_resources
})
.sorted_by_key(|i| i.allow_user_resources) // consider user resources last
.collect::<Vec<_>>()
} else {
s3_inputs
.iter()
.filter(|s3_input| s3_input.allow_workspace_resource)
.collect::<Vec<_>>()
};
let matched_input = matching_s3_inputs.iter().find(|s3_input| {
match Regex::new(&s3_input.file_key_regex) {
Ok(re) => re.is_match(&file_key),
Err(e) => {
tracing::error!("Error compiling regex: {}", e);
false
}
}
});
if let Some(matched_input) = matched_input {
if let Some(ref s3_resource_path) = query.s3_resource_path {
if matched_input.allow_user_resources {
if let Some(authed) = opt_authed {
let db_with_opt_authed = DbWithOptAuthed::from_authed(
&authed,
db.clone(),
Some(user_db.clone()),
);
(
Some(
get_s3_resource(
&db_with_opt_authed,
&w_id,
s3_resource_path,
None,
None,
)
.await?,
),
file_key,
email,
permissioned_as,
username,
)
} else {
return Err(Error::BadRequest(
"User resources are not allowed without being logged in"
.to_string(),
));
}
} else {
let db_with_opt_authed = DbWithOptAuthed::from_authed(
&on_behalf_authed,
db.clone(),
Some(user_db.clone()),
);
(
Some(
get_s3_resource(
&db_with_opt_authed,
&w_id,
s3_resource_path,
None,
None,
)
.await?,
),
file_key,
email,
permissioned_as,
username,
)
}
} else {
let db_with_opt_authed = DbWithOptAuthed::from_authed(
&on_behalf_authed,
db.clone(),
Some(user_db.clone()),
);
let (_, s3_resource_opt) = get_workspace_s3_resource_and_check_paths(
&db_with_opt_authed,
Some(&on_behalf_authed),
&w_id,
None,
&[(&file_key, S3Permission::WRITE)],
None,
)
.await?;
(s3_resource_opt, file_key, email, permissioned_as, username)
}
} else {
return Err(Error::BadRequest(
"No matching s3 resource found for the given file key".to_string(),
));
}
} else {
// no file key => requires unnamed upload policy => allow workspace resource and file_key_regex is empty
let has_unnamed_policy = s3_inputs.iter().any(|s3_input| {
s3_input.allow_workspace_resource && s3_input.file_key_regex.is_empty()
});
if !has_unnamed_policy {
return Err(Error::BadRequest(
"no policy found for unnamed s3 file upload".to_string(),
));
}
// for now, we place all files into `windmill_uploads` folder with a random name
// TODO: make the folder configurable via the workspace settings
let file_key = get_random_file_name(query.file_extension);
let db_with_opt_authed =
DbWithOptAuthed::from_authed(&on_behalf_authed, db.clone(), None);
let (_, s3_resource_opt) = get_workspace_s3_resource_and_check_paths(
&db_with_opt_authed,
Some(&on_behalf_authed),
&w_id,
None,
&[(&file_key, S3Permission::WRITE)],
None,
)
.await?;
(s3_resource_opt, file_key, email, permissioned_as, username)
}
} else {
// backward compatibility (no policy)
// if no policy but logged in, use the user's auth to get the s3 resource
if let Some(authed) = opt_authed {
let file_key = query
.file_key
.unwrap_or_else(|| get_random_file_name(query.file_extension));
let (on_behalf_of_email, permissioned_as, username) = (
authed.email.clone(),
username_to_permissioned_as(&authed.username),
authed.display_username().to_string(),
);
if let Some(ref s3_resource_path) = query.s3_resource_path {
let db_with_opt_authed =
DbWithOptAuthed::from_authed(&authed, db.clone(), Some(user_db.clone()));
(
Some(
get_s3_resource(&db_with_opt_authed, &w_id, s3_resource_path, None, None)
.await?,
),
file_key,
on_behalf_of_email,
permissioned_as,
username,
)
} else {
let db_with_opt_authed = DbWithOptAuthed::from_authed(&authed, db.clone(), None);
let (_, s3_resource) = get_workspace_s3_resource_and_check_paths(
&db_with_opt_authed,
Some(&authed),
&w_id,
None,
&[(&file_key, S3Permission::WRITE)],
None,
)
.await?;
(
s3_resource,
file_key,
on_behalf_of_email,
permissioned_as,
username,
)
}
} else {
return Err(Error::BadRequest("Missing s3 policy".to_string()));
}
};
let s3_resource = s3_resource_opt.ok_or(Error::internal_err(
"No files storage resource defined at the workspace level".to_string(),
))?;
let s3_client = windmill_object_store::build_object_store_client(&s3_resource).await?;
let options = Attributes::from_iter(vec![
(
Attribute::ContentType,
query.content_type.unwrap_or_else(|| {
mime_guess::from_path(&file_key)
.first_or_octet_stream()
.to_string()
}),
),
(
Attribute::ContentDisposition,
query.content_disposition.unwrap_or("inline".to_string()),
),
])
.into();
let _put_result = upload_file_from_req(s3_client, &file_key, request, options).await?;
let delete_token = jwt::encode_with_internal_secret(S3DeleteTokenClaims {
file_key: file_key.clone(),
on_behalf_of_email,
permissioned_as,
username,
s3_resource_path: query.s3_resource_path,
workspace: w_id.clone(),
exp: (chrono::Utc::now() + chrono::Duration::hours(12)).timestamp() as usize,
})
.await?;
return Ok(Json(AppUploadFileResponse { file_key, delete_token }));
}
#[cfg(feature = "parquet")]
#[derive(Deserialize)]
struct DeleteS3FileQuery {
delete_token: String,
}
#[cfg(feature = "parquet")]
async fn delete_s3_file_from_app(
Extension(db): Extension<DB>,
Extension(user_db): Extension<UserDB>,
Path(w_id): Path<String>,
Query(query): Query<DeleteS3FileQuery>,
) -> Result<()> {
let S3DeleteTokenClaims {
file_key,
on_behalf_of_email,
permissioned_as,
username,
s3_resource_path,
workspace,
..
} = jwt::decode_with_internal_secret::<S3DeleteTokenClaims>(&query.delete_token).await?;
let path = windmill_object_store::object_store_reexports::Path::parse(file_key.as_str())
.map_err(|e| Error::internal_err(format!("Error parsing file key: {}", e)))?;
if workspace != w_id {
return Err(Error::BadRequest("Invalid workspace".to_string()));
}
let on_behalf_authed = fetch_api_authed_from_permissioned_as(
permissioned_as,
on_behalf_of_email,
&w_id,
&db,
Some(username),
)
.await?;
let s3_resource = if let Some(s3_resource_path) = s3_resource_path {
let db_with_opt_authed =
DbWithOptAuthed::from_authed(&on_behalf_authed, db.clone(), Some(user_db.clone()));
get_s3_resource(
&db_with_opt_authed,
&w_id,
s3_resource_path.as_str(),
None,
None,
)
.await?
} else {
let db_with_opt_authed = DbWithOptAuthed::from_authed(&on_behalf_authed, db.clone(), None);
let (_, s3_resource) = get_workspace_s3_resource_and_check_paths(
&db_with_opt_authed,
Some(&on_behalf_authed),
&w_id,
None,
&[(&path.to_string(), S3Permission::DELETE)],
None,
)
.await?;
s3_resource.ok_or(Error::internal_err(
"No files storage resource defined at the workspace level".to_string(),
))?
};
let s3_client = windmill_object_store::build_object_store_client(&s3_resource).await?;
s3_client.delete(&path).await.map_err(|err| {
tracing::error!("Error deleting file: {:?}", err);
Error::internal_err(format!("Error deleting file: {}", err.to_string()))
})?;
Ok(())
}
#[cfg(not(feature = "parquet"))]
async fn download_s3_file_from_app() -> Result<()> {
return Err(Error::BadRequest(
"This endpoint requires the parquet feature to be enabled".to_string(),
));
}
#[cfg(feature = "parquet")]
async fn get_on_behalf_authed_from_app(
db: &DB,
path: &str,
w_id: &str,
opt_authed: &Option<ApiAuthed>,
force_allowed_s3_keys: Option<Vec<S3Key>>,
) -> Result<(ApiAuthed, Policy)> {
let policy = if let Some(force_allowed_s3_keys) = force_allowed_s3_keys {
Policy {
execution_mode: ExecutionMode::Viewer,
triggerables: None,
triggerables_v2: None,
on_behalf_of: None,
on_behalf_of_email: None,
s3_inputs: None,
allowed_s3_keys: Some(force_allowed_s3_keys),
}
} else {
// TODO: improve db query to not return uneeded fields
let policy_o = sqlx::query_scalar!(
"SELECT policy from app WHERE path = $1 AND workspace_id = $2",
path,
w_id
)
.fetch_optional(db)
.await?;
policy_o
.map(|p| serde_json::from_value::<Policy>(p).map_err(to_anyhow))
.transpose()?
.unwrap_or_else(|| Policy {
execution_mode: ExecutionMode::Viewer,
triggerables: None,
triggerables_v2: None,
on_behalf_of: None,
on_behalf_of_email: None,
s3_inputs: None,
allowed_s3_keys: None,
})
};
let (username, permissioned_as, email) =
get_on_behalf_details_from_policy_and_authed(&policy, &opt_authed).await?;
let on_behalf_authed =
fetch_api_authed_from_permissioned_as(permissioned_as, email, &w_id, &db, Some(username))
.await?;
Ok((on_behalf_authed, policy))
}
#[cfg(feature = "parquet")]
async fn check_if_allowed_to_access_s3_file_from_app(
db: &DB,
opt_authed: &Option<ApiAuthed>,
file_query: &AppS3FileQuery,
w_id: &str,
path: &str,
policy: &Policy,
) -> Result<()> {
// if anonymous, check that the file was the result of an app script ran by an anonymous user in the last 3 hours
// otherwise, if logged in, allow any file (TODO: change that when we implement better s3 policy)
if file_query.sig.is_some() {
#[cfg(feature = "private")]
{
crate::s3_proxy_ee::validate_s3_signature(
&file_query.s3,
&file_query.sig,
&file_query.exp,
&file_query.storage,
w_id,
&db,
)
.await?;
Ok(())
}
#[cfg(not(feature = "private"))]
return Err(Error::InternalErr(
"Internal error: signature validation is not supported in open source mode".to_string(),
));
} else if opt_authed.is_some() {
Ok(())
} else {
let allowed = policy
.allowed_s3_keys
.as_ref()
.unwrap()
.iter()
.any(|key| key.s3_path == file_query.s3 && key.storage == file_query.storage)
|| {
sqlx::query_scalar!(
r#"SELECT EXISTS (
SELECT 1 FROM v2_job_completed c JOIN v2_job j USING (id)
WHERE j.workspace_id = $2
AND (j.kind = 'appscript' OR j.kind = 'preview')
AND j.created_by = 'anonymous'
AND c.started_at > now() - interval '3 hours'
AND j.runnable_path LIKE $3 || '/%'
AND c.result @> ('{"s3":"' || $1 || '"}')::jsonb
)"#,
file_query.s3,
w_id,
path,
)
.fetch_one(db)
.await?
.unwrap_or(false)
};
if !allowed {
Err(Error::BadRequest("File restricted".to_string()))
} else {
Ok(())
}
}
}
#[cfg(feature = "parquet")]
#[derive(Deserialize, Debug)]
struct AppS3FileQuery {
s3: String,
storage: Option<String>,
sig: Option<String>,
#[cfg(feature = "private")]
exp: Option<String>,
}
#[cfg(feature = "parquet")]
#[derive(Deserialize, Debug)]
struct AppS3FileQueryWithForceViewerAllowedS3Keys {
#[serde(flatten)]
pub file_query: AppS3FileQuery,
pub force_viewer_allowed_s3_keys: Option<String>,
}
#[cfg(feature = "parquet")]
async fn download_s3_file_from_app(
OptAuthed(opt_authed): OptAuthed,
Extension(db): Extension<DB>,
Path((w_id, path)): Path<(String, StripPath)>,
Query(query): Query<AppS3FileQueryWithForceViewerAllowedS3Keys>,
) -> Result<Response> {
use crate::db::OptJobAuthed;
let path = path.to_path();
let force_viewer_allowed_s3_keys = if let Some(force_viewer_allowed_s3_keys) =
query.force_viewer_allowed_s3_keys.clone()
{
// `force_viewer_allowed_s3_keys` lets the caller supply a synthetic
// allowlist that bypasses the deployed app policy. Apply the same
// preview-mode guard as `execute_component` (PR #9235): authed, not an
// operator, `apps:write` scope so an `apps:run`-scoped token cannot
// pick its own allowlist.
let authed = opt_authed.as_ref().ok_or_else(|| {
Error::NotAuthorized("App S3 preview download requires authentication".to_string())
})?;
if authed.is_operator {
return Err(Error::NotAuthorized(
"Operators cannot run app S3 previews for security reasons".to_string(),
));
}
check_scopes(authed, || format!("apps:write:{}", path))?;
Some(serde_json::from_str::<Vec<S3Key>>(&force_viewer_allowed_s3_keys).unwrap_or_default())
} else {
None
};
let (on_behalf_authed, policy) =
get_on_behalf_authed_from_app(&db, &path, &w_id, &opt_authed, force_viewer_allowed_s3_keys)
.await?;
check_if_allowed_to_access_s3_file_from_app(
&db,
&opt_authed,
&query.file_query,
&w_id,
&path,
&policy,
)
.await?;
download_s3_file_internal(
OptJobAuthed { authed: on_behalf_authed, job_id: None },
&db,
None,
&w_id,
DownloadFileQuery {
file_key: query.file_query.s3,
s3_resource_path: None,
storage: query.file_query.storage,
},
)
.await
}
fn get_on_behalf_of(policy: &Policy) -> Result<(String, String)> {
let permissioned_as = policy
.on_behalf_of
.as_ref()
.ok_or_else(|| {
Error::BadRequest(
"on_behalf_of is missing in the app policy and is required for anonymous execution"
.to_string(),
)
})?
.to_string();
let email = policy
.on_behalf_of_email
.as_ref()
.ok_or_else(|| {
Error::BadRequest(
"on_behalf_of_email is missing in the app policy and is required for anonymous execution"
.to_string(),
)
})?
.to_string();
Ok((permissioned_as, email))
}
pub async fn require_is_writer(authed: &ApiAuthed, path: &str, w_id: &str, db: DB) -> Result<()> {
return crate::users::require_is_writer(
authed,
path,
w_id,
db,
"SELECT extra_perms FROM app WHERE path = $1 AND workspace_id = $2",
"app",
)
.await;
}
async fn exists_app(
Extension(db): Extension<DB>,
Path((w_id, path)): Path<(String, StripPath)>,
) -> JsonResult<bool> {
let exists = sqlx::query_scalar!(
"SELECT EXISTS(SELECT 1 FROM app WHERE path = $1 AND workspace_id = $2)",
path.to_path(),
w_id
)
.fetch_one(&db)
.await?
.unwrap_or(false);
Ok(Json(exists))
}
async fn build_args(
policy: &Policy,
PolicyTriggerableInputs {
static_inputs,
one_of_inputs,
allow_user_resources,
sensitive_inputs,
..
}: &PolicyTriggerableInputs,
mut args: HashMap<String, Box<RawValue>>,
authed: Option<&ApiAuthed>,
user_db: &UserDB,
db: &DB,
w_id: &str,
) -> Result<(PushArgsOwned, Option<Uuid>)> {
let mut job_id: Option<Uuid> = None;
let mut safe_args = HashMap::<String, Box<RawValue>>::new();
// tracing::error!("{:?}", allow_user_resources);
for k in allow_user_resources.iter() {
if let Some(arg_val) = args.get(k) {
let key = serde_json::from_str::<String>(arg_val.get()).ok();
if let Some(path) =
key.and_then(|x| x.clone().strip_prefix("$res:").map(|x| x.to_string()))
{
if let Some(authed) = authed {
let db_with_opt_authed =
DbWithOptAuthed::from_authed(authed, db.clone(), Some(user_db.clone()));
let res = get_resource_value_interpolated_internal(
&db_with_opt_authed,
w_id,
&path,
None,
None,
false,
)
.await?;
if res.is_none() {
return Err(Error::BadRequest(format!(
"Resource {} not found or not allowed for viewer",
path
)));
}
let job_id = if let Some(job_id) = job_id {
job_id
} else {
job_id = Some(ulid::Ulid::new().into());
job_id.unwrap()
};
let mc = build_crypt_with_key_suffix(&db, &w_id, &job_id.to_string()).await?;
let encrypted = encrypt(&mc, to_raw_value(&res.unwrap()).get());
safe_args.insert(
k.to_string(),
to_raw_value(&format!("$encrypted:{encrypted}")),
);
} else {
return Err(Error::BadRequest(
"User resources are not allowed without being logged in".to_string(),
));
}
}
}
}
for (k, v) in one_of_inputs {
if safe_args.contains_key(k) {
continue;
}
if let Some(arg_val) = args.get(k) {
let arg_str = arg_val.get();
let options_str_vec = v.iter().map(|x| x.get()).collect::<Vec<&str>>();
if options_str_vec.contains(&arg_str) {
safe_args.insert(k.to_string(), arg_val.clone());
args.remove(k);
continue;
}
// check if multiselect
if let Ok(args_str_vec) = serde_json::from_str::<Vec<Box<RawValue>>>(arg_val.get()) {
if args_str_vec
.iter()
.all(|x| options_str_vec.contains(&x.get()))
{
safe_args.insert(k.to_string(), arg_val.clone());
args.remove(k);
continue;
}
}
return Err(Error::BadRequest(format!(
"argument {} with value {} must be one of [{}]",
k,
arg_str,
options_str_vec.join(",")
)));
}
}
for (k, v) in args {
if safe_args.contains_key(&k) {
continue;
}
let arg_str = v.get();
if arg_str.starts_with("\"$ctx:") {
let prop = arg_str.trim_start_matches("\"$ctx:").trim_end_matches("\"");
let value = match prop {
"username" => authed.as_ref().map(|a| {
serde_json::to_value(a.username_override.as_ref().unwrap_or(&a.username))
}),
"email" => authed.as_ref().map(|a| serde_json::to_value(&a.email)),
"workspace" => Some(serde_json::to_value(&w_id)),
"groups" => authed.as_ref().map(|a| serde_json::to_value(&a.groups)),
"author" => Some(serde_json::to_value(&policy.on_behalf_of_email)),
_ => {
return Err(Error::BadRequest(format!(
"context variable {} not allowed",
prop
)))
}
};
safe_args.insert(
k.to_string(),
to_raw_value(&value.unwrap_or(Ok(serde_json::Value::Null)).map_err(|e| {
Error::internal_err(format!(
"failed to serialize ctx variable for {}: {}",
k, e
))
})?),
);
} else if !arg_str.contains("\"$var:") && !arg_str.contains("\"$res:") {
safe_args.insert(k.to_string(), v);
} else {
safe_args.insert(
k.to_string(),
RawValue::from_string(
arg_str
.replace(
"$var:",
"The following variable has been omitted for security reasons: ",
)
.replace(
"$res:",
"The following resource has been omitted for security reasons, to allow it, toggle: 'Allow resources from users' on that field input: ",
),
)
.map_err(|e| {
Error::internal_err(format!(
"failed to remove sensitive variable(s)/resource(s) with error: {}",
e
))
})?,
);
}
}
for k in sensitive_inputs {
let Some(v) = safe_args.get(k) else { continue };
let raw = v.get();
if raw.starts_with("\"$encrypted:") {
continue;
}
let job_id = if let Some(job_id) = job_id {
job_id
} else {
job_id = Some(ulid::Ulid::new().into());
job_id.unwrap()
};
let mc = build_crypt_with_key_suffix(&db, &w_id, &job_id.to_string()).await?;
let encrypted = encrypt(&mc, raw);
safe_args.insert(
k.to_string(),
to_raw_value(&format!("$encrypted:{encrypted}")),
);
}
let mut extra = HashMap::new();
for (k, v) in static_inputs {
extra.insert(k.to_string(), v.to_owned());
}
Ok((
PushArgsOwned { extra: Some(extra), args: safe_args },
job_id,
))
}