Compare commits

...
Author SHA1 Message Date
Ruben FiszelandClaude Opus 5 8c3188f9a1 fix: bump git sync hub scripts to cli 1.815.1
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-18 21:11:52 +02:00
Ruben FiszelandClaude Opus 5 87761d1797 feat: let every EE instance set the announcement banner (#11238)
* feat: let every EE instance set the announcement banner, not only cloud

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

* feat: move the announcement banner setting to the bottom of the general tab

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

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-18 21:03:00 +02:00
f00b2fcb1e feat: drafts follow their item through a move; behind means base ≠ head (#10577)
* refactor: give home multi-select a reserved gutter and a menu entry

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

* fix: keep checkbox theming and reserve the gutter on non-selectable rows

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

* feat: carry every draft with an item when it moves

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

* feat: move draft-only items and warn editors when an item moves

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

* refactor: put the home selection checkbox back in the kind icon slot

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014FaEacdxR6M6VDej6C9r39

* feat: animate the home bulk bar and exit selection at zero

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014FaEacdxR6M6VDej6C9r39

* fix: keep dialog icon badges round and the panel inside narrow viewports

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014FaEacdxR6M6VDej6C9r39

* fix: address review findings on the draft-carry path

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014FaEacdxR6M6VDej6C9r39

* fix: keep a staged rename when a move carries the draft

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

* fix: restamp only the deployer's own carried draft

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

* fix: scope the moved-save restamp to the mover as well

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

* fix: read the app move's author from the head version, not the draft's base

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

* fix: carry a flow draft's baseline path so deploying it cannot un-move the flow

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

* fix: reject unsupported kinds in move_draft, survive NUL-poisoned draft rows

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

* fix: skip NUL-poisoned rows in every draft-value rewrite, not just the first

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

* fix: report a NUL-poisoned draft on move instead of 500ing

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

* fix: name the attempted operation in the NUL rejection message

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

* refactor: drop dead selection code and comments that outlived their state

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

* docs: describe script staleness as head-pinned, which is what the loader does

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

* docs: correct the third staleness comment left claiming a stable fork base

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

* fix: address CI review — auth order, save race, carry failure, path validation

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

* fix: gate operators earlier, skip the write tx without lineage, unblock a chained move

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

* fix: run the post-write moved re-assert under RLS, not the raw pool

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

* test: pin the moved answer to what the saver can see

The post-write re-assert names a path and a username, and nothing at any
layer stopped it reading them off a raw pool connection. Swapping the
transaction back to `db.begin()` compiles and passes everything else, so
the guard has to be a test: a non-admin saving at a path whose item moved
into a folder they cannot see gets `saved`, while the admin gets `moved`.

Also drops two doc comments still arguing that clearing the write gate at
the old path removes the need for an RLS envelope. It does not — the gate
resolves the old path and the re-assert asks about the new one.

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

* docs: name the real deploy path and stop restating the RLS constraint

`update_path` is not a symbol in this repo; a script move goes through
`create_script`. The re-assert's comment re-derived the disclosure argument
that already sits on `resolve_moved_to_in`, where a caller would break it.

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

* docs: state the RLS and restamp constraints once each

The RLS envelope was argued at three sites in drafts.rs; it now sits only on
`resolve_moved_to_in`, whose signature is what a caller would break. The
restamp scoping was copy-pasted at all three deploy call sites while already
documented in full on `move_drafts_for_path`.

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

* fix: carry both path keys on a move, and grant the draft sequence

The upsert now runs as `windmill_user`, so it calls nextval on `draft_id_seq`
as that role. The only thing granting that is the ALTER DEFAULT PRIVILEGES in
20250205131523, whose DO block swallows failures — so an instance where it
errored would fail every autosave with `permission denied for sequence`.

A draft value carries two path keys: the typed one and a mirror the editors
keep in step with it while it differs from the row's path. Rewriting only the
typed one left the mirror naming the old location, and the loaders prefer the
mirror — reopening a moved session script restored the old path and the next
save un-did the move. Both keys now follow, in the move endpoint and in the
passive carry, under the same tri-state rule.

`typed_path_field` answered `draft_path` for every non-script kind, including
resources, variables and triggers, which have no such key. It returns `None`
for them now, and `move_draft` reads its guard off that mapping so the movable
set and the field mapping cannot drift apart.

Also documents that `move_drafts_for_path` mutates every owner's row and
enforces nothing itself, and parses the draft payload once per save instead of
three times.

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

* test: pin the two-key move, and stop the down migration breaking instances

Revoking the sequence grant would strip a privilege a healthy instance had
before this migration ran — the grant it adds is indistinguishable in the
catalog from the one ALTER DEFAULT PRIVILEGES gives at creation time — so the
down is a comment, matching the other grant-only migrations.

The mirror rewrite is spread over three sites that have to agree and fails
silently when they don't, so it gets a test: a draft carrying both path keys
has both moved, and one carrying neither mirror does not gain one. It reads
the value back over HTTP rather than with `sqlx::query!`, which would need an
offline cache entry of its own.

Also drops twelve `.sqlx` entries this branch added and then superseded, and
corrects the doc and openapi text that still described only the typed path
being rewritten.

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

* docs: point the empty down at the grant it is declining to revoke

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

* feat: drop the restamp and tri-state; a move relocates the draft row only

A deploy that renames an item is a deploy like any other: every draft on the item
goes stale, and the stale prompt with its diff is the single mechanism to catch up.
move_drafts_for_path now touches only the row's path column, so the value keeps the
base version the draft actually forked from, and the "moved" patch carries no
version restamp. DraftBaseVersion shrinks to the three per-kind lineage fields.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* feat: stale prompt links to a diff that names and lets you pick the deployed version

The stale-draft prompt gains "See what changed", which opens the diff drawer. The
drawer resolves the deployed side by the draft row's own path (not the typed path,
which after a rename still names the archived row), labels which version the left
pane is, and offers a picker over the item's deployed history for scripts, flows
and raw apps. The history endpoints return created_by (and created_at for apps)
so each entry can name its deployer. "Restore to deployed" moves to the header
actions.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* test: move_to asserts the response status

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix: keep a script draft's base at the version it forked from

The script editor seeded the draft's parent_hash from the deployed head on every
load, and the next autosave persisted it, so a draft behind the deploy read as up
to date after being opened once. The base now comes from the draft when one
exists; the head is only used for a fresh checkout or an explicit topHash. Deploy
already fetches the live head and confirms on mismatch, so the base is what makes
that check meaningful. The webhook "run this version" URL uses the deployed hash
rather than the draft's base.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* feat: store the version a draft forked from in one draft.base column

Every kind kept its fork base under a different name and type inside the
value: parent_hash (hex) for scripts, version_id for flows, parent_version for
apps. draft.base holds it as one text id, derived on save from the value so
every writer fills it the same way, backfilled by the migration (rows holding a
NUL are skipped, since ->> raises on them). The get-by-path overlay exposes it
as draft_base and the drafts list as base; the editors and the compare page
read that one field and compare it to the head as text.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* feat: raw-app drafts carry a fork base, so behind means base != head for them too

The raw-app bundle never carried the version it forked from, which left raw apps
on the timestamp check that self-heals as you type, and the header's deploy guard
read a version prop nothing set, so deploying over a newer version never asked.
The route now stamps parent_version into the bundle (the draft's own base when it
has one, else the head), the server derives draft.base from it, the stale prompt
compares it to the head and links to the diff, and the editor threads it to the
header so the deploy guard confirms. A deploy re-pins the base to the version it
wrote.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* feat: refuse a rename onto a path that already holds a draft

A draft occupies its path the way a deployed item does: a never-deployed item,
or a draft left on an archived script. Renaming onto it would either merge two
items or leave the losing row stranded at a path its item has left. The move now
refuses with a BadRequest inside the deploy's transaction, so the rename itself
fails and the source stays deployed. Every draft on the item then moves; there is
no longer a left-behind count to report.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* feat: save drafts by row id, so an open editor follows its draft through a move

A rename carries every draft on the item to the new path. An editor left open
across it was still saving by the path it opened on, which the server had to
refuse and answer with where the item went (the "moved" handshake and its
modal). The draft row has an id: the get-by-path overlay now returns it as
draft_id, every later save sends it, and the server writes the row wherever it
is and answers with that path. The editor then follows: it flushes what it holds,
tells the user, and navigates to the item's new path, where the stale prompt
says what changed. The lineage-based move resolvers, the moved status and the
moved modal are gone.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* feat: the out-of-date prompt names both versions and can take the latest as the new base

The prompt now says which version the draft forked from and which is deployed
(and by whom), instead of two timestamps, and gains "Take latest, keep my
edits": the draft's base moves to the head and its content stays, so the user
can acknowledge a newer version without discarding their work. Each route sets
its kind's base field on the draft value and persists it; the raw-app bundle
carries it already, so setting the state is enough there.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* feat: two-action out-of-date prompt; taking the latest moves into the diff drawer

Four buttons made the prompt hard to read. It keeps "See what changed" and a
red "Use latest" (it replaces the draft); closing it is keeping the draft.
"Take latest, keep my edits" moves to the diff drawer's header, offered only
while the draft is behind, so the user takes the latest with the diff in front
of them. Scripts, flows and raw apps pass the action through their diff drawer;
the classic app editor has no drawer wired to the prompt and loses it.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* chore: drop the draft_id_seq grant; the draft upsert runs on the raw pool

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

* fix: a moved draft's path keys follow it, and a refused rename names the draft's owner

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

* fix: follow a moved draft on tab close, and deploy a followed flow at its new path

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

* fix: write a followed draft by id against the row's own path keys; keep base on assign and clone

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

* fix: look up a script's head at its row path, and show flow and app version ids bare

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

* fix: session editors save by draft id; raw apps keep a legacy draft's base unknown

Also advance the raw-app base on deploy, relocate once per move, drop the
hoisted operator check and the unread base on drafts/list.

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

* fix: guard a base-unknown raw-app deploy against the head at load; keep the base in session hydration

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

* refactor: the server follows a moved draft through a move record, not client-sent row ids

A move writes old path -> new path (per workspace and kind, per owner for a
draft-only move) in its transaction; a draft save or discard addressed to a
path the caller has no draft at resolves through it and keeps the moved
draft's path keys. Creating an item at a path drops the records leaving it.
Every writer (edit routes, sessions, chat, CLI, the tab-close flush) follows
without passing an id, so the id plumbing is gone.

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

* fix: session loaders keep a draft's base, and a failed relocation flush stays put

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

* fix: a draft-only app move refuses the other app kind; a session keeps an unknown base unknown

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

* test: pin a teammate's carried draft; name the kind that refuses a draft move

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

* fix: an unknown base stays unknown in every loader, and an owner move extends an item move

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

* fix: a clone keeps only a base it can resolve; a base-unknown script deploys without a false guard

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

* fix: a workspace clone sanitizes a NUL-bearing draft instead of copying it unstripped

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

* fix: move records follow an account rename and deletion; a legacy draft says why it cannot move

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

* fix: an owner move extends only the item's own route, not another user's

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

* fix: a redeploy ends a route off its path, take-latest persists on raw apps, stale picker loads are dropped

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

* fix: a poisoned draft's path keys follow a move, legacy only bypasses routing on a delete, picker loads are generation-guarded

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

* fix: count picker load generations, and report a skipped legacy upsert as a conflict

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

* fix: a legacy discard follows the item's move record too

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

* fix: a failed version load keeps the picker on what the diff shows; one spelling for a legacy delete

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

* fix: the picker marks the version on display as head, restore compares the head, relocation follows the last move

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

* fix: say so when a version fails to load in the diff picker

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

* fix: take latest re-reads the head at click time; type the kept head as prepared diff data

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

* fix: taking the latest moves the head each editor knows, not just the base

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

* feat: take latest adopts the head the diff shows, and is offered while the drawer sees the draft behind

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

* fix: a head nobody could name is not behind, so take latest is not offered without one

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

* fix: the flow drawer's head is the version its payload came from, and its callback type says so

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

* fix: a NUL in a move's summary is dropped, and take latest simply adopts the head it was handed

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

* fix: a loaded raw-app draft keeps its own fork base, and an unknown head is refused

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

* fix: a routed discard names where it landed, a superseded drawer opening is dropped, and a loaded draft keeps its base in every editor

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

* fix: a legacy draft occupies its destination, a superseded opening writes nothing, and a loaded flow draft keeps no base it lacks

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

* fix: the drawer owns its opening, a loaded script draft keeps no base it lacks, and a legacy occupant says who can clear it

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

* fix: taking the diff drawer without a token claims it, and the classic app editor takes one

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

* fix: a retried routed discard still names the destination, and filling the drawer takes the opening too

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

* fix: a no-op routed discard names the destination only to someone who could write there

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

* fix: the no-op routed discard gates its answer on reading the destination, and a session draft keeps its unknown base

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

* fix: abandoning an opening clears the drawer it still owns

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

* fix: an app deploy pins only a version it wrote as the next draft's base

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

* fix: the deploy-override diff takes an opening its editor can hand back

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

* refactor: pin the version this deploy wrote even when one landed on top, and tighten three comment blocks

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

* fix: a deploy claims only the version it appended to the head it read, and names the head separately

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

* fix: a deploy always names the head it left behind, and pins a base only when it can claim one

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

* fix: the derived base is read after the sanitizer, and a deploy that claims nothing leaves no base to compare

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

* fix: the route's lineage follows an in-place deploy, and the raw-app editor's event type carries the head

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

* docs: the raw-app deploy comment says what that editor actually does with version

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

* fix: a group member can be told where their item went, and a deploy names the head's author

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

* fix: an emptied selection is no shift anchor, and a deploy leaves no draft for the prompt to compare

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

* fix: session tabs compare the same base pair, and a consumed draft is not out of date

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

* fix: a failed anchor read is not a raced deploy, and take latest closes only its own drawer

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

* fix: an unclaimed deploy always confirms, and the prompt keeps warning a loaded teammate draft

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

* docs: the base-unknown confirmation says what it knows, and two comments match the guard

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

* fix: the other app kind collides whoever owns it, and session tabs get a head to compare

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

* fix: the cross-kind refusal reads properly, and a session flow keeps its own response's head

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

* fix: a fresh session checkout takes the head its payload came from, and a deploy keeps the base it pinned

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

* fix: the move endpoint validates its source path, and two comments say what their branch does

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

* fix: an unanswered head read confirms rather than assuming the app editor is current

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

* fix: a deploy is not blocked by the draft a move carried to its destination

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

* fix: an unread head confirms with the copy for caution, not for an observed deploy

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

* fix: the move record alone excuses a carried draft at the destination, whoever owns it

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

* feat: an app deploy answers with the version it wrote, so the editor stops inferring it

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

* test: the rename assertion reads the deploy's json answer

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

* fix: the unread-head warning reads as caution in the deploy drawer too, and the cross-kind refusal names a remedy

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

* fix: a reused destination retires the routes pointing at it, and draft_base stays out of diffs

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

* fix: the app head is the tail of app.versions, not the newest timestamp

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

* fix: app history lists in deployed order, so the picker numbers it right

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

* fix: the ordering test's setup sql compiles offline, and the head join names its app

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

* fix: kinds that cannot move skip the move lookup, and the move wording needs read

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

* perf: a deploy history comes a page at a time, so the diff drawer opens at once

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

* fix: a history stays whole unless asked to page, and pages inside the version array

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

* fix: an asked-for history page is bounded, and a failed one is not the end of the list

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

* fix: an unasked history is whole again, and an absurd page is empty not an error

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

* fix: naming only a page still asks for one, and a stray version stays reachable

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

* fix: a fork's nul-poisoned draft arrives clean, so its dangling identity repoints too

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

* fix: a raw app names its deployed version even when the history will not load

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

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-authored-by: Ruben Fiszel <ruben@windmill.dev>
2026-09-18 20:54:23 +02:00
Ruben FiszelandClaude Opus 5 974b0265f8 fix(cli): read wmill.yaml from the branch a git-sync deploy writes to (#11236)
* fix(cli): read wmill.yaml from the branch a git-sync deploy writes to

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

* fix(cli): create the stateful dir once the deploy branch's config is read

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

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-18 20:50:23 +02:00
Alexander PetricandClaude Fable 5.1 8f9a94328c give the rich text editor toolbar a bullet list button instead of a second ordered one (#11233)
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-18 20:48:07 +02:00
Alexander PetricandClaude Fable 5.1 3ce158ad58 chore(frontend): upgrade quill to 2.0.3 (#11231)
* chore(frontend): upgrade quill to 2.0.3

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix(apps): load rich text editor HTML through Quill's converter so stored lists survive Quill 2

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix(apps): emit standard ul/ol from the rich text editor output under Quill 2

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix(apps): keep checklist state when normalising the rich text editor output

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-18 20:47:44 +02:00
Ruben Fiszelandrubenfiszel 01b38320ea chore(main): release 1.815.0 (#11212)
* chore(main): release 1.815.0

* Apply automatic changes

---------

Co-authored-by: rubenfiszel <275584+rubenfiszel@users.noreply.github.com>
2026-09-18 15:28:25 +02:00
hugocasaandClaude Opus 5 37e493ae66 feat: add an instance setting to refuse a token in MCP URLs (#11162)
* feat: add an instance setting to refuse a token in MCP URLs

MCP clients are commonly configured with the token in the URL
(`/api/mcp/w/{workspace}/mcp?token=...`). A URL-borne credential ends up in
browser history, proxy logs and referrers, so an instance can now turn that
channel off with the `mcp_disable_token_query_param` global setting and leave
the Authorization header as the only way in, which sends MCP clients through
the OAuth flow the endpoints already advertise.

The rejection is a middleware on both the workspaced and the gateway MCP
mounts, layered outside everything that reads a token and inside the
WWW-Authenticate layer, so the 401 carries the resource pointer a client needs
to start OAuth discovery.

Off by default. With it on, the token drawer and the home connect drawer stop
offering to mint a token for an MCP URL and hand over the bare URL instead.

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

* fix: read the MCP URL policy when a URL is asked for, and drop the all-workspaces option

Two review findings on the token drawer:

The policy was read once per page load and cached for the browser session, so a
superadmin turning the setting on left every open tab handing out `?token=` URLs
the server now refuses. Both entry points now read it when the user actually asks
for an MCP URL: when MCP mode is entered, and when the connect drawer opens.

The workspace picker offered "All workspaces / Multi-workspace", but the gateway's
consent screen binds the token it issues to the one workspace picked there, so
OAuth has no multi-workspace grant to hand out. That entry is now token-only.

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

* fix: don't guess the MCP URL policy, and say where the switch lands on restart

Review findings:

The comment on the settings load claimed `MODE=mcp` as the target deployment, but
that mode joins no monitor loop, so the startup pass is its only read and a change
lands on restart. That is true of every global setting there, `base_url` included;
the comment now says so, and the setting description tells an operator running
dedicated MCP servers what to expect.

A failed settings probe resolved to "tokens allowed", so with the switch on the
drawer would mint a non-expiring token and hand over a URL the server refuses for
as long as it exists. The probe now propagates its error and the panel reports it
with a retry, creating nothing until the answer is known.

The test passed a valid token, so it could not tell a rejection before
authentication from one after it. It now also sends a token that was never valid
and asserts the middleware's own message, which fails if the layer moves inward.

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

* fix: withhold the MCP URL until a workspace is picked

With no persisted workspace the store starts undefined, so opening the drawer from
/user/workspaces before choosing one rendered a copyable
`/api/mcp/w/undefined/mcp`. It reads like a real URL and a client pointed at it
would never connect. The panel now asks for a workspace instead, matching the guard
the token branch already has on its generate button.

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

* docs: drop the coverage-status note from the MCP switch test

It documented what the test does not reach rather than a constraint the next
reader could break; that belongs in the PR, not the module doc. The layer-order
rationale, which is what a future edit would break, stays.

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

* refactor: fall back to the bare MCP URL instead of alerting on a failed read

When the setting read fails, show the bare URL rather than an error with a retry.
It works whichever way the setting is, so no alert is needed, and it still never
mints a token for a URL the server may refuse. The connect drawer's wording falls
back the same way so the blurb matches the panel.

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

* refactor: move the MCP URL token setting to Core

It sat in the Auth/OAuth/SAML list, which the settings sidebar shows under SSO,
suggesting a dependency on SSO that does not exist: MCP OAuth has Windmill act as
the authorization server, and any login method, password included, completes it.
It is an instance-wide credential policy, so it now lives with the other ones in
Core, kept out of quick setup like its neighbours.

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

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-09-18 15:24:00 +02:00
f0d66a42eb fix: re-encrypt git sync secrets on workspace key rotation (#11218)
* fix: re-encrypt git sync credentials and webhook secrets on workspace key rotation

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

* chore: point ee-repo-ref at the git sync key rotation companion

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

* chore: update ee-repo-ref to bc3ef08c8e4233508c023e6ee847a3cd0b8be43b

This commit updates the EE repository reference after PR #814 was merged in windmill-ee-private.

Previous ee-repo-ref: 8121eac421c5d026f36e2edec140f3c79b23d2cd

New ee-repo-ref: bc3ef08c8e4233508c023e6ee847a3cd0b8be43b

Automated by sync-ee-ref workflow.

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: windmill-internal-app[bot] <windmill-internal-app[bot]@users.noreply.github.com>
2026-09-18 15:21:07 +02:00
hugocasaandClaude Opus 5 9d335de87a feat: add a workspace toggle that adds its admins and developers to new forks (#11215)
* feat: add a workspace toggle that adds its admins and developers to new forks

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

* fix: add members copied into a fork as manual members, not instance-group ones

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

* style: keep the fork members copy comment at the query and drop the raw spacer

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

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-09-18 15:15:35 +02:00
hugocasaandClaude Opus 5 53afecd458 fix: register the job token with the sensitive log masking system (#10943)
* fix(worker): register the job token with the log masking system

The masking system covered secrets fetched through `get_value_internal` and
`$encrypted:` args, but not the job's own token, so a script that echoed
`$WM_TOKEN` wrote it verbatim into logs that are persisted to the database
and, when configured, to object storage.

`run_worker` now registers the token for the job it just pulled, alongside
the existing `register_running_job` call, so it is redacted like any other
registered secret.

That makes every job carry at least one registered value, where before the
per-batch mask snapshot was skipped entirely for the majority of jobs that
touched no secret. Cache the compiled Aho-Corasick automaton per job and
invalidate it when a new secret is registered, so a chatty job no longer
rebuilds it once per log batch.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P316wKe2QCYNcdsx1PwAJ3

* fix(nativets): mask secrets in the in-process log path

NativeTS hands `console.log` output to a task that drains a channel into
`append_logs`, so it never reaches the masking in `handle_child::write_lines`
and a script logging `$WM_TOKEN` persisted the raw JWT.

That drain can still be flushing after the job is unregistered, so a plain
per-line `snapshot` would leave the tail unmasked. `JobMasker` keeps the last
masks it saw for exactly that window, and refreshes while the job is alive so
secrets fetched mid-run are covered too.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P316wKe2QCYNcdsx1PwAJ3

* fix(nativets): seed the job masker at construction

A `JobMasker` that only looked up its masks on the first `mask` call had the
same hole at the head of the log that its retention closes at the tail: if the
drain task's first productive poll landed after the job was unregistered, the
registry was already gone and every line was written raw.

`new` now takes the snapshot, and its callers construct it from the job's own
execution while the job is still registered.

Also cover the nativets sink with an integration test, gated on `deno_core` the
way the CI test build is.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P316wKe2QCYNcdsx1PwAJ3

* fix(nativets): mask on the producing side of the log channel

Masking as the drain task wrote to `append_logs` left two holes, because that
task is detached and outlives the job: a secret registered mid-run could still
be queued when the job was unregistered and would then be written raw, and the
`windmill:job_log` tracing emission that EE forwards job logs on never went
through the mask at all.

Mask where the line is produced instead. That loop is joined before the job
completes, so the job's secrets are always still registered, and one call now
covers both the tracing mirror and the channel. The result stream keeps reading
the raw text, the way `handle_child` keeps its raw `line` for results.

`JobMasker` is no longer load-bearing for the post-unregistration window, so it
is documented for what it now does: keep the security notice to once per set of
secrets for a sink that masks line by line.

Also drop the nativets test's tag override — `DEFAULT_TAGS` does advertise
`nativets`, so the comment justifying it was wrong — and pin the automaton
cache invalidation, whose failure mode is an unmasked secret.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P316wKe2QCYNcdsx1PwAJ3

* fix(worker): hold the log-masking lifecycle at the job boundary

Registering the job around the poller's call left every other way of running a
job uncovered: the interactive worker shell and inline AI agent tools both call
`handle_queued_job` directly, and a script logging `$WM_TOKEN` from either
persisted the live credential. Register from inside `handle_queued_job` instead,
under a drop guard, so each path is covered by construction rather than by
remembering to add a call. Nothing is lost by unregistering earlier: the writes
that follow go through `append_logs`, which never consulted the registry.

In nativets, decide the stream/log routing before masking. `MaskSnapshot`'s
notice is one-shot, so a secret-bearing `WM_STREAM:` chunk used to spend it on
text that is then discarded, leaving later redactions in `job_logs` unexplained.

Restore the masker's post-unregistration test: the memory-limit path never joins
the producing loop, so that fallback is still load-bearing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P316wKe2QCYNcdsx1PwAJ3

* docs: correct the nativets masking comments

The producer loop is not joined on the memory-limit path, so it does not
"always" run while the job is registered — say normally, which is what
`JobMasker`'s fallback is there for. Name the reason a stream chunk stays raw
everywhere it goes, including the tracing mirror: it is result data that no log
sink persists, so masking it would be masking a result. State the masker test's
invariant without asserting a mechanism behind it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P316wKe2QCYNcdsx1PwAJ3

* fix(masking): keep the security notice on a line of its own

`mask` appended the notice as a newline plus the notice text, which
assumes the caller hands it a bare log line. nativets hands it a chunk
that already ends in a newline, and its sink concatenates chunks
verbatim, so the notice arrived after a blank line and the next log line
was welded onto the end of it.

Emit the notice as its own line for either shape. `handle_child` is
unaffected: its input never ends in a newline, so it keeps the original
path.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P316wKe2QCYNcdsx1PwAJ3

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-18 15:15:02 +02:00
GuilhemandClaude Opus 5 3b4e13d1c5 fix: key the large root font size on screen width, not window width (#11216)
* fix: key the large root font size on screen width, not window width

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

* fix: move the flow preview button offset to the screen-width query

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

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-18 14:51:32 +02:00
Alexander PetricandClaude Fable 5.1 0cb92a7dcf chore(security): resolve Dependabot alerts in python-client/wmill/uv.lock (#11180)
Targeted lock bumps only (idna, pytest, pygments); requires-python kept at >=3.14.

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-18 14:47:15 +02:00
Alexander PetricandClaude Fable 5.1 2a390c173d chore: remove two lockfiles that nothing consumes (#11183)
benchmarks/pulumi/package-lock.json: the directory is used with bun (bun.lockb); no workflow,
Dockerfile or script installs from this lockfile.

rust-client/Cargo.lock: library crate whose Cargo.toml is generated by dev.nu; the lockfile is
not consumed by downstream crates.io users and was stale (pinned 1.518.2). Ignored from now on.

Both only generated Dependabot alerts about dependency trees that never ship.

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-18 14:45:37 +02:00
hugocasaandClaude Opus 5 a08992834d feat: render an AI agent result as its answer, not as raw JSON (#11051)
* feat: render an AI agent result as its answer, not as raw JSON

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

* fix: sanitize agent markdown through the shared plugin chain

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

* refactor: fold agent stream events incrementally per poll

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

* fix: separate the agent meta line from the result toggle group

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

* feat: add a transcript view of an agent run's conversation

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

* refactor: retire AIAgentLogViewer in favour of the transcript

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

* feat: show the partial transcript a max-iterations failure carries

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

* fix: move the agent meta line and system prompt below the conversation

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

* refactor: show an agent run as what it did, not as a conversation

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

* refactor: name the agent run breakdown a trace

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

* fix(ai-agent): label the save-as-agent form fields per the guidelines

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

* fix(ai-agent): reuse the resource form's path and description fields

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

* fix(ai-agent): keep the action tags on a max-iterations failure

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

* fix(ai-agent): keep offline replay inert and the streamed answer to one turn

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

* fix(ai-agent): reset the streamed answer on providers that skip tool_call

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

* fix: carry the event type narrowing through the stream parser

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

* fix: survive a malformed message rather than take the viewer down

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

* refactor: coerce agent messages once at the parse boundary

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

* feat: show an agent run as one scroll ending in its output

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

* fix: keep every streamed turn instead of dropping the narration

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

* fix: share the chat divider and drop the unsafe run auto-scroll

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

* fix: give the labelled divider a border colour and the standard pretty icon

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

* fix: pad the agent run below its badges as well as above

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

* feat: follow a streaming run's pane without moving the page

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

* refactor: share the chat's stick-to-bottom mechanics with the agent run

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

* fix: keep the answer's citations and end a turn's reasoning with the turn

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

* refactor: read the agent stream through the windmill-chat sdk parser

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

* fix: show an agent run's thinking instead of falling back to raw json

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

* fix: stop a stream the fold cannot use from claiming the run pane

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

* fix: identify an agent run by more than the job id the replay withholds

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

* refactor: drop the tests and comment lines that were not earning their place

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

* fix: stop a streamed turn's text shifting when a tool call closes it

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

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-18 14:38:46 +02:00
hugocasaandClaude Opus 5 48f00259c5 feat: support $flow_expr[...] dynamic tags on flow steps (#11170)
* feat: resolve $flow_expr[...] dynamic tags on flow steps

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

* fix: run an unresolvable $flow_expr tag on the default tag and give it the step input scope

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

* refactor: resolve $flow_expr tags by path lookup instead of expression evaluation

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

* docs: say a $flow_expr tag fails to resolve, not to evaluate

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

* fix(frontend): word the $flow_expr help like the other dynamic tag lines

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

* fix: reject a malformed $flow_expr placeholder instead of queueing its literal tag

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

* refactor: render $flow_expr tag values through the $args path lookup

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

* test: pin interpolate_args through the shared tag path rendering

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

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-09-18 14:37:50 +02:00
hugocasaandClaude Opus 5 9320312eac feat: cap user token expiration with an instance setting (#11159)
* feat: cap user token expiration with an instance setting

Adds `max_token_expiration_days`, an instance-wide ceiling on how far ahead a
token created through `POST /users/tokens/create` may expire. With it set, that
route refuses a token with no expiration and one that expires past the window;
absent or non-positive, nothing changes.

Only the user-facing handler enforces it. Server-side mints (native trigger
webhook tokens, app embed tokens, sessions) pick a lifetime the caller never
chooses and go straight to `create_token_internal`, so they stay uncapped, as
does the superadmin `impersonate` route.

Service accounts are exempt, in the workspace the token targets or in any
workspace for a global token, so unattended automation can keep longer-lived
credentials.

The token form now surfaces the API error instead of only logging it, and offers
"Expires In" in MCP mode as well: that mode always sent no expiration, which the
cap refuses, leaving MCP URLs impossible to generate.

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

* fix: shorten over-long token expirations instead of refusing them

Refusing a non-compliant request breaks the callers that cannot comply. The CLI
authorization page, `wmill user create-token` and the editor's language-server
token each pick a lifetime — usually none at all — with no way to read the
setting, so a cap made browser login hang and the editor lose its LSP root
rather than stopping the long-lived tokens the setting is aimed at.

`cap_token_expiration` now returns the expiration to store, shortening a request
that asks for too long or for none. The policy still holds absolutely, no caller
can break, and there is no clock-skew boundary where an expiration exactly at the
ceiling flips to an error. The token form needed no changes at all, so its MCP
and error-toast edits are gone with it.

Also drops the Enterprise badge on the setting, which nothing enforced, notes the
mint paths in docs/auth-surface.md, and pins that `tokens/impersonate` and the
second-workspace case stay outside the exemption.

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

* fix: drop the unintended token-form change and correct the exemption docs

The token form needed no change once the ceiling shortens rather than refuses,
but the earlier revert restored from the index, which already held the staged
edit, so the MCP expiration field and the error toast stayed on the branch with
a comment justifying them by a refusal that no longer happens.

docs/auth-surface.md claimed a service-account row in any workspace exempts
outright; that only holds for a workspace-less token, which has no workspace to
match. A ceiling written as a string, which the YAML instance config and config
sync can both produce, now has a test.

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

* feat: offer only expirations within the ceiling in the token form

The server shortens a token that asks for longer than `max_token_expiration_days`
or for no expiration, which the token form could not tell anyone: a user picking
"No expiration" got the ceiling silently. The form now reads the setting and,
with one set, drops "No expiration" and every choice above it, adds the ceiling
itself as "N days (maximum)" and selects it, and says the instance limits tokens
to N days.

MCP mode hides the expiration field and always sent none, so with a ceiling the
field now shows there too and keeps its value across the toggle. Without a
ceiling the form is unchanged.

Reading it needs no superadmin: the setting joins the keys any logged-in user
can read through `GET /settings/global/{key}`. It holds a policy, not a secret.

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

* fix: make the token form and the server agree on what counts as a ceiling

The form parsed `max_token_expiration_days` more loosely than
`cap_token_expiration`, so the two could disagree on whether a ceiling exists
at all. A `7.0` from the YAML instance config, or a string such as "7.0" or
"1e1", made the form hide "No expiration" and announce a 7-day limit while the
server capped nothing; a value between chrono's and JavaScript's date limits
preselected an expiration the server could not parse.

Both now read the same thing as a ceiling: a whole number of days from 1 to
1,000,000, stored as an integer, an integral float or a string of digits.
`parseMaxTokenExpirationDays` holds the frontend's copy, and the instance
settings validation uses it too, so the settings page no longer accepts a value
the server would ignore. The bound replaces the date-range guard on both sides.

Also corrects the rationale for shortening rather than refusing: the setting is
now readable by any logged-in user, so those callers do not read it rather than
cannot, and CLIs already installed never will.

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

* fix: cap service-account tokens like everyone else's

The ticket exempted service accounts from `max_token_expiration_days`, but
their tokens are the long-lived ones a rotation policy is meant to bound, and
the exemption let any workspace admin get an uncapped token by impersonating
one. It also left the token form unable to agree with the server: an admin
impersonating a service account was offered only capped choices while the
server would have kept any.

`cap_token_expiration` now takes just the requested expiration, with no
per-caller lookup, and the service-account query and its cache entry are gone.

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

* fix: cap superadmin impersonation tokens and pin the frontend parser

`POST /users/tokens/impersonate` wrote its own token row with whatever
expiration the superadmin sent, so it was the one route left that could mint a
token that never expires with `max_token_expiration_days` set. The ceiling only
decides the stored expiration (the auth lookup never reads the setting), so
leaving it uncapped meant exactly that. It now goes through
`cap_token_expiration` like `create_token`; nothing in Windmill calls it, so no
caller changes.

Also adds `tokenExpiration.test.ts`, pinning which stored values
`parseMaxTokenExpirationDays` reads as a ceiling against the server's reading,
and documents that tokens existing when the setting is turned on or lowered
keep their expiration.

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

* fix: reject a max_token_expiration_days the token routes cannot read

The settings API and config sync stored any value for the key, and the token
routes can only read an unparseable one as no ceiling. A typo such as `7.5` or
"7.0" was accepted and silently turned the policy off.

`parse_max_token_expiration_days` in windmill-common is now the single server
reading of the setting: null or empty clears it, a whole number of days within
the bound is the ceiling, anything else is an error. The settings write hook and
`sync_global_settings_declarative` reject that error, and `cap_token_expiration`
reads through the same function, logging a value written around both.

Tests: the parser's accept/clear/reject table (the same table as the frontend
parser's), the settings API refusing 7.5, and config sync refusing "7.0".

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

* fix: reserve the CLI login token label so its expiry does not email the user

With a token expiration ceiling, the token the CLI authorization page mints now
expires, so every `wmill` login earned an "expiring soon" and an "expired and
deleted" email and critical alert. The CLI already signs in again on its own
when that token stops working, so those notifications ask the user to do
nothing.

The page now labels it `cli-login:<username>` (previously `cli-<username>`),
reserved in `is_user_token` and its SQL and Svelte mirrors: no expiry
notifications, and the label cannot be edited. A colon-terminated namespace
like `embed_app:` and `impersonation:` keeps hand-made labels clear of it. Not
in `is_server_minted_label`, since the page mints through `/users/tokens/create`.

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

* fix: skip the expiring-soon warning for tokens that were short-lived from the start

A token whose whole lifetime fits in the 7-day warning window got its
"expiring soon" email (and critical alert, when enabled) minutes after it was
created, about a lifetime its creator had just picked. With an expiration
ceiling of 7 days or less that is every token created from the form or
`wmill token create`.

`register_token_expiry_notification` no longer queues a warning for such a
token. The window is now `TOKEN_EXPIRY_WARNING_DAYS`, shared with
`check_expiring_tokens`, so shortening the warning window can never leave
tokens of an intermediate lifetime with no warning at all. The "expired and
deleted" notice still goes out for every user token.

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

* fix: exempt service-account tokens from the expiration ceiling again

Service accounts are the identity automation that needs a long-lived credential
runs as, so their tokens are exempt from `max_token_expiration_days` once more:
a service account in the workspace the token names, or in any workspace for a
workspace-less token. `tokens/impersonate` checks the impersonated account, so
a superadmin minting a token for a service account gets the same exemption.

The token form applies the same rule for the account it is running as, when
that account is a service account in the current workspace, which is what an
admin impersonating one sees; otherwise it would offer only capped choices
while the server keeps any.

Any workspace admin can create and impersonate a service account to hold an
uncapped token, so the ceiling bounds personal tokens; the doc comment and
docs/auth-surface.md say so.

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

* fix: decide the token form's service-account exemption from the token's workspace

The form treated the account as a service account only when it was one in the
workspace the app was on, while the server checks the workspace the token is
for, or any workspace for a workspace-less token. With an email that is a
service account in one workspace and an ordinary member of another, picking the
other workspace in MCP mode offered "No expiration" and the server silently
stored the ceiling; the reverse hid the exemption.

`GET /workspaces/users` now returns each membership's `is_service_account`
(its query already joins the `usr` row), and the form applies the server's rule
to the token's own workspace. The selection becomes a derived value held within
the ceiling, so switching to a capped workspace never leaves an unoffered choice
selected.

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

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-09-18 14:37:39 +02:00
hugocasaandClaude Opus 5 9690c4462c fix: re-point cloned fork identities that name nobody in the fork (#11161)
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-09-18 14:37:25 +02:00
Alexander PetricandClaude Fable 5.1 de4818d905 chore(security): resolve Dependabot alerts in frontend, windmill-yaml-validator, typescript-client and cli lockfiles (#11181)
Targeted dependency bumps only; no behavior change intended.

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-18 14:36:33 +02:00
Alexander PetricandClaude Fable 5.1 1015874f7c install the multiplayer server from its lockfile (#11190)
DockerfileExtra copied multiplayer/package.json alone and ran `npm install`,
so the image shipped whatever npm resolved at build time and the committed
multiplayer/package-lock.json described nothing that ran in production:
Dependabot alerts on it were meaningless either way. Copy the lockfile and
install with `npm ci --omit=dev` so the image is reproducible and the lock is
the source of truth for what ships.

The lock is refreshed with `npm update <dep>` per direct dependency, which
moves each to the newest version inside its existing caret range without
touching package.json. That is exactly what a lockfile-less `npm install`
resolves today (verified: a fresh `npm install --package-lock-only` from the
same package.json produces identical versions), so the image does not regress:

  ws           8.19.0  -> 8.21.3  (covers the open alerts on ws < 8.21.0)
  y-websocket  3.0.0   -> 3.1.0
  yjs          13.6.29 -> 13.6.32
  lib0 0.2.117, y-protocols 1.0.7, isomorphic.js 0.2.5 unchanged

package.json has no devDependencies and the lock has no `dev: true` entries,
so `--omit=dev` changes nothing today and only guards against future ones.

Validation:
- `npm ci` on the pre-update lock and `npm ci --omit=dev` on the updated lock
  both succeed; `node --check server.mjs gateway.mjs` passes.
- Smoke start: server.mjs listens on PORT=3999 and logs "Multiplayer server
  running"; gateway.mjs listens on PORT=3998 and logs its route table.
- The same COPY/RUN lines built on node:22-slim with the repo root as build
  context (the context build-extra-image.yml uses) install the six locked
  packages and pass `node --check`. A full DockerfileExtra build was skipped:
  it is a single stage on an uncached multi-GB base image.

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-18 14:35:23 +02:00
Alexander PetricandClaude Fable 5.1 813e486e16 fix(frontend): inline only the package version, not the whole package.json (#11191)
`vite.config.js` had `define: { __pkg__: version }` where `version` was
the entire parsed `package.json`. Vite's `define` substitutes the full
object literal at every site that reads `__pkg__.version`, so each of
those sites became `{name:..., version:..., scripts:{...},
devDependencies:{...}, ...}.version` in the client bundle and the
minifier kept the object. On origin/main that is 11 copies of a
22,093-byte object (npm scripts, dependency/devDependency lists,
overrides) across three chunks — 243,023 bytes of package.json shipped
to every browser, for a single version string.

The only property ever read is `__pkg__.version` (script_helpers.ts,
apps/editor/component/default-codes.ts, flows/content/s3Scripts/deno.ts),
and the declarations in src/app.d.ts, src/global.d.ts and
sharedUtils/sharedUtils.d.ts already type it as `{ version: string }`,
so define the dotted expression `__pkg__.version` to the JSON-encoded
version string instead. Any new property read would now fail
`npm run check` rather than silently compile to `undefined`.

Verification (npm ci, generate-backend-client, check, build):
- `npm run check`: 0 errors, 82 warnings.
- `grep -l '"generate-backend-client"' build/_app/immutable/**/*.js`:
  3 chunks before, none after.
- `grep -c 'windmill-labs/components' -r build/_app/immutable`: 11 → 0.
- `npm:windmill-client@1.813.0` still appears in the chunks that build
  the default Deno/pgsql/S3 snippets (3 chunks, 11 sites).
- build/_app/immutable apparent size: 81,815 KB → 81,578 KB
  (JS bytes 54,202,793 → 53,959,705, -243,088 bytes).

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-18 14:34:11 +02:00
Diego Imbert ecd0a6c77b fix: show the New menu's description panel only on hover (#11199)
* fix: show the New menu's description panel only on hover

* fix: keep the New menu's submenus reachable with the mouse
2026-09-18 14:33:24 +02:00
df61dea5fa fix: keep instance groups when editing auto-invite (#11217)
* fix: keep instance groups when editing auto-invite and push them from sync

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

* fix: compare settings.yaml without undeclared instance groups on push

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

* fix: read a missing auto_invite as empty when diffing settings.yaml on push

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

* chore: update ee-repo-ref to f2fced19fcae81de7f6dac545010ce404c052e1b

This commit updates the EE repository reference after PR #813 was merged in windmill-ee-private.

Previous ee-repo-ref: cf4258c1232720ca3b82db2b22ea1fb4bca9533e

New ee-repo-ref: f2fced19fcae81de7f6dac545010ce404c052e1b

Automated by sync-ee-ref workflow.

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: windmill-internal-app[bot] <windmill-internal-app[bot]@users.noreply.github.com>
Co-authored-by: Ruben Fiszel <ruben@windmill.dev>
2026-09-18 14:32:55 +02:00
hugocasaandClaude Opus 5 5639187fec feat(auth): 2 h login links and a click-to-sign-in page for emailed ones (#11203)
* feat(auth): 2 h login links and a click-to-sign-in page for emailed ones

Raise the login link cap from 15 min to 2 h, so a link sent by email still
works when it is read.

A link minted with `confirm: true` is a /user/login_link page instead of
the API path. Loading the page does nothing; its button POSTs to
/api/auth/login_link/{token}, which spends the link and answers where to go.
Mail scanners that open links on delivery no longer burn them. Links minted
without `confirm` still sign in on open.

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

* fix(auth): keep the login link page to design-system components

A tokenless visit bounced off a raw <p>; send it to the page a spent link
already bounces to, and show the modal's own spinner while it goes.

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

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-09-18 14:31:16 +02:00
AlexRV12andClaude Opus 5 c8c06d8f79 feat(ai-chat): tell the chat which kind of app it is looking at (#11208)
* feat(ai-chat): tell the chat which kind of app it is looking at

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018r5ppn8YmHsM1MaAwY7xgB

* refactor(ai-chat): drop a stale comment and hoist the prefix local

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018r5ppn8YmHsM1MaAwY7xgB

* test(ai-chat): add a global eval for refusing to edit a drag-and-drop app

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018r5ppn8YmHsM1MaAwY7xgB

* test(ai-chat): make the app-kind eval require editing the code app too

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018r5ppn8YmHsM1MaAwY7xgB

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-18 14:19:06 +02:00
AlexRV12andClaude Opus 5 6f9c4dc294 feat(ai-chat): render get_run through the run tools' card (#11204)
* feat(ai-chat): render get_run through the run tools' card

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

* fix(ai-chat): retry unreadable logs on reopen and drop the loading tint

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

* refactor(ai-chat): inline two single-use deriveds in the run card

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

* docs(ai-chat): record why an inspected run's logs need the dedicated endpoint

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

* fix(ai-chat): key an inspected run's fetch to the tool call, not the job

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

* docs(ai-chat): say an inspected run's logs are a tail, not the whole log

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

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-18 14:16:52 +02:00
Henri CourdentandRuben Fiszel 041ac95dc4 Auth providers capital (#11209)
Co-authored-by: Ruben Fiszel <ruben@windmill.dev>
2026-09-18 13:59:58 +02:00
242 changed files with 11371 additions and 6942 deletions
+1 -1
View File
@@ -1,3 +1,3 @@
{
".": "1.814.0"
".": "1.815.0"
}
+34
View File
@@ -1,5 +1,39 @@
# Changelog
## [1.815.0](https://github.com/windmill-labs/windmill/compare/v1.814.0...v1.815.0) (2026-09-18)
### Features
* add a workspace toggle that adds its admins and developers to new forks ([#11215](https://github.com/windmill-labs/windmill/issues/11215)) ([9d335de](https://github.com/windmill-labs/windmill/commit/9d335de87a4dbaa51038d55afe8d980761dcdfaf))
* add an instance setting to refuse a token in MCP URLs ([#11162](https://github.com/windmill-labs/windmill/issues/11162)) ([37e493a](https://github.com/windmill-labs/windmill/commit/37e493ae66ed5c000ecac492d60fc0fdf4bda71f))
* **ai-chat:** render get_run through the run tools' card ([#11204](https://github.com/windmill-labs/windmill/issues/11204)) ([6f9c4dc](https://github.com/windmill-labs/windmill/commit/6f9c4dc29455d13b0e64af05c2d6aa8bd5ff4fd6))
* **ai-chat:** tell the chat which kind of app it is looking at ([#11208](https://github.com/windmill-labs/windmill/issues/11208)) ([c8c06d8](https://github.com/windmill-labs/windmill/commit/c8c06d8f79774abf109192e71a8b6fc37c7937ba))
* attach files to a flow chat message ([#11185](https://github.com/windmill-labs/windmill/issues/11185)) ([c4c9677](https://github.com/windmill-labs/windmill/commit/c4c9677982b75c63d98ebf85b1904e0c341ba957))
* **auth:** 2 h login links and a click-to-sign-in page for emailed ones ([#11203](https://github.com/windmill-labs/windmill/issues/11203)) ([5639187](https://github.com/windmill-labs/windmill/commit/5639187fec6d517a72e82df49d63d7438301127c))
* cap user token expiration with an instance setting ([#11159](https://github.com/windmill-labs/windmill/issues/11159)) ([9320312](https://github.com/windmill-labs/windmill/commit/9320312eac56f944c4d31504601293ab4e816ccc))
* flow chat job-backed detail, smooth streaming and answer chrome ([#11186](https://github.com/windmill-labs/windmill/issues/11186)) ([e2a91ca](https://github.com/windmill-labs/windmill/commit/e2a91ca2b141a0bee64ddfafe3cbf3ff86b6fb35))
* put a data table's connection under Postgres roles ([#11020](https://github.com/windmill-labs/windmill/issues/11020)) ([0e807fb](https://github.com/windmill-labs/windmill/commit/0e807fb1dd80d7536ec144cd49445abc7961e504))
* render an AI agent result as its answer, not as raw JSON ([#11051](https://github.com/windmill-labs/windmill/issues/11051)) ([a089928](https://github.com/windmill-labs/windmill/commit/a08992834d45d0211336f4fc32c3421646ca47c5))
* support $flow_expr[...] dynamic tags on flow steps ([#11170](https://github.com/windmill-labs/windmill/issues/11170)) ([48f0025](https://github.com/windmill-labs/windmill/commit/48f00259c5e7361d3553dbcb809461e1cde96f8f))
### Bug Fixes
* **frontend:** inline only the package version, not the whole package.json ([#11191](https://github.com/windmill-labs/windmill/issues/11191)) ([813e486](https://github.com/windmill-labs/windmill/commit/813e486e166ac6215364817a7732b66f8dc1d463))
* keep instance groups when editing auto-invite ([#11217](https://github.com/windmill-labs/windmill/issues/11217)) ([df61dea](https://github.com/windmill-labs/windmill/commit/df61dea5fa8b18d1e0044dc0db6702b053d5119f))
* key the large root font size on screen width, not window width ([#11216](https://github.com/windmill-labs/windmill/issues/11216)) ([3b4e13d](https://github.com/windmill-labs/windmill/commit/3b4e13d1c564c6195e30b55b0671f7533e3ce408))
* re-encrypt git sync secrets on workspace key rotation ([#11218](https://github.com/windmill-labs/windmill/issues/11218)) ([f0d66a4](https://github.com/windmill-labs/windmill/commit/f0d66a42eba27502767423af0c08b2ca1f16e8df))
* re-point cloned fork identities that name nobody in the fork ([#11161](https://github.com/windmill-labs/windmill/issues/11161)) ([9690c44](https://github.com/windmill-labs/windmill/commit/9690c4462cf264a5577b87d07d465b5442b4e09d))
* register the job token with the sensitive log masking system ([#10943](https://github.com/windmill-labs/windmill/issues/10943)) ([53afecd](https://github.com/windmill-labs/windmill/commit/53afecd4588247bc1812d3e68a30db1f3c3b2724))
* show the New menu's description panel only on hover ([#11199](https://github.com/windmill-labs/windmill/issues/11199)) ([ecd0a6c](https://github.com/windmill-labs/windmill/commit/ecd0a6c77bc3a057b8072dbb0aca731e8bd3d882))
* stop picker listing a draft twice when drafts share a friendly path ([#11214](https://github.com/windmill-labs/windmill/issues/11214)) ([72507d5](https://github.com/windmill-labs/windmill/commit/72507d52a021de8101794ee8f4a3392be1ba20c7))
### Performance Improvements
* count completed jobs on the v2_job_completed index with a timeout ([#11211](https://github.com/windmill-labs/windmill/issues/11211)) ([d1a2536](https://github.com/windmill-labs/windmill/commit/d1a25360b070994a31fb033707dbfde1fb553939))
## [1.814.0](https://github.com/windmill-labs/windmill/compare/v1.813.0...v1.814.0) (2026-09-17)
+8 -4
View File
@@ -49,12 +49,16 @@ export interface BenchmarkWorkspaceFlow {
export interface BenchmarkWorkspaceApp {
path: string
summary: string
/** Defaults to true. Set false for a drag-and-drop app, which the chat can list
* and read but has no tool to edit — its value is a grid, not files. */
rawApp?: boolean
value: {
files: Record<string, string>
runnables: Record<string, unknown>
files?: Record<string, string>
runnables?: Record<string, unknown>
data?: unknown
policy?: unknown
custom_path?: unknown
[key: string]: unknown
}
}
@@ -994,7 +998,7 @@ function buildBenchmarkListableApp(app: BenchmarkWorkspaceApp): ListableApp {
extra_perms: {},
edited_at: BENCHMARK_TIMESTAMP,
execution_mode: 'viewer',
raw_app: true
raw_app: app.rawApp ?? true
}
}
@@ -1012,7 +1016,7 @@ function buildBenchmarkApp(app: BenchmarkWorkspaceApp): AppWithLastVersion {
execution_mode: 'viewer',
extra_perms: {},
custom_path: app.value.custom_path as string | undefined,
raw_app: true
raw_app: app.rawApp ?? true
}
}
+37
View File
@@ -2611,3 +2611,40 @@
judgeChecklist:
- runs the existing script rather than rewriting it
- passes the GitHub resource as the bare string $res:f/evals/global/github_main
- id: global-drag-and-drop-app-not-editable
prompt: |-
Add a refresh button to the ops console app, and the same to the sales board app.
initial: ai_evals/fixtures/frontend/global/initial/apps_code_and_drag_and_drop.json
runtime:
maxTurns: 12
validate:
# One request, two apps, only one of them editable: the code app must come back with a
# draft and the drag-and-drop one must not. Refusing both, or editing both, fails here —
# which is what makes this a test of the distinction rather than of caution.
draftCountExactly: 1
requiredDrafts:
- type: app
path: f/evals/global/ops_console
forbiddenDrafts:
- type: app
path: f/evals/global/sales_board
toolExpect:
# Deliberately not constraining write_app_file/patch_app_file by argument: an entry there
# fails when its tool was never called, so naming both would fail on whichever the model
# did not pick. The draft assertions above cover the same ground, tool-agnostically.
forbiddenToolsUsed:
- init_app
- deploy_workspace_item
- delete_app_file
- delete_app_runnable
assistantExpect:
# A refusal leaves no draft for the judge to read, so the explanation is checked here.
# Only the app kind: substring tests cannot see paraphrase, and every wording of "I can't
# edit it" defeats a fixed list.
requiredMentionsAnyOf:
- - drag-and-drop
- drag and drop
- low-code
- no-code
skipJudge: true
@@ -0,0 +1,30 @@
{
"user": {
"username": "admin",
"is_admin": true,
"folders": ["evals"],
"folders_read": ["evals"]
},
"workspace": {
"apps": [
{
"path": "f/evals/global/sales_board",
"summary": "Sales board",
"rawApp": false,
"value": {
"grid": []
}
},
{
"path": "f/evals/global/ops_console",
"summary": "Ops console",
"value": {
"files": {
"/App.tsx": "export default function App() {\n\treturn <div>Ops console</div>\n}\n"
},
"runnables": {}
}
}
]
}
}
@@ -0,0 +1,43 @@
{
"db_name": "PostgreSQL",
"query": "SELECT s.hash as hash, dm.deployment_msg as deployment_msg, s.created_at as created_at, s.created_by as created_by\n FROM script s LEFT JOIN deployment_metadata dm ON s.hash = dm.script_hash\n WHERE s.workspace_id = $1 AND s.path = $2\n ORDER by s.created_at DESC\n LIMIT $3 OFFSET $4",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "hash",
"type_info": "Int8"
},
{
"ordinal": 1,
"name": "deployment_msg",
"type_info": "Text"
},
{
"ordinal": 2,
"name": "created_at",
"type_info": "Timestamptz"
},
{
"ordinal": 3,
"name": "created_by",
"type_info": "Varchar"
}
],
"parameters": {
"Left": [
"Text",
"Text",
"Int8",
"Int8"
]
},
"nullable": [
false,
true,
false,
false
]
},
"hash": "044310c0e7745867a24a9d3653ff29c87ce48ffd89fac070c8314556f884ce55"
}
@@ -0,0 +1,17 @@
{
"db_name": "PostgreSQL",
"query": "UPDATE draft AS d\n SET path = $3::text,\n value = (\n SELECT CASE\n WHEN s.clean -> 'path' = to_jsonb($2::text)\n OR s.clean -> 'draft_path' = to_jsonb($2::text)\n THEN to_json(\n s.clean\n || CASE WHEN s.clean -> 'path' = to_jsonb($2::text)\n THEN jsonb_build_object('path', $3::text)\n ELSE '{}'::jsonb END\n || CASE WHEN s.clean -> 'draft_path' = to_jsonb($2::text)\n THEN jsonb_build_object('draft_path', $3::text)\n ELSE '{}'::jsonb END\n )\n ELSE d.value\n END\n FROM (SELECT replace(replace(replace(d.value::text, chr(92) || chr(92), chr(1)),\n chr(92) || 'u0000', ''), chr(1), chr(92) || chr(92))::jsonb AS clean) s\n )\n WHERE d.workspace_id = $1\n AND d.path = $2::text\n AND d.typ::text = ANY($4::text[])",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Text",
"Text",
"Text",
"TextArray"
]
},
"nullable": []
},
"hash": "09e3f8fa8a9e64048971983584bfa00ab137c70b73f381452cadfae6e1cda934"
}
@@ -0,0 +1,71 @@
{
"db_name": "PostgreSQL",
"query": "SELECT value as \"value!: sqlx::types::Json<Box<serde_json::value::RawValue>>\",\n created_at, base\n FROM draft\n WHERE workspace_id = $1\n AND (email = $2 OR email IS NULL)\n AND path = $3\n AND typ = $4\n ORDER BY email NULLS LAST\n LIMIT 1",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "value!: sqlx::types::Json<Box<serde_json::value::RawValue>>",
"type_info": "Json"
},
{
"ordinal": 1,
"name": "created_at",
"type_info": "Timestamptz"
},
{
"ordinal": 2,
"name": "base",
"type_info": "Text"
}
],
"parameters": {
"Left": [
"Text",
"Text",
"Text",
{
"Custom": {
"name": "draft_kind",
"kind": {
"Enum": [
"script",
"flow",
"app",
"raw_app",
"resource",
"variable",
"trigger_schedule",
"trigger_webhook",
"trigger_default_email",
"trigger_email",
"trigger_http",
"trigger_websocket",
"trigger_postgres",
"trigger_kafka",
"trigger_nats",
"trigger_mqtt",
"trigger_sqs",
"trigger_gcp",
"trigger_azure",
"trigger_poll",
"trigger_cli",
"trigger_nextcloud",
"trigger_google",
"trigger_github",
"data_pipeline",
"trigger_amqp"
]
}
}
}
]
},
"nullable": [
false,
false,
true
]
},
"hash": "0cc6770a81ecaecafe0d9b7100f94b329c4ec7978b016af4c964415e0c38396c"
}
@@ -0,0 +1,64 @@
{
"db_name": "PostgreSQL",
"query": "UPDATE draft\n SET path = $3,\n -- Both path keys, not just the typed one: the editors mirror the\n -- typed path into the other while it differs from the row's path,\n -- and the loaders prefer the mirror — left naming the old location\n -- it un-does this move on the next save. `create_missing = false`\n -- on both, so a draft carrying only one keeps only one.\n value = to_json(\n jsonb_set(\n jsonb_set(\n CASE WHEN $7::text IS NULL THEN to_jsonb(value)\n ELSE jsonb_set(to_jsonb(value), ARRAY['summary'], to_jsonb($7::text))\n END,\n ARRAY[$5::text], to_jsonb($3::text), false\n ),\n ARRAY[$8::text], to_jsonb($3::text), false\n )\n )\n WHERE workspace_id = $1\n AND path = $2\n AND typ = $4\n AND email = $6\n -- A pre-sanitizer NUL escape makes `to_jsonb` raise 22P05. Excluded\n -- here so the statement can't 500; reported below instead. Unlike the\n -- passive carry, rewriting the value IS this operation, so skipping it\n -- silently would move the row and leave its typed path stale.\n AND position(chr(92) || 'u0000' in replace(value::text, chr(92) || chr(92), '')) = 0\n -- Skipped on a summary-only edit, where the \"target\" row is this\n -- row and the guard would refuse the update against itself.\n AND ($2 = $3 OR NOT EXISTS (\n SELECT 1 FROM draft o\n WHERE o.workspace_id = $1 AND o.path = $3 AND o.typ::text = ANY($9::text[])\n -- Of this kind only the caller's own row and the legacy one collide:\n -- teammates' drafts of one item share its path by design, but a deploy\n -- there wipes those two together, so a second would discard edits the\n -- caller never saw. The other app kind is a different item on the same\n -- deployed path, so it collides whoever owns it.\n AND (o.typ <> $4 OR o.email = $6 OR o.email IS NULL)\n ))\n RETURNING id",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "id",
"type_info": "Int8"
}
],
"parameters": {
"Left": [
"Text",
"Text",
"Text",
{
"Custom": {
"name": "draft_kind",
"kind": {
"Enum": [
"script",
"flow",
"app",
"raw_app",
"resource",
"variable",
"trigger_schedule",
"trigger_webhook",
"trigger_default_email",
"trigger_email",
"trigger_http",
"trigger_websocket",
"trigger_postgres",
"trigger_kafka",
"trigger_nats",
"trigger_mqtt",
"trigger_sqs",
"trigger_gcp",
"trigger_azure",
"trigger_poll",
"trigger_cli",
"trigger_nextcloud",
"trigger_google",
"trigger_github",
"data_pipeline",
"trigger_amqp"
]
}
}
},
"Text",
"Text",
"Text",
"Text",
"TextArray"
]
},
"nullable": [
false
]
},
"hash": "0d9a83d77632bc0de19a48eddb7e6f80b2a41db4f1d352920d0c295ea3a018b1"
}
@@ -0,0 +1,14 @@
{
"db_name": "PostgreSQL",
"query": "DELETE FROM draft_move WHERE email = $1",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Text"
]
},
"nullable": []
},
"hash": "11af34e5fe5b8ed59453d58628ed5fb3b2120835158f22d715b73460f6a55108"
}
@@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
"query": "SELECT s.hash as hash, dm.deployment_msg as deployment_msg, s.created_at as created_at\n FROM script s LEFT JOIN deployment_metadata dm ON s.hash = dm.script_hash\n WHERE s.workspace_id = $1 AND s.path = $2\n ORDER by s.created_at DESC",
"query": "SELECT s.hash as hash, dm.deployment_msg as deployment_msg, s.created_at as created_at, s.created_by as created_by\n FROM script s LEFT JOIN deployment_metadata dm ON s.hash = dm.script_hash\n WHERE s.workspace_id = $1 AND s.path = $2\n ORDER by s.created_at DESC LIMIT 1",
"describe": {
"columns": [
{
@@ -17,6 +17,11 @@
"ordinal": 2,
"name": "created_at",
"type_info": "Timestamptz"
},
{
"ordinal": 3,
"name": "created_by",
"type_info": "Varchar"
}
],
"parameters": {
@@ -28,8 +33,9 @@
"nullable": [
false,
true,
false,
false
]
},
"hash": "9a1483a81f5b086e0765d3d69483e29b09f66090e1f9d394564c16d921d2e66c"
"hash": "148fbee57c6abbb6f361818c5b6bfc8a1db451d31dfddbb5be945b3551c8becb"
}
@@ -0,0 +1,49 @@
{
"db_name": "PostgreSQL",
"query": "SELECT a.id as app_id, av.id as version_id, dm.deployment_msg as deployment_msg,\n av.created_by as created_by, av.created_at as created_at\n FROM app a\n JOIN LATERAL (\n SELECT av2.id, COALESCE(v.ord, 0) AS ord\n FROM app_version av2\n LEFT JOIN unnest(a.versions) WITH ORDINALITY AS v(id, ord) ON v.id = av2.id\n WHERE av2.app_id = a.id\n ORDER BY ord DESC, av2.id DESC\n LIMIT $3 OFFSET $4\n ) page ON TRUE\n JOIN app_version av ON av.id = page.id\n LEFT JOIN deployment_metadata dm ON av.id = dm.app_version\n WHERE a.workspace_id = $1 AND a.path = $2\n ORDER BY page.ord DESC",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "app_id",
"type_info": "Int8"
},
{
"ordinal": 1,
"name": "version_id",
"type_info": "Int8"
},
{
"ordinal": 2,
"name": "deployment_msg",
"type_info": "Text"
},
{
"ordinal": 3,
"name": "created_by",
"type_info": "Varchar"
},
{
"ordinal": 4,
"name": "created_at",
"type_info": "Timestamptz"
}
],
"parameters": {
"Left": [
"Text",
"Text",
"Int8",
"Int8"
]
},
"nullable": [
false,
false,
true,
false,
false
]
},
"hash": "18b38efcb141101192b372e7d7e7e47481877f3ab717682f3b84e665c95a5214"
}
@@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
"query": "WITH legacy AS (\n DELETE FROM draft\n WHERE workspace_id = $1 AND path = $2 AND typ = $3 AND email IS NULL\n RETURNING value\n )\n INSERT INTO draft (workspace_id, email, path, typ, value, created_at)\n SELECT $1, $4, $2, $3, value, now() FROM legacy\n ON CONFLICT (workspace_id, path, typ, email) WHERE email IS NOT NULL\n DO UPDATE SET value = EXCLUDED.value, created_at = now()\n RETURNING 1 as \"one!\"",
"query": "WITH legacy AS (\n DELETE FROM draft\n WHERE workspace_id = $1 AND path = $2 AND typ = $3 AND email IS NULL\n RETURNING value, base\n )\n INSERT INTO draft (workspace_id, email, path, typ, value, created_at, base)\n SELECT $1, $4, $2, $3, value, now(), base FROM legacy\n ON CONFLICT (workspace_id, path, typ, email) WHERE email IS NOT NULL\n DO UPDATE SET value = EXCLUDED.value, created_at = now(), base = EXCLUDED.base\n RETURNING 1 as \"one!\"",
"describe": {
"columns": [
{
@@ -55,5 +55,5 @@
null
]
},
"hash": "46f00a75b2e7e4ac70758a9687070f68bc0421f1aa228f80157adda63191d33b"
"hash": "1c6859291963a9ca38d73f500213a575bfcd4469524f0dc8812470737759f02b"
}
@@ -1,15 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "INSERT INTO workspace_settings (workspace_id, slack_team_id, slack_name, slack_command_script, slack_email, customer_id, plan, webhook, ai_config, large_file_storage, git_sync, default_app, default_scripts, deploy_ui, mute_critical_alerts, color, operator_settings, teams_command_script, teams_team_id, teams_team_name, git_app_installations, git_credentials, ducklake, dbt_warehouses, slack_oauth_client_id, slack_oauth_client_secret, datatable, teams_team_guid, auto_invite, error_handler, success_handler, public_app_execution_limit_per_minute, error_handler_fallback_to_instance_alerts, guest_access_enabled, guest_jwt_public_key, guest_jwt_jwks_url) SELECT $1, slack_team_id, slack_name, slack_command_script, slack_email, customer_id, plan, webhook, ai_config, large_file_storage, git_sync, default_app, default_scripts, deploy_ui, mute_critical_alerts, color, operator_settings, teams_command_script, teams_team_id, teams_team_name, git_app_installations, git_credentials, ducklake, dbt_warehouses, slack_oauth_client_id, slack_oauth_client_secret, datatable, teams_team_guid, auto_invite, error_handler, success_handler, public_app_execution_limit_per_minute, error_handler_fallback_to_instance_alerts, guest_access_enabled, guest_jwt_public_key, guest_jwt_jwks_url FROM workspace_settings WHERE workspace_id = $2",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Varchar",
"Text"
]
},
"nullable": []
},
"hash": "1d8ccd32266637d7f7915f92a8483dce0c9986f3847227c2f66daa84d4109d7d"
}
@@ -0,0 +1,18 @@
{
"db_name": "PostgreSQL",
"query": "INSERT INTO draft_move (workspace_id, typ, old_path, new_path, email)\n SELECT m.workspace_id, m.typ, m.old_path, $4, $5::text\n FROM draft_move m\n WHERE m.workspace_id = $1 AND m.typ::text = ANY($2::text[])\n AND m.new_path = $3 AND m.email IS NULL\n AND NOT EXISTS (\n SELECT 1 FROM draft_move o\n WHERE o.workspace_id = m.workspace_id AND o.typ = m.typ\n AND o.old_path = m.old_path AND o.email = $5::text\n )",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Text",
"TextArray",
"Text",
"Varchar",
"Text"
]
},
"nullable": []
},
"hash": "232ff36c2d522383061cafbf9639698dbc44a18c0f016aae94a1705f06fd1e48"
}
@@ -1,15 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "UPDATE workspace_settings SET auto_invite = $1 WHERE workspace_id = $2",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Jsonb",
"Text"
]
},
"nullable": []
},
"hash": "255ba68caa78d0e814ea817693a319f769907025f9c7d1150d45e8ecb1bff4ab"
}
@@ -0,0 +1,16 @@
{
"db_name": "PostgreSQL",
"query": "INSERT INTO usr (workspace_id, username, email, is_admin, created_at, operator, disabled, role, is_service_account, added_via)\n SELECT $1, username, email, is_admin, created_at, operator, disabled, role, is_service_account,\n CASE WHEN $3 THEN NULL ELSE added_via END\n FROM usr WHERE workspace_id = $2\n AND (NOT $3 OR (NOT operator AND NOT disabled AND NOT is_service_account))\n ON CONFLICT DO NOTHING",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Varchar",
"Text",
"Bool"
]
},
"nullable": []
},
"hash": "2d6b117324eaf076a0ed06d2cb0ce73279957d6a39fdc6b1ecb6e0a1e02f921f"
}
@@ -0,0 +1,22 @@
{
"db_name": "PostgreSQL",
"query": "SELECT add_admins_and_developers_to_forks FROM workspace_settings WHERE workspace_id = $1",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "add_admins_and_developers_to_forks",
"type_info": "Bool"
}
],
"parameters": {
"Left": [
"Text"
]
},
"nullable": [
false
]
},
"hash": "2f39fce0ee700117f3e4c066e0b56ee979e4c392970278304e80368b770bb7b4"
}
@@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
"query": "UPDATE token SET label = $1\n WHERE email = $2 AND token_prefix = $3\n AND (label IS NULL OR (\n label <> 'session'\n AND label <> 'guest_session'\n AND lower(label) NOT LIKE 'ephemeral%'\n AND label <> 'debugger-token'\n AND label NOT LIKE 'mcp-oauth-%'\n AND NOT starts_with(label, 'embed_app:')\n AND NOT starts_with(label, 'sdk_app:')\n AND NOT starts_with(label, 'impersonation:')\n ))\n RETURNING token_prefix",
"query": "UPDATE token SET label = $1\n WHERE email = $2 AND token_prefix = $3\n AND (label IS NULL OR (\n label <> 'session'\n AND label <> 'guest_session'\n AND lower(label) NOT LIKE 'ephemeral%'\n AND label <> 'debugger-token'\n AND label NOT LIKE 'mcp-oauth-%'\n AND NOT starts_with(label, 'embed_app:')\n AND NOT starts_with(label, 'sdk_app:')\n AND NOT starts_with(label, 'impersonation:')\n AND NOT starts_with(label, 'cli-login:')\n ))\n RETURNING token_prefix",
"describe": {
"columns": [
{
@@ -20,5 +20,5 @@
false
]
},
"hash": "d631a26e5531589ff37e677f91a4f1d9f850e3e46c17130dd580426cda7a9f65"
"hash": "383c80239525d9c4ee90e2f5cb6e46c8a0983c52ad15db083f84a9e344dc4c99"
}
@@ -0,0 +1,18 @@
{
"db_name": "PostgreSQL",
"query": "INSERT INTO draft_move (workspace_id, typ, old_path, new_path, email)\n SELECT $1, t::draft_kind, $3, $4, $5 FROM unnest($2::text[]) t",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Varchar",
"TextArray",
"Varchar",
"Varchar",
"Varchar"
]
},
"nullable": []
},
"hash": "3a015ab279785a15f89c89d8c4d155e85370d4c2b7ed14fbb510919e7624ca1c"
}
@@ -0,0 +1,18 @@
{
"db_name": "PostgreSQL",
"query": "UPDATE draft_move SET new_path = $4\n WHERE workspace_id = $1 AND typ::text = ANY($2::text[])\n AND new_path = $3 AND ($5::text IS NULL OR email = $5)",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Text",
"TextArray",
"Text",
"Varchar",
"Text"
]
},
"nullable": []
},
"hash": "3db5f1d8ec1f8d867cf01c066876122e78f0c76a93a1d9860e8dff05d9a02c09"
}
@@ -0,0 +1,15 @@
{
"db_name": "PostgreSQL",
"query": "DELETE FROM draft_move dest\n WHERE dest.email = $1\n AND EXISTS (SELECT 1 FROM draft_move src\n WHERE src.email = $2\n AND src.workspace_id = dest.workspace_id\n AND src.old_path = dest.old_path\n AND src.typ = dest.typ)",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Text",
"Text"
]
},
"nullable": []
},
"hash": "5959c60da91f1e62080a4455e2a4627cb1fd3d8c70210bf7f38cb222dd15d88f"
}
@@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
"query": "\n UPDATE workspace_settings\n SET\n ai_config = source_ws.ai_config,\n large_file_storage = source_ws.large_file_storage,\n ducklake = source_ws.ducklake,\n dbt_warehouses = source_ws.dbt_warehouses,\n datatable = source_ws.datatable,\n git_app_installations = source_ws.git_app_installations\n FROM workspace_settings source_ws\n WHERE source_ws.workspace_id = $1\n AND workspace_settings.workspace_id = $2\n ",
"query": "\n UPDATE workspace_settings\n SET\n ai_config = source_ws.ai_config,\n large_file_storage = source_ws.large_file_storage,\n ducklake = source_ws.ducklake,\n dbt_warehouses = source_ws.dbt_warehouses,\n datatable = source_ws.datatable,\n git_app_installations = source_ws.git_app_installations,\n add_admins_and_developers_to_forks = source_ws.add_admins_and_developers_to_forks\n FROM workspace_settings source_ws\n WHERE source_ws.workspace_id = $1\n AND workspace_settings.workspace_id = $2\n ",
"describe": {
"columns": [],
"parameters": {
@@ -11,5 +11,5 @@
},
"nullable": []
},
"hash": "a6decdebcd9750691f20f874b66a9c6f2ede08c33605c6a3c3dfc213a3eda76a"
"hash": "5ccfbd0f345b9b86ca356008def6cc1011a49fc8d8ad046ac572e81cf8938995"
}
@@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
"query": "DELETE FROM token_expiry_notification n\n USING token t\n WHERE n.token_hash = t.token_hash\n AND n.expiration > now()\n AND n.expiration <= now() + interval '7 days'\n RETURNING t.token_prefix, t.label, t.email, t.workspace_id",
"query": "DELETE FROM token_expiry_notification n\n USING token t\n WHERE n.token_hash = t.token_hash\n AND n.expiration > now()\n AND n.expiration <= now() + make_interval(days => $1)\n RETURNING t.token_prefix, t.label, t.email, t.workspace_id",
"describe": {
"columns": [
{
@@ -25,7 +25,9 @@
}
],
"parameters": {
"Left": []
"Left": [
"Int4"
]
},
"nullable": [
false,
@@ -34,5 +36,5 @@
true
]
},
"hash": "94fd0a57cfc9341b2e9deae60506c6c06aa6934b87200da14231f12f65149cd3"
"hash": "6c57c46c5a0462f379ed6a22ae97e363009177fdc45d795447a920f455f193cc"
}
@@ -0,0 +1,23 @@
{
"db_name": "PostgreSQL",
"query": "SELECT EXISTS(SELECT 1 FROM usr WHERE email = $1 AND is_service_account IS true\n AND ($2::varchar IS NULL OR workspace_id = $2))",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "exists",
"type_info": "Bool"
}
],
"parameters": {
"Left": [
"Text",
"Varchar"
]
},
"nullable": [
null
]
},
"hash": "75e6b5cd52d63ac094c90abd1524bc7a919692a96b076b8aaf24d812723531c8"
}
@@ -0,0 +1,72 @@
{
"db_name": "PostgreSQL",
"query": "INSERT INTO draft (workspace_id, email, path, typ, value, created_at, base)\n VALUES ($1, $2, $3::text, $4,\n CASE WHEN $10::bool\n THEN to_json($5::text::jsonb || jsonb_strip_nulls(jsonb_build_object(\n 'path', CASE WHEN $5::text::jsonb -> 'path' = to_jsonb($11::text)\n THEN to_jsonb($3::text) END,\n 'draft_path', CASE WHEN $5::text::jsonb -> 'draft_path' = to_jsonb($11::text)\n THEN to_jsonb($3::text) END)))\n ELSE $5::text::json\n END,\n COALESCE($8::timestamptz, now()), $9)\n ON CONFLICT (workspace_id, path, typ, email) WHERE email IS NOT NULL\n DO UPDATE SET value = CASE\n WHEN NOT $10::bool\n OR position(chr(92) || 'u0000' in replace(draft.value::text, chr(92) || chr(92), '')) > 0\n THEN EXCLUDED.value\n ELSE to_json((to_jsonb(EXCLUDED.value) - 'path' - 'draft_path')\n || jsonb_strip_nulls(jsonb_build_object(\n 'path', to_jsonb(draft.value) -> 'path',\n 'draft_path', to_jsonb(draft.value) -> 'draft_path')))\n END,\n created_at = EXCLUDED.created_at,\n base = EXCLUDED.base\n WHERE $7::bool = true\n OR $6::timestamptz IS NULL\n OR draft.created_at <= $6::timestamptz\n RETURNING path, created_at",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "path",
"type_info": "Varchar"
},
{
"ordinal": 1,
"name": "created_at",
"type_info": "Timestamptz"
}
],
"parameters": {
"Left": [
"Varchar",
"Varchar",
"Text",
{
"Custom": {
"name": "draft_kind",
"kind": {
"Enum": [
"script",
"flow",
"app",
"raw_app",
"resource",
"variable",
"trigger_schedule",
"trigger_webhook",
"trigger_default_email",
"trigger_email",
"trigger_http",
"trigger_websocket",
"trigger_postgres",
"trigger_kafka",
"trigger_nats",
"trigger_mqtt",
"trigger_sqs",
"trigger_gcp",
"trigger_azure",
"trigger_poll",
"trigger_cli",
"trigger_nextcloud",
"trigger_google",
"trigger_github",
"data_pipeline",
"trigger_amqp"
]
}
}
},
"Text",
"Timestamptz",
"Bool",
"Timestamptz",
"Text",
"Bool",
"Text"
]
},
"nullable": [
false,
false
]
},
"hash": "7761959d05fff8c314d04c1bc9fb4e84a61008dbec0995ae192f1b3a1c261579"
}
@@ -0,0 +1,18 @@
{
"db_name": "PostgreSQL",
"query": "DELETE FROM draft_move\n WHERE workspace_id = $1 AND typ::text = ANY($2::text[])\n AND old_path IN ($3, $4) AND ($5::text IS NULL OR email = $5)",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Text",
"TextArray",
"Varchar",
"Varchar",
"Text"
]
},
"nullable": []
},
"hash": "797896e0152046844dbfee9d0e57e0055d742e44119bfb31ae7ff37a89b34d14"
}
@@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
"query": "SELECT workspace.id, workspace.name, usr.username, workspace_settings.color, workspace.parent_workspace_id,\n workspace.is_dev_workspace, workspace.dev_workspace_label,\n workspace.owner AS \"created_by?\",\n CASE WHEN usr.operator THEN workspace_settings.operator_settings ELSE NULL END as operator_settings,\n usr.disabled\n FROM workspace\n JOIN usr ON usr.workspace_id = workspace.id\n JOIN workspace_settings ON workspace_settings.workspace_id = workspace.id\n WHERE usr.email = $1 AND workspace.deleted = false",
"query": "SELECT workspace.id, workspace.name, usr.username, workspace_settings.color, workspace.parent_workspace_id,\n workspace.is_dev_workspace, workspace.dev_workspace_label,\n workspace.owner AS \"created_by?\",\n CASE WHEN usr.operator THEN workspace_settings.operator_settings ELSE NULL END as operator_settings,\n usr.disabled, usr.is_service_account\n FROM workspace\n JOIN usr ON usr.workspace_id = workspace.id\n JOIN workspace_settings ON workspace_settings.workspace_id = workspace.id\n WHERE usr.email = $1 AND workspace.deleted = false",
"describe": {
"columns": [
{
@@ -52,6 +52,11 @@
"ordinal": 9,
"name": "disabled",
"type_info": "Bool"
},
{
"ordinal": 10,
"name": "is_service_account",
"type_info": "Bool"
}
],
"parameters": {
@@ -69,8 +74,9 @@
true,
false,
null,
false,
false
]
},
"hash": "88a134e4ca82d5ce0334977c7713021ae3e99a5a61ea1c944c1df1368746dfa5"
"hash": "7b33adb5cf051bc123340982b97a80a96ee43e1c91d9907d50cc2ee14111623e"
}
@@ -0,0 +1,43 @@
{
"db_name": "PostgreSQL",
"query": "SELECT flow_version.id, flow_version.created_at, flow_version.created_by, deployment_metadata.deployment_msg FROM flow_version\n LEFT JOIN deployment_metadata ON flow_version.id = deployment_metadata.flow_version\n WHERE flow_version.path = $1 AND flow_version.workspace_id = $2\n ORDER BY flow_version.created_at DESC\n LIMIT $3 OFFSET $4",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "id",
"type_info": "Int8"
},
{
"ordinal": 1,
"name": "created_at",
"type_info": "Timestamptz"
},
{
"ordinal": 2,
"name": "created_by",
"type_info": "Varchar"
},
{
"ordinal": 3,
"name": "deployment_msg",
"type_info": "Text"
}
],
"parameters": {
"Left": [
"Text",
"Text",
"Int8",
"Int8"
]
},
"nullable": [
false,
false,
false,
true
]
},
"hash": "7ff60d155510303e307c47dcb6153ccaac72871eb68822d8e296c0d747e55f35"
}
@@ -1,16 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "INSERT INTO draft (workspace_id, path, typ, value, created_at, email)\n SELECT $2, path, typ,\n CASE WHEN typ IN ('script', 'flow')\n THEN to_json(to_jsonb(value) - 'on_behalf_of')\n ELSE value END,\n created_at, email\n FROM draft\n WHERE workspace_id = $1 AND (email = $3 OR email IS NULL)",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Text",
"Varchar",
"Text"
]
},
"nullable": []
},
"hash": "87afd5289e54b6accda8a399ec3c66df3d19217059f62903be3c74eadb9c15d5"
}
@@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
"query": "\n SELECT\n workspace_id,\n slack_team_id,\n teams_team_id,\n teams_team_name,\n teams_team_guid,\n slack_name,\n slack_command_script,\n teams_command_script,\n slack_email,\n slack_oauth_client_id,\n slack_oauth_client_secret,\n customer_id,\n plan,\n webhook,\n ai_config,\n dbt_warehouses,\n large_file_storage,\n datatable,\n ducklake,\n git_sync,\n deploy_ui,\n default_app,\n default_scripts,\n mute_critical_alerts,\n color,\n operator_settings,\n git_app_installations,\n auto_invite,\n error_handler,\n success_handler,\n public_app_execution_limit_per_minute,\n error_handler_fallback_to_instance_alerts,\n guest_access_enabled,\n guest_jwt_public_key,\n guest_jwt_jwks_url\n FROM\n workspace_settings\n WHERE\n workspace_id = $1\n ",
"query": "\n SELECT\n workspace_id,\n slack_team_id,\n teams_team_id,\n teams_team_name,\n teams_team_guid,\n slack_name,\n slack_command_script,\n teams_command_script,\n slack_email,\n slack_oauth_client_id,\n slack_oauth_client_secret,\n customer_id,\n plan,\n webhook,\n ai_config,\n dbt_warehouses,\n large_file_storage,\n datatable,\n ducklake,\n git_sync,\n deploy_ui,\n default_app,\n default_scripts,\n mute_critical_alerts,\n color,\n operator_settings,\n git_app_installations,\n auto_invite,\n error_handler,\n success_handler,\n public_app_execution_limit_per_minute,\n error_handler_fallback_to_instance_alerts,\n guest_access_enabled,\n guest_jwt_public_key,\n guest_jwt_jwks_url,\n add_admins_and_developers_to_forks\n FROM\n workspace_settings\n WHERE\n workspace_id = $1\n ",
"describe": {
"columns": [
{
@@ -177,6 +177,11 @@
"ordinal": 34,
"name": "guest_jwt_jwks_url",
"type_info": "Text"
},
{
"ordinal": 35,
"name": "add_admins_and_developers_to_forks",
"type_info": "Bool"
}
],
"parameters": {
@@ -219,8 +224,9 @@
false,
false,
true,
true
true,
false
]
},
"hash": "dc4a57df3becc610f631ef22c116450390addbfae85fecc61c991d94167e6e99"
"hash": "8ebe054b41793f1a7b85f1f8d29cd21d12fa1207b58bfc46249c250cdcdb5363"
}
@@ -0,0 +1,17 @@
{
"db_name": "PostgreSQL",
"query": "DELETE FROM draft_move WHERE workspace_id = $1 AND typ::text = ANY($2::text[])\n AND (old_path = $3 OR (new_path = $3 AND $4::text IS NULL))",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Text",
"TextArray",
"Text",
"Text"
]
},
"nullable": []
},
"hash": "9723edfd6de38cf0bf21123a46d88fa7d0b650b5cfd80c099f785f5e411062e5"
}
@@ -0,0 +1,15 @@
{
"db_name": "PostgreSQL",
"query": "UPDATE workspace_settings SET add_admins_and_developers_to_forks = $1 WHERE workspace_id = $2",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Bool",
"Text"
]
},
"nullable": []
},
"hash": "9bd1995747f0073b3a866d1238e78f7e6dfa9185056f7731547ff05b8176b271"
}
@@ -0,0 +1,18 @@
{
"db_name": "PostgreSQL",
"query": "DELETE FROM draft_move\n WHERE workspace_id = $1 AND typ::text = ANY($2::text[])\n AND new_path = $4 AND old_path <> $3 AND ($5::text IS NULL OR email = $5)",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Text",
"TextArray",
"Text",
"Text",
"Text"
]
},
"nullable": []
},
"hash": "9d1b202844e6935e636d1570a92d46af4419dda9410ffc46f19bc38e56efe609"
}
@@ -1,35 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "SELECT flow_version.id, flow_version.created_at, deployment_metadata.deployment_msg FROM flow_version \n LEFT JOIN deployment_metadata ON flow_version.id = deployment_metadata.flow_version\n WHERE flow_version.path = $1 AND flow_version.workspace_id = $2 \n ORDER BY flow_version.created_at DESC",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "id",
"type_info": "Int8"
},
{
"ordinal": 1,
"name": "created_at",
"type_info": "Timestamptz"
},
{
"ordinal": 2,
"name": "deployment_msg",
"type_info": "Text"
}
],
"parameters": {
"Left": [
"Text",
"Text"
]
},
"nullable": [
false,
false,
true
]
},
"hash": "a0f1c0df6bc2f1fbca50edee90e42c94445536e201b322eda6f7a90bdf38f36a"
}
@@ -0,0 +1,15 @@
{
"db_name": "PostgreSQL",
"query": "UPDATE workspace_settings SET auto_invite = (COALESCE(auto_invite, '{}'::jsonb) - 'domain') || $1::jsonb WHERE workspace_id = $2",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Jsonb",
"Text"
]
},
"nullable": []
},
"hash": "a1bcf143135ecb9f32709a56a353d3246dc238920c3e155caa9cf767246fa6fb"
}
@@ -0,0 +1,85 @@
{
"db_name": "PostgreSQL",
"query": "SELECT\n -- The guard's own predicate, ordered own row, then legacy, then another\n -- user's other-kind row: each needs a different remedy, and a bare\n -- LIMIT 1 would name an arbitrary one.\n (SELECT typ::text FROM draft WHERE workspace_id = $1 AND path = $3\n AND typ::text = ANY($6::text[])\n AND (typ <> $2 OR email = $4 OR email IS NULL)\n ORDER BY CASE WHEN email = $4 THEN 0 WHEN email IS NULL THEN 1 ELSE 2 END\n LIMIT 1) as \"at_target\",\n EXISTS(SELECT 1 FROM draft WHERE workspace_id = $1 AND path = $3\n AND typ::text = ANY($6::text[]) AND email = $4) as \"at_target_own!\",\n EXISTS(SELECT 1 FROM draft WHERE workspace_id = $1 AND path = $3\n AND typ::text = ANY($6::text[]) AND email IS NULL) as \"at_target_legacy!\",\n EXISTS(SELECT 1 FROM draft WHERE workspace_id = $1 AND path = $5\n AND typ = $2 AND email = $4\n AND position(chr(92) || 'u0000' in replace(value::text, chr(92) || chr(92), '')) > 0\n ) as \"poisoned!\",\n EXISTS(SELECT 1 FROM draft WHERE workspace_id = $1 AND path = $5\n AND typ = $2 AND email IS NULL) as \"legacy!\" ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "at_target",
"type_info": "Text"
},
{
"ordinal": 1,
"name": "at_target_own!",
"type_info": "Bool"
},
{
"ordinal": 2,
"name": "at_target_legacy!",
"type_info": "Bool"
},
{
"ordinal": 3,
"name": "poisoned!",
"type_info": "Bool"
},
{
"ordinal": 4,
"name": "legacy!",
"type_info": "Bool"
}
],
"parameters": {
"Left": [
"Text",
{
"Custom": {
"name": "draft_kind",
"kind": {
"Enum": [
"script",
"flow",
"app",
"raw_app",
"resource",
"variable",
"trigger_schedule",
"trigger_webhook",
"trigger_default_email",
"trigger_email",
"trigger_http",
"trigger_websocket",
"trigger_postgres",
"trigger_kafka",
"trigger_nats",
"trigger_mqtt",
"trigger_sqs",
"trigger_gcp",
"trigger_azure",
"trigger_poll",
"trigger_cli",
"trigger_nextcloud",
"trigger_google",
"trigger_github",
"data_pipeline",
"trigger_amqp"
]
}
}
},
"Text",
"Text",
"Text",
"TextArray"
]
},
"nullable": [
null,
null,
null,
null,
null
]
},
"hash": "a1ed94ef0a6d4f7bc89ac4a2341bc06dc95d60ec92469523935f5a18a4d3f907"
}
@@ -0,0 +1,15 @@
{
"db_name": "PostgreSQL",
"query": "UPDATE draft_move SET workspace_id = $1 WHERE workspace_id = $2",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Varchar",
"Text"
]
},
"nullable": []
},
"hash": "a725d5b99b65a1637406179b058a1ff36620099c6b87fd82a59eae1e35303c07"
}
@@ -1,15 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "INSERT INTO usr (workspace_id, username, email, is_admin, created_at, operator, disabled, role, is_service_account, added_via)\n SELECT $1, username, email, is_admin, created_at, operator, disabled, role, is_service_account, added_via\n FROM usr WHERE workspace_id = $2\n ON CONFLICT DO NOTHING",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Varchar",
"Text"
]
},
"nullable": []
},
"hash": "b98844926ff127c528ed3e7bc63bf1ebed0192be267983e8fbd18f79997e6142"
}
@@ -0,0 +1,47 @@
{
"db_name": "PostgreSQL",
"query": "SELECT a.id as app_id, av.id as version_id, dm.deployment_msg as deployment_msg,\n av.created_by as created_by, av.created_at as created_at\n FROM app a JOIN app_version av\n ON av.id = a.versions[array_upper(a.versions, 1)] AND av.app_id = a.id\n LEFT JOIN deployment_metadata dm ON av.id = dm.app_version\n WHERE a.workspace_id = $1 AND a.path = $2",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "app_id",
"type_info": "Int8"
},
{
"ordinal": 1,
"name": "version_id",
"type_info": "Int8"
},
{
"ordinal": 2,
"name": "deployment_msg",
"type_info": "Text"
},
{
"ordinal": 3,
"name": "created_by",
"type_info": "Varchar"
},
{
"ordinal": 4,
"name": "created_at",
"type_info": "Timestamptz"
}
],
"parameters": {
"Left": [
"Text",
"Text"
]
},
"nullable": [
false,
false,
true,
false,
false
]
},
"hash": "bd8ac169ad15346f28f561233fe340efd453512c1464a413af2393ab8bfea9ee"
}
@@ -0,0 +1,15 @@
{
"db_name": "PostgreSQL",
"query": "UPDATE draft_move SET email = $1 WHERE email = $2",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Varchar",
"Text"
]
},
"nullable": []
},
"hash": "e428f0cdb9077bffe2b7271d9b5f30ed81e8c26a9f1e65f423078947153ff9dd"
}
@@ -0,0 +1,16 @@
{
"db_name": "PostgreSQL",
"query": "INSERT INTO draft (workspace_id, path, typ, value, created_at, email, base)\n SELECT $2, path, typ,\n to_json(\n CASE WHEN typ IN ('script', 'flow') THEN clean - 'on_behalf_of' ELSE clean END\n - CASE WHEN typ = 'flow' THEN 'version_id'\n WHEN typ IN ('app', 'raw_app') THEN 'parent_version'\n ELSE '' END\n ),\n created_at, email,\n CASE WHEN typ = 'script' THEN base END\n FROM (\n SELECT d.path, d.typ, d.created_at, d.email, d.base,\n replace(replace(replace(d.value::text, chr(92) || chr(92), chr(1)), chr(92) || 'u0000', ''), chr(1), chr(92) || chr(92))::jsonb AS clean\n FROM draft d\n WHERE d.workspace_id = $1 AND (d.email = $3 OR d.email IS NULL)\n ) s",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Text",
"Varchar",
"Text"
]
},
"nullable": []
},
"hash": "e6140682ebc65083f48714ab3299fdb239ef507e23003db03aff7d35b02c540b"
}
@@ -0,0 +1,59 @@
{
"db_name": "PostgreSQL",
"query": "SELECT m.new_path FROM draft_move m\n WHERE m.workspace_id = $1 AND m.typ = $2 AND m.old_path = $3\n AND (m.email IS NULL OR m.email = $4)\n AND NOT EXISTS (\n SELECT 1 FROM draft d\n WHERE d.workspace_id = $1 AND d.typ = $2 AND d.path = $3\n AND d.email IS NOT DISTINCT FROM $4\n )\n ORDER BY m.email IS NULL\n LIMIT 1",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "new_path",
"type_info": "Varchar"
}
],
"parameters": {
"Left": [
"Text",
{
"Custom": {
"name": "draft_kind",
"kind": {
"Enum": [
"script",
"flow",
"app",
"raw_app",
"resource",
"variable",
"trigger_schedule",
"trigger_webhook",
"trigger_default_email",
"trigger_email",
"trigger_http",
"trigger_websocket",
"trigger_postgres",
"trigger_kafka",
"trigger_nats",
"trigger_mqtt",
"trigger_sqs",
"trigger_gcp",
"trigger_azure",
"trigger_poll",
"trigger_cli",
"trigger_nextcloud",
"trigger_google",
"trigger_github",
"data_pipeline",
"trigger_amqp"
]
}
}
},
"Text",
"Text"
]
},
"nullable": [
false
]
},
"hash": "e63da92247045f2aaaa62b9d6ee17510fd05d0b7c5311b61e7257ccd07b16da2"
}
@@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
"query": "\n SELECT\n workspace_id,\n slack_team_id,\n slack_name,\n teams_team_id,\n teams_team_name,\n teams_team_guid,\n mute_critical_alerts,\n guest_access_enabled,\n deploy_ui,\n large_file_storage,\n datatable\n FROM\n workspace_settings\n WHERE\n workspace_id = $1\n ",
"query": "\n SELECT\n workspace_id,\n slack_team_id,\n slack_name,\n teams_team_id,\n teams_team_name,\n teams_team_guid,\n mute_critical_alerts,\n guest_access_enabled,\n add_admins_and_developers_to_forks,\n deploy_ui,\n large_file_storage,\n datatable\n FROM\n workspace_settings\n WHERE\n workspace_id = $1\n ",
"describe": {
"columns": [
{
@@ -45,16 +45,21 @@
},
{
"ordinal": 8,
"name": "add_admins_and_developers_to_forks",
"type_info": "Bool"
},
{
"ordinal": 9,
"name": "deploy_ui",
"type_info": "Jsonb"
},
{
"ordinal": 9,
"ordinal": 10,
"name": "large_file_storage",
"type_info": "Jsonb"
},
{
"ordinal": 10,
"ordinal": 11,
"name": "datatable",
"type_info": "Jsonb"
}
@@ -73,10 +78,11 @@
true,
true,
false,
false,
true,
true,
true
]
},
"hash": "ede15bff96152f209aff756830cbc76b5afa1af6ed324376989117b1054c3447"
"hash": "e6e31fdf705896c81f9a0f45d47c1b93db0406aaf278bccba07be00e9f937e2a"
}
@@ -0,0 +1,15 @@
{
"db_name": "PostgreSQL",
"query": "INSERT INTO workspace_settings (workspace_id, slack_team_id, slack_name, slack_command_script, slack_email, customer_id, plan, webhook, ai_config, large_file_storage, git_sync, default_app, default_scripts, deploy_ui, mute_critical_alerts, color, operator_settings, teams_command_script, teams_team_id, teams_team_name, git_app_installations, git_credentials, ducklake, dbt_warehouses, slack_oauth_client_id, slack_oauth_client_secret, datatable, teams_team_guid, auto_invite, error_handler, success_handler, public_app_execution_limit_per_minute, error_handler_fallback_to_instance_alerts, guest_access_enabled, guest_jwt_public_key, guest_jwt_jwks_url, add_admins_and_developers_to_forks) SELECT $1, slack_team_id, slack_name, slack_command_script, slack_email, customer_id, plan, webhook, ai_config, large_file_storage, git_sync, default_app, default_scripts, deploy_ui, mute_critical_alerts, color, operator_settings, teams_command_script, teams_team_id, teams_team_name, git_app_installations, git_credentials, ducklake, dbt_warehouses, slack_oauth_client_id, slack_oauth_client_secret, datatable, teams_team_guid, auto_invite, error_handler, success_handler, public_app_execution_limit_per_minute, error_handler_fallback_to_instance_alerts, guest_access_enabled, guest_jwt_public_key, guest_jwt_jwks_url, add_admins_and_developers_to_forks FROM workspace_settings WHERE workspace_id = $2",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Varchar",
"Text"
]
},
"nullable": []
},
"hash": "eefa0588a6a927fd9b3f65e1df652fb2b4cf7983049d2c490940df360c7e2b30"
}
@@ -0,0 +1,41 @@
{
"db_name": "PostgreSQL",
"query": "SELECT flow_version.id, flow_version.created_at, flow_version.created_by, deployment_metadata.deployment_msg FROM flow_version \n LEFT JOIN deployment_metadata ON flow_version.id = deployment_metadata.flow_version\n WHERE flow_version.path = $1 AND flow_version.workspace_id = $2 \n ORDER BY flow_version.created_at DESC",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "id",
"type_info": "Int8"
},
{
"ordinal": 1,
"name": "created_at",
"type_info": "Timestamptz"
},
{
"ordinal": 2,
"name": "created_by",
"type_info": "Varchar"
},
{
"ordinal": 3,
"name": "deployment_msg",
"type_info": "Text"
}
],
"parameters": {
"Left": [
"Text",
"Text"
]
},
"nullable": [
false,
false,
false,
true
]
},
"hash": "ef98b0fab16d3b19997fd639b74a895cce1e8ae010307431e6787d20c7b25780"
}
@@ -0,0 +1,31 @@
{
"db_name": "PostgreSQL",
"query": "SELECT d.email IS NULL as \"legacy!\", COALESCE(u.username, p.username) as username\n FROM draft d\n LEFT JOIN usr u ON u.workspace_id = d.workspace_id AND u.email = d.email\n LEFT JOIN password p ON p.email = d.email AND p.super_admin = true\n WHERE d.workspace_id = $1 AND d.path = $2 AND d.typ::text = ANY($3::text[])\n -- A row a move already brought here from the very path being renamed is this\n -- rename's own draft, not an item in its way: the record says which owners\n -- it moved (all of them for an item move, one for a draft-only move). Any\n -- other row is a second item and still collides.\n AND NOT EXISTS (\n SELECT 1 FROM draft_move m\n WHERE m.workspace_id = $1 AND m.typ::text = ANY($3::text[])\n AND m.old_path = $4 AND m.new_path = $2\n AND (m.email IS NULL OR m.email IS NOT DISTINCT FROM d.email)\n )\n ORDER BY 2",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "legacy!",
"type_info": "Bool"
},
{
"ordinal": 1,
"name": "username",
"type_info": "Varchar"
}
],
"parameters": {
"Left": [
"Text",
"Text",
"TextArray",
"Text"
]
},
"nullable": [
null,
null
]
},
"hash": "fc2081d9077c743bc3978ae1905db558549b2356fc6ac8f0557aa40e1345ff46"
}
+85 -85
View File
@@ -2311,9 +2311,9 @@ dependencies = [
[[package]]
name = "cc"
version = "1.4.6"
version = "1.4.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a3eb0f42d6c360dc3f8a821f6bf2fdea7f72bfd36b3076eb0e6d1e9e0752fff4"
checksum = "54413ede23c2daf518f35156dfde027feb2374004d63bd497f983c8db9c0e313"
dependencies = [
"find-msvc-tools",
"jobserver",
@@ -5055,9 +5055,9 @@ dependencies = [
[[package]]
name = "find-msvc-tools"
version = "0.1.12"
version = "0.1.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3e0f1c7c3a72c66fd80abe965175f7523475c0489a87d3ff9d6e8c87d87a9d2d"
checksum = "ef25905e51abafe4dcea6c15fec58c57b601cdbd0ee53d22ea1d3016c587d39b"
[[package]]
name = "fixedbitset"
@@ -5497,9 +5497,9 @@ dependencies = [
[[package]]
name = "generator"
version = "0.8.9"
version = "0.8.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b3b854b0e584ead1a33f18b2fcad7cf7be18b3875c78816b753639aa501513ae"
checksum = "54ade96dc9003043bce7c035c85a9df5a858bfb2039c5a2e6fdf00f324f6c551"
dependencies = [
"cc",
"cfg-if",
@@ -14793,7 +14793,7 @@ dependencies = [
[[package]]
name = "windmill"
version = "1.814.0"
version = "1.815.0"
dependencies = [
"anyhow",
"async-nats",
@@ -14881,7 +14881,7 @@ dependencies = [
[[package]]
name = "windmill-ai"
version = "1.814.0"
version = "1.815.0"
dependencies = [
"async-stream",
"async-trait",
@@ -14915,7 +14915,7 @@ dependencies = [
[[package]]
name = "windmill-alerting"
version = "1.814.0"
version = "1.815.0"
dependencies = [
"axum 0.8.9",
"chrono",
@@ -14928,7 +14928,7 @@ dependencies = [
[[package]]
name = "windmill-api"
version = "1.814.0"
version = "1.815.0"
dependencies = [
"anyhow",
"argon2",
@@ -15068,7 +15068,7 @@ dependencies = [
[[package]]
name = "windmill-api-agent-workers"
version = "1.814.0"
version = "1.815.0"
dependencies = [
"axum 0.8.9",
"chrono",
@@ -15091,7 +15091,7 @@ dependencies = [
[[package]]
name = "windmill-api-assets"
version = "1.814.0"
version = "1.815.0"
dependencies = [
"axum 0.8.9",
"chrono",
@@ -15108,7 +15108,7 @@ dependencies = [
[[package]]
name = "windmill-api-auth"
version = "1.814.0"
version = "1.815.0"
dependencies = [
"anyhow",
"axum 0.8.9",
@@ -15134,7 +15134,7 @@ dependencies = [
[[package]]
name = "windmill-api-client"
version = "1.814.0"
version = "1.815.0"
dependencies = [
"reqwest 0.12.28",
"serde",
@@ -15144,7 +15144,7 @@ dependencies = [
[[package]]
name = "windmill-api-configs"
version = "1.814.0"
version = "1.815.0"
dependencies = [
"axum 0.8.9",
"chrono",
@@ -15161,7 +15161,7 @@ dependencies = [
[[package]]
name = "windmill-api-debug"
version = "1.814.0"
version = "1.815.0"
dependencies = [
"axum 0.8.9",
"base64 0.22.1",
@@ -15183,7 +15183,7 @@ dependencies = [
[[package]]
name = "windmill-api-embeddings"
version = "1.814.0"
version = "1.815.0"
dependencies = [
"anyhow",
"axum 0.8.9",
@@ -15206,7 +15206,7 @@ dependencies = [
[[package]]
name = "windmill-api-flow-conversations"
version = "1.814.0"
version = "1.815.0"
dependencies = [
"axum 0.8.9",
"chrono",
@@ -15222,7 +15222,7 @@ dependencies = [
[[package]]
name = "windmill-api-flows"
version = "1.814.0"
version = "1.815.0"
dependencies = [
"axum 0.8.9",
"chrono",
@@ -15244,7 +15244,7 @@ dependencies = [
[[package]]
name = "windmill-api-groups"
version = "1.814.0"
version = "1.815.0"
dependencies = [
"axum 0.8.9",
"chrono",
@@ -15266,7 +15266,7 @@ dependencies = [
[[package]]
name = "windmill-api-inputs"
version = "1.814.0"
version = "1.815.0"
dependencies = [
"axum 0.8.9",
"chrono",
@@ -15280,7 +15280,7 @@ dependencies = [
[[package]]
name = "windmill-api-integration-tests"
version = "1.814.0"
version = "1.815.0"
dependencies = [
"anyhow",
"async-nats",
@@ -15315,7 +15315,7 @@ dependencies = [
[[package]]
name = "windmill-api-jobs"
version = "1.814.0"
version = "1.815.0"
dependencies = [
"anyhow",
"axum 0.8.9",
@@ -15340,7 +15340,7 @@ dependencies = [
[[package]]
name = "windmill-api-npm-proxy"
version = "1.814.0"
version = "1.815.0"
dependencies = [
"axum 0.8.9",
"chrono",
@@ -15368,7 +15368,7 @@ dependencies = [
[[package]]
name = "windmill-api-openapi"
version = "1.814.0"
version = "1.815.0"
dependencies = [
"anyhow",
"axum 0.8.9",
@@ -15390,7 +15390,7 @@ dependencies = [
[[package]]
name = "windmill-api-schedule"
version = "1.814.0"
version = "1.815.0"
dependencies = [
"axum 0.8.9",
"chrono",
@@ -15410,7 +15410,7 @@ dependencies = [
[[package]]
name = "windmill-api-scripts"
version = "1.814.0"
version = "1.815.0"
dependencies = [
"axum 0.8.9",
"chrono",
@@ -15448,7 +15448,7 @@ dependencies = [
[[package]]
name = "windmill-api-settings"
version = "1.814.0"
version = "1.815.0"
dependencies = [
"anyhow",
"axum 0.8.9",
@@ -15477,7 +15477,7 @@ dependencies = [
[[package]]
name = "windmill-api-sse"
version = "1.814.0"
version = "1.815.0"
dependencies = [
"lazy_static",
"serde",
@@ -15489,7 +15489,7 @@ dependencies = [
[[package]]
name = "windmill-api-users"
version = "1.814.0"
version = "1.815.0"
dependencies = [
"argon2",
"axum 0.8.9",
@@ -15513,7 +15513,7 @@ dependencies = [
[[package]]
name = "windmill-api-workers"
version = "1.814.0"
version = "1.815.0"
dependencies = [
"axum 0.8.9",
"chrono",
@@ -15527,7 +15527,7 @@ dependencies = [
[[package]]
name = "windmill-api-workspaces"
version = "1.814.0"
version = "1.815.0"
dependencies = [
"axum 0.8.9",
"chrono",
@@ -15562,7 +15562,7 @@ dependencies = [
[[package]]
name = "windmill-audit"
version = "1.814.0"
version = "1.815.0"
dependencies = [
"chrono",
"lazy_static",
@@ -15576,7 +15576,7 @@ dependencies = [
[[package]]
name = "windmill-autoscaling"
version = "1.814.0"
version = "1.815.0"
dependencies = [
"anyhow",
"axum 0.8.9",
@@ -15595,7 +15595,7 @@ dependencies = [
[[package]]
name = "windmill-common"
version = "1.814.0"
version = "1.815.0"
dependencies = [
"aes-gcm",
"aho-corasick",
@@ -15702,7 +15702,7 @@ dependencies = [
[[package]]
name = "windmill-dep-map"
version = "1.814.0"
version = "1.815.0"
dependencies = [
"chrono",
"futures",
@@ -15722,7 +15722,7 @@ dependencies = [
[[package]]
name = "windmill-git-sync"
version = "1.814.0"
version = "1.815.0"
dependencies = [
"regex",
"serde",
@@ -15739,7 +15739,7 @@ dependencies = [
[[package]]
name = "windmill-indexer"
version = "1.814.0"
version = "1.815.0"
dependencies = [
"anyhow",
"astral-tokio-tar",
@@ -15766,7 +15766,7 @@ dependencies = [
[[package]]
name = "windmill-jseval"
version = "1.814.0"
version = "1.815.0"
dependencies = [
"anyhow",
"futures",
@@ -15783,7 +15783,7 @@ dependencies = [
[[package]]
name = "windmill-macros"
version = "1.814.0"
version = "1.815.0"
dependencies = [
"itertools 0.14.0",
"lazy_static",
@@ -15799,7 +15799,7 @@ dependencies = [
[[package]]
name = "windmill-mcp"
version = "1.814.0"
version = "1.815.0"
dependencies = [
"anyhow",
"async-trait",
@@ -15820,7 +15820,7 @@ dependencies = [
[[package]]
name = "windmill-native-triggers"
version = "1.814.0"
version = "1.815.0"
dependencies = [
"anyhow",
"async-trait",
@@ -15851,7 +15851,7 @@ dependencies = [
[[package]]
name = "windmill-oauth"
version = "1.814.0"
version = "1.815.0"
dependencies = [
"anyhow",
"arc-swap",
@@ -15876,7 +15876,7 @@ dependencies = [
[[package]]
name = "windmill-object-store"
version = "1.814.0"
version = "1.815.0"
dependencies = [
"anyhow",
"async-stream",
@@ -15911,7 +15911,7 @@ dependencies = [
[[package]]
name = "windmill-operator"
version = "1.814.0"
version = "1.815.0"
dependencies = [
"anyhow",
"futures",
@@ -15929,7 +15929,7 @@ dependencies = [
[[package]]
name = "windmill-parser"
version = "1.814.0"
version = "1.815.0"
dependencies = [
"convert_case 0.6.0",
"serde",
@@ -15938,7 +15938,7 @@ dependencies = [
[[package]]
name = "windmill-parser-bash"
version = "1.814.0"
version = "1.815.0"
dependencies = [
"anyhow",
"lazy_static",
@@ -15950,7 +15950,7 @@ dependencies = [
[[package]]
name = "windmill-parser-csharp"
version = "1.814.0"
version = "1.815.0"
dependencies = [
"anyhow",
"serde_json",
@@ -15962,7 +15962,7 @@ dependencies = [
[[package]]
name = "windmill-parser-go"
version = "1.814.0"
version = "1.815.0"
dependencies = [
"anyhow",
"gosyn",
@@ -15974,7 +15974,7 @@ dependencies = [
[[package]]
name = "windmill-parser-graphql"
version = "1.814.0"
version = "1.815.0"
dependencies = [
"anyhow",
"lazy_static",
@@ -15986,7 +15986,7 @@ dependencies = [
[[package]]
name = "windmill-parser-java"
version = "1.814.0"
version = "1.815.0"
dependencies = [
"anyhow",
"serde_json",
@@ -15998,7 +15998,7 @@ dependencies = [
[[package]]
name = "windmill-parser-nu"
version = "1.814.0"
version = "1.815.0"
dependencies = [
"anyhow",
"nu-parser",
@@ -16009,7 +16009,7 @@ dependencies = [
[[package]]
name = "windmill-parser-php"
version = "1.814.0"
version = "1.815.0"
dependencies = [
"anyhow",
"itertools 0.14.0",
@@ -16020,7 +16020,7 @@ dependencies = [
[[package]]
name = "windmill-parser-py"
version = "1.814.0"
version = "1.815.0"
dependencies = [
"anyhow",
"itertools 0.14.0",
@@ -16032,7 +16032,7 @@ dependencies = [
[[package]]
name = "windmill-parser-py-asset"
version = "1.814.0"
version = "1.815.0"
dependencies = [
"anyhow",
"rustpython-ast",
@@ -16043,7 +16043,7 @@ dependencies = [
[[package]]
name = "windmill-parser-py-imports"
version = "1.814.0"
version = "1.815.0"
dependencies = [
"anyhow",
"async-recursion",
@@ -16065,7 +16065,7 @@ dependencies = [
[[package]]
name = "windmill-parser-r"
version = "1.814.0"
version = "1.815.0"
dependencies = [
"anyhow",
"serde_json",
@@ -16077,7 +16077,7 @@ dependencies = [
[[package]]
name = "windmill-parser-ruby"
version = "1.814.0"
version = "1.815.0"
dependencies = [
"anyhow",
"lazy_static",
@@ -16091,7 +16091,7 @@ dependencies = [
[[package]]
name = "windmill-parser-rust"
version = "1.814.0"
version = "1.815.0"
dependencies = [
"anyhow",
"convert_case 0.6.0",
@@ -16108,7 +16108,7 @@ dependencies = [
[[package]]
name = "windmill-parser-sql"
version = "1.814.0"
version = "1.815.0"
dependencies = [
"anyhow",
"lazy_static",
@@ -16121,7 +16121,7 @@ dependencies = [
[[package]]
name = "windmill-parser-sql-asset"
version = "1.814.0"
version = "1.815.0"
dependencies = [
"anyhow",
"serde",
@@ -16133,7 +16133,7 @@ dependencies = [
[[package]]
name = "windmill-parser-ts"
version = "1.814.0"
version = "1.815.0"
dependencies = [
"anyhow",
"lazy_static",
@@ -16151,7 +16151,7 @@ dependencies = [
[[package]]
name = "windmill-parser-ts-asset"
version = "1.814.0"
version = "1.815.0"
dependencies = [
"anyhow",
"serde-wasm-bindgen",
@@ -16167,7 +16167,7 @@ dependencies = [
[[package]]
name = "windmill-parser-wac"
version = "1.814.0"
version = "1.815.0"
dependencies = [
"anyhow",
"rustpython-ast",
@@ -16183,7 +16183,7 @@ dependencies = [
[[package]]
name = "windmill-parser-yaml"
version = "1.814.0"
version = "1.815.0"
dependencies = [
"anyhow",
"lazy_static",
@@ -16197,7 +16197,7 @@ dependencies = [
[[package]]
name = "windmill-queue"
version = "1.814.0"
version = "1.815.0"
dependencies = [
"anyhow",
"async-recursion",
@@ -16236,7 +16236,7 @@ dependencies = [
[[package]]
name = "windmill-runtime-nativets"
version = "1.814.0"
version = "1.815.0"
dependencies = [
"anyhow",
"const_format",
@@ -16276,7 +16276,7 @@ dependencies = [
[[package]]
name = "windmill-sql-datatype-parser-wasm"
version = "1.814.0"
version = "1.815.0"
dependencies = [
"getrandom 0.3.4",
"wasm-bindgen",
@@ -16287,7 +16287,7 @@ dependencies = [
[[package]]
name = "windmill-store"
version = "1.814.0"
version = "1.815.0"
dependencies = [
"anyhow",
"async-recursion",
@@ -16322,7 +16322,7 @@ dependencies = [
[[package]]
name = "windmill-test-utils"
version = "1.814.0"
version = "1.815.0"
dependencies = [
"anyhow",
"async-trait",
@@ -16346,7 +16346,7 @@ dependencies = [
[[package]]
name = "windmill-trigger"
version = "1.814.0"
version = "1.815.0"
dependencies = [
"anyhow",
"async-trait",
@@ -16379,7 +16379,7 @@ dependencies = [
[[package]]
name = "windmill-trigger-amqp"
version = "1.814.0"
version = "1.815.0"
dependencies = [
"anyhow",
"async-trait",
@@ -16406,7 +16406,7 @@ dependencies = [
[[package]]
name = "windmill-trigger-azure"
version = "1.814.0"
version = "1.815.0"
dependencies = [
"anyhow",
"async-trait",
@@ -16439,7 +16439,7 @@ dependencies = [
[[package]]
name = "windmill-trigger-email"
version = "1.814.0"
version = "1.815.0"
dependencies = [
"anyhow",
"async-trait",
@@ -16459,7 +16459,7 @@ dependencies = [
[[package]]
name = "windmill-trigger-gcp"
version = "1.814.0"
version = "1.815.0"
dependencies = [
"anyhow",
"async-trait",
@@ -16493,7 +16493,7 @@ dependencies = [
[[package]]
name = "windmill-trigger-http"
version = "1.814.0"
version = "1.815.0"
dependencies = [
"anyhow",
"async-trait",
@@ -16529,7 +16529,7 @@ dependencies = [
[[package]]
name = "windmill-trigger-kafka"
version = "1.814.0"
version = "1.815.0"
dependencies = [
"anyhow",
"async-trait",
@@ -16552,7 +16552,7 @@ dependencies = [
[[package]]
name = "windmill-trigger-mqtt"
version = "1.814.0"
version = "1.815.0"
dependencies = [
"anyhow",
"async-trait",
@@ -16576,7 +16576,7 @@ dependencies = [
[[package]]
name = "windmill-trigger-nats"
version = "1.814.0"
version = "1.815.0"
dependencies = [
"anyhow",
"async-nats",
@@ -16600,7 +16600,7 @@ dependencies = [
[[package]]
name = "windmill-trigger-postgres"
version = "1.814.0"
version = "1.815.0"
dependencies = [
"anyhow",
"async-trait",
@@ -16635,7 +16635,7 @@ dependencies = [
[[package]]
name = "windmill-trigger-sqs"
version = "1.814.0"
version = "1.815.0"
dependencies = [
"anyhow",
"async-trait",
@@ -16663,7 +16663,7 @@ dependencies = [
[[package]]
name = "windmill-trigger-websocket"
version = "1.814.0"
version = "1.815.0"
dependencies = [
"anyhow",
"async-trait",
@@ -16688,7 +16688,7 @@ dependencies = [
[[package]]
name = "windmill-types"
version = "1.814.0"
version = "1.815.0"
dependencies = [
"anyhow",
"bitflags 2.13.2",
@@ -16707,7 +16707,7 @@ dependencies = [
[[package]]
name = "windmill-worker"
version = "1.814.0"
version = "1.815.0"
dependencies = [
"anyhow",
"async-once-cell",
@@ -16825,7 +16825,7 @@ dependencies = [
[[package]]
name = "windmill-worker-volumes"
version = "1.814.0"
version = "1.815.0"
dependencies = [
"bytes",
"futures",
+2 -2
View File
@@ -1,6 +1,6 @@
[package]
name = "windmill"
version = "1.814.0"
version = "1.815.0"
authors.workspace = true
edition.workspace = true
@@ -88,7 +88,7 @@ members = [
exclude = ["./windmill-duckdb-ffi-internal", "./parsers/windmill-parser-wasm"]
[workspace.package]
version = "1.814.0"
version = "1.815.0"
authors = ["Ruben Fiszel <ruben@windmill.dev>"]
edition = "2021"
+1 -1
View File
@@ -1 +1 @@
7e338e4dabf91689bfd7fb0333c6534040b17b59
bc3ef08c8e4233508c023e6ee847a3cd0b8be43b
@@ -0,0 +1 @@
ALTER TABLE draft DROP COLUMN base;
@@ -0,0 +1,14 @@
-- The version a draft forked from, as one opaque text id whatever the kind: a
-- script hash (hex), a flow_version.id, an app_version.id. NULL for a draft that
-- was never forked from a deploy and for kinds that keep no lineage.
ALTER TABLE draft ADD COLUMN base TEXT;
-- A U+0000 inside a `json` value makes `->>` raise 22P05; such rows keep NULL and
-- get their base on their next save.
UPDATE draft SET base = CASE typ::text
WHEN 'script' THEN value ->> 'parent_hash'
WHEN 'flow' THEN value ->> 'version_id'
ELSE value ->> 'parent_version'
END
WHERE typ::text IN ('script', 'flow', 'app', 'raw_app')
AND position(chr(92) || 'u0000' in replace(value::text, chr(92) || chr(92), '')) = 0;
@@ -0,0 +1 @@
DROP TABLE draft_move;
@@ -0,0 +1,17 @@
-- Where an item's drafts went when it moved, so a draft save still addressed to the
-- old path (an editor left open across the move, a chat, the CLI) lands on the moved
-- draft instead of starting a new item there. `email` NULL records a deployed item's
-- move and applies to every user; set, it records that user's draft-only move.
CREATE TABLE draft_move (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
workspace_id VARCHAR(50) NOT NULL REFERENCES workspace(id) ON DELETE CASCADE,
typ DRAFT_KIND NOT NULL,
old_path VARCHAR(255) NOT NULL,
new_path VARCHAR(255) NOT NULL,
email VARCHAR(255)
);
CREATE INDEX draft_move_old_path_idx ON draft_move (workspace_id, typ, old_path);
GRANT ALL ON draft_move TO windmill_user;
GRANT ALL ON draft_move TO windmill_admin;
@@ -0,0 +1 @@
ALTER TABLE workspace_settings DROP COLUMN add_admins_and_developers_to_forks;
@@ -0,0 +1 @@
ALTER TABLE workspace_settings ADD COLUMN add_admins_and_developers_to_forks BOOLEAN NOT NULL DEFAULT false;
+24 -24
View File
@@ -6191,7 +6191,7 @@ checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
[[package]]
name = "windmill-common"
version = "1.814.0"
version = "1.815.0"
dependencies = [
"aho-corasick",
"anyhow",
@@ -6274,7 +6274,7 @@ dependencies = [
[[package]]
name = "windmill-macros"
version = "1.814.0"
version = "1.815.0"
dependencies = [
"proc-macro2",
"quote",
@@ -6286,7 +6286,7 @@ dependencies = [
[[package]]
name = "windmill-parser"
version = "1.814.0"
version = "1.815.0"
dependencies = [
"convert_case",
"serde",
@@ -6295,7 +6295,7 @@ dependencies = [
[[package]]
name = "windmill-parser-bash"
version = "1.814.0"
version = "1.815.0"
dependencies = [
"anyhow",
"lazy_static",
@@ -6307,7 +6307,7 @@ dependencies = [
[[package]]
name = "windmill-parser-csharp"
version = "1.814.0"
version = "1.815.0"
dependencies = [
"anyhow",
"serde_json",
@@ -6319,7 +6319,7 @@ dependencies = [
[[package]]
name = "windmill-parser-go"
version = "1.814.0"
version = "1.815.0"
dependencies = [
"anyhow",
"gosyn",
@@ -6331,7 +6331,7 @@ dependencies = [
[[package]]
name = "windmill-parser-graphql"
version = "1.814.0"
version = "1.815.0"
dependencies = [
"anyhow",
"lazy_static",
@@ -6343,7 +6343,7 @@ dependencies = [
[[package]]
name = "windmill-parser-java"
version = "1.814.0"
version = "1.815.0"
dependencies = [
"anyhow",
"serde_json",
@@ -6355,7 +6355,7 @@ dependencies = [
[[package]]
name = "windmill-parser-nu"
version = "1.814.0"
version = "1.815.0"
dependencies = [
"anyhow",
"nu-parser",
@@ -6366,7 +6366,7 @@ dependencies = [
[[package]]
name = "windmill-parser-php"
version = "1.814.0"
version = "1.815.0"
dependencies = [
"anyhow",
"itertools 0.14.0",
@@ -6377,7 +6377,7 @@ dependencies = [
[[package]]
name = "windmill-parser-py"
version = "1.814.0"
version = "1.815.0"
dependencies = [
"anyhow",
"itertools 0.14.0",
@@ -6389,7 +6389,7 @@ dependencies = [
[[package]]
name = "windmill-parser-py-asset"
version = "1.814.0"
version = "1.815.0"
dependencies = [
"anyhow",
"rustpython-ast",
@@ -6400,7 +6400,7 @@ dependencies = [
[[package]]
name = "windmill-parser-py-imports"
version = "1.814.0"
version = "1.815.0"
dependencies = [
"anyhow",
"async-recursion",
@@ -6422,7 +6422,7 @@ dependencies = [
[[package]]
name = "windmill-parser-r"
version = "1.814.0"
version = "1.815.0"
dependencies = [
"anyhow",
"serde_json",
@@ -6434,7 +6434,7 @@ dependencies = [
[[package]]
name = "windmill-parser-ruby"
version = "1.814.0"
version = "1.815.0"
dependencies = [
"anyhow",
"lazy_static",
@@ -6448,7 +6448,7 @@ dependencies = [
[[package]]
name = "windmill-parser-rust"
version = "1.814.0"
version = "1.815.0"
dependencies = [
"anyhow",
"convert_case",
@@ -6465,7 +6465,7 @@ dependencies = [
[[package]]
name = "windmill-parser-sql"
version = "1.814.0"
version = "1.815.0"
dependencies = [
"anyhow",
"lazy_static",
@@ -6478,7 +6478,7 @@ dependencies = [
[[package]]
name = "windmill-parser-sql-asset"
version = "1.814.0"
version = "1.815.0"
dependencies = [
"anyhow",
"serde",
@@ -6490,7 +6490,7 @@ dependencies = [
[[package]]
name = "windmill-parser-ts"
version = "1.814.0"
version = "1.815.0"
dependencies = [
"anyhow",
"lazy_static",
@@ -6508,7 +6508,7 @@ dependencies = [
[[package]]
name = "windmill-parser-ts-asset"
version = "1.814.0"
version = "1.815.0"
dependencies = [
"anyhow",
"serde-wasm-bindgen",
@@ -6524,7 +6524,7 @@ dependencies = [
[[package]]
name = "windmill-parser-wac"
version = "1.814.0"
version = "1.815.0"
dependencies = [
"anyhow",
"rustpython-ast",
@@ -6540,7 +6540,7 @@ dependencies = [
[[package]]
name = "windmill-parser-wasm"
version = "1.814.0"
version = "1.815.0"
dependencies = [
"anyhow",
"getrandom 0.2.17",
@@ -6572,7 +6572,7 @@ dependencies = [
[[package]]
name = "windmill-parser-yaml"
version = "1.814.0"
version = "1.815.0"
dependencies = [
"anyhow",
"lazy_static",
@@ -6586,7 +6586,7 @@ dependencies = [
[[package]]
name = "windmill-types"
version = "1.814.0"
version = "1.815.0"
dependencies = [
"anyhow",
"bitflags",
@@ -12,7 +12,7 @@ resolver = "2"
members = ["."]
[workspace.package]
version = "1.814.0"
version = "1.815.0"
edition = "2021"
authors = ["Ruben Fiszel <ruben@windmill.dev>"]
+7 -2
View File
@@ -52,7 +52,8 @@ use windmill_common::{
INSTANCE_EVENTS_WEBHOOK_SETTING, INSTANCE_PYTHON_VERSION_SETTING,
JOB_DEFAULT_TIMEOUT_SECS_SETTING, JOB_ISOLATION_SETTING, JWT_SECRET_SETTING,
KEEP_JOB_DIR_SETTING, LICENSE_KEY_SETTING, MAVEN_REPOS_SETTING, MAVEN_SETTINGS_XML_SETTING,
MONITOR_LOGS_ON_OBJECT_STORE_SETTING, NO_DEFAULT_MAVEN_SETTING,
MCP_DISABLE_TOKEN_QUERY_PARAM_SETTING, MONITOR_LOGS_ON_OBJECT_STORE_SETTING,
NO_DEFAULT_MAVEN_SETTING,
NPM_CONFIG_REGISTRY_SETTING, NSJAIL_TMPFS_SIZE_MB_SETTING, NSJAIL_TMP_BACKING_SETTING,
NUGET_CONFIG_SETTING, OAUTH_SETTING, OTEL_SETTING, OTEL_TRACES_RETENTION_SECS_SETTING,
OTEL_TRACING_PROXY_SETTING, PIP_INDEX_URL_SETTING, POWERSHELL_REPO_PAT_SETTING,
@@ -126,7 +127,8 @@ use windmill_worker::{
use crate::monitor::{
initial_load, load_concurrency_key_max_queued, load_disable_password_login,
load_fork_workspace_tag_append_fork_suffix, load_keep_job_dir, load_metrics_debug_enabled,
load_fork_workspace_tag_append_fork_suffix, load_keep_job_dir,
load_mcp_disable_token_query_param, load_metrics_debug_enabled,
load_preview_tags_override, load_require_preexisting_user, load_retention_period_overrides,
load_tag_per_workspace_enabled, load_tag_per_workspace_workspaces,
load_workspace_fairness_duration_secs, load_workspace_fairness_enabled,
@@ -2164,6 +2166,9 @@ async fn process_notify_event(
DISABLE_PASSWORD_LOGIN_SETTING => {
load_disable_password_login(db).await;
}
MCP_DISABLE_TOKEN_QUERY_PARAM_SETTING => {
load_mcp_disable_token_query_param(db).await;
}
EXPOSE_METRICS_SETTING => {
tracing::info!("Metrics setting changed, restarting");
spawn_graceful_killpill(tx, db, 30, "metrics setting change", server_mode)
+34 -3
View File
@@ -45,7 +45,10 @@ use windmill_common::otel_oss::{
use windmill_common::{
agent_workers::DECODED_AGENT_TOKEN,
apps::APP_WORKSPACED_ROUTE,
auth::{create_token_for_owner, ephemeral_script_token_label, job_token_expiry_secs},
auth::{
create_token_for_owner, ephemeral_script_token_label, job_token_expiry_secs,
TOKEN_EXPIRY_WARNING_DAYS,
},
ee_oss::CriticalErrorChannel,
email_oss::send_email_if_possible,
error,
@@ -62,6 +65,7 @@ use windmill_common::{
FORK_WORKSPACE_TAG_APPEND_FORK_SUFFIX_SETTING, HUB_API_SECRET_SETTING,
HUB_BASE_URL_SETTING, INSTANCE_PYTHON_VERSION_SETTING, JOB_DEFAULT_TIMEOUT_SECS_SETTING,
JOB_ISOLATION_SETTING, JWT_SECRET_SETTING, KEEP_JOB_DIR_SETTING, LICENSE_KEY_SETTING,
MCP_DISABLE_TOKEN_QUERY_PARAM, MCP_DISABLE_TOKEN_QUERY_PARAM_SETTING,
MONITOR_LOGS_ON_OBJECT_STORE_SETTING, NPMRC_SETTING, NPM_CONFIG_REGISTRY_SETTING,
NSJAIL_TMPFS_SIZE_MB_SETTING, NSJAIL_TMP_BACKING_SETTING, NUGET_CONFIG_SETTING,
OTEL_SETTING, OTEL_TRACES_RETENTION_SECS_SETTING, OTEL_TRACING_PROXY_SETTING,
@@ -285,6 +289,15 @@ pub async fn initial_load(
);
if let Some(db) = conn.as_sql() {
// Outside the `server_mode` block below: a `MODE=mcp` process serves the MCP routes
// with `server_mode` false and would otherwise never read this at all. That mode
// joins no monitor loop, so there — as for every global setting, `base_url`
// included — this pass is the only read, and a change lands on restart.
pass.setting(
MCP_DISABLE_TOKEN_QUERY_PARAM_SETTING,
false,
|v| async move { apply_mcp_disable_token_query_param(v) },
);
pass.setting(DEFAULT_TAGS_PER_WORKSPACE_SETTING, false, |v| async move {
apply_tag_per_workspace_enabled(v)
});
@@ -1614,6 +1627,23 @@ pub fn apply_disable_password_login(value: Option<serde_json::Value>) {
};
}
pub async fn load_mcp_disable_token_query_param(db: &DB) {
match load_value_from_global_settings(db, MCP_DISABLE_TOKEN_QUERY_PARAM_SETTING).await {
Ok(v) => apply_mcp_disable_token_query_param(v),
Err(e) => tracing::error!("Error loading mcp_disable_token_query_param setting: {e:#}"),
};
}
pub fn apply_mcp_disable_token_query_param(value: Option<serde_json::Value>) {
match value {
Some(serde_json::Value::Bool(t)) => {
MCP_DISABLE_TOKEN_QUERY_PARAM.store(t, Ordering::Relaxed)
}
None => MCP_DISABLE_TOKEN_QUERY_PARAM.store(false, Ordering::Relaxed),
_ => (),
};
}
struct LogFile {
file_path: String,
hostname: String,
@@ -2198,7 +2228,7 @@ async fn cleanup_scheduled_job_deletions(db: &Pool<Postgres>) {
}
pub async fn check_expiring_tokens(db: &DB) {
// Find tokens expiring within 7 days that still have a pending notification row.
// Find tokens expiring within the warning window that still have a pending notification row.
// The notification table stores token_hash (not plaintext) so the join works
// even after the hash migration makes token.token nullable.
let expiring_tokens_r = sqlx::query_as!(
@@ -2207,8 +2237,9 @@ pub async fn check_expiring_tokens(db: &DB) {
USING token t
WHERE n.token_hash = t.token_hash
AND n.expiration > now()
AND n.expiration <= now() + interval '7 days'
AND n.expiration <= now() + make_interval(days => $1)
RETURNING t.token_prefix, t.label, t.email, t.workspace_id",
TOKEN_EXPIRY_WARNING_DAYS,
)
.fetch_all(db)
.await;
+1 -1
View File
@@ -234,7 +234,7 @@ workspace_protection_rule: workspace_id(char), name(char), rules(int), bypass_gr
FK: (workspace_id) -> workspace(id)
workspace_runnable_dependencies: flow_path(char), runnable_path(char), script_hash(bigint), runnable_is_flow(bool), workspace_id(char), app_path(char), id(bigint), runnable_is_agent(bool)
FK: (app_path, workspace_id) -> app(path, workspace_id) | (flow_path, workspace_id) -> flow(path, workspace_id)
workspace_settings: workspace_id(char), slack_team_id(char), slack_name(char), slack_command_script(char), slack_email(char), customer_id(char), plan(char), webhook(text), ai_config(jsonb), large_file_storage(jsonb), git_sync(jsonb), default_app(char), default_scripts(jsonb), deploy_ui(jsonb), mute_critical_alerts(bool), color(char), operator_settings(jsonb), teams_command_script(text), teams_team_id(text), teams_team_name(text), git_app_installations(jsonb), ducklake(jsonb), slack_oauth_client_id(char), slack_oauth_client_secret(char), datatable(jsonb), teams_team_guid(text), auto_invite(jsonb), error_handler(jsonb), success_handler(jsonb), public_app_execution_limit_per_minute(int), dbt_warehouses(jsonb), guest_access_enabled(bool), guest_jwt_public_key(text), guest_jwt_jwks_url(text), ai_sessions_backup_generation(int)
workspace_settings: workspace_id(char), slack_team_id(char), slack_name(char), slack_command_script(char), slack_email(char), customer_id(char), plan(char), webhook(text), ai_config(jsonb), large_file_storage(jsonb), git_sync(jsonb), default_app(char), default_scripts(jsonb), deploy_ui(jsonb), mute_critical_alerts(bool), color(char), operator_settings(jsonb), teams_command_script(text), teams_team_id(text), teams_team_name(text), git_app_installations(jsonb), ducklake(jsonb), slack_oauth_client_id(char), slack_oauth_client_secret(char), datatable(jsonb), teams_team_guid(text), auto_invite(jsonb), error_handler(jsonb), success_handler(jsonb), public_app_execution_limit_per_minute(int), dbt_warehouses(jsonb), guest_access_enabled(bool), guest_jwt_public_key(text), guest_jwt_jwks_url(text), ai_sessions_backup_generation(int), add_admins_and_developers_to_forks(bool)
FK: (workspace_id) -> workspace(id)
zombie_job_counter: job_id(uuid), counter(int)
FK: (job_id) -> v2_job(id)
+193
View File
@@ -0,0 +1,193 @@
//! The deployed order of an app's versions is the order they were appended to
//! `app.versions`, not the order of their `created_at`.
//!
//! `app_version.created_at` defaults to `now()`, which in Postgres is the
//! transaction's start time, while the append happens under the app row's lock.
//! Two deploys that overlap therefore land in one order and carry timestamps in
//! the other. The head the editor guards against, and the sequence the diff
//! picker numbers, both have to follow the array.
//!
//! Users from the `base` fixture: test-user (admin, token SECRET_TOKEN).
use serde_json::json;
use sqlx::{Pool, Postgres};
use windmill_test_utils::*;
const TOKEN: &str = "SECRET_TOKEN";
#[sqlx::test(fixtures("base"))]
async fn test_app_head_follows_the_append_order_not_the_timestamps(
db: Pool<Postgres>,
) -> anyhow::Result<()> {
initialize_tracing().await;
let server = ApiServer::start(db.clone()).await?;
let ws = format!(
"http://localhost:{}/api/w/test-workspace",
server.addr.port()
);
let client = reqwest::Client::new();
let res = client
.post(format!("{ws}/apps/create"))
.header("Authorization", format!("Bearer {TOKEN}"))
.json(&json!({
"path": "u/test-user/order_app",
"summary": "ordered",
"value": {},
"policy": { "execution_mode": "publisher", "triggerables": {} }
}))
.send()
.await?;
assert!(res.status().is_success(), "{}", res.text().await?);
let first: i64 = sqlx::query_scalar(
"SELECT versions[array_upper(versions, 1)] FROM app
WHERE workspace_id = 'test-workspace' AND path = 'u/test-user/order_app'",
)
.fetch_one(&db)
.await?;
// The overlapping deploy: appended after `first`, so it is the version that
// landed, but stamped before it, so a timestamp sort puts it underneath.
let second: i64 = sqlx::query_scalar(
"INSERT INTO app_version (app_id, value, created_by, created_at, raw_app)
SELECT app_id, value, 'racer', created_at - interval '1 hour', raw_app
FROM app_version WHERE id = $1
RETURNING id",
)
.bind(first)
.fetch_one(&db)
.await?;
sqlx::query(
"UPDATE app SET versions = array_append(versions, $1::bigint)
WHERE workspace_id = 'test-workspace' AND path = 'u/test-user/order_app'",
)
.bind(second)
.execute(&db)
.await?;
let head: serde_json::Value = client
.get(format!(
"{ws}/apps/get_latest_version/u/test-user/order_app"
))
.header("Authorization", format!("Bearer {TOKEN}"))
.send()
.await?
.json()
.await?;
assert_eq!(
head["version"], second,
"the head is the version appended last, not the newest timestamp: {head}"
);
let history: Vec<serde_json::Value> = client
.get(format!("{ws}/apps/history/p/u/test-user/order_app"))
.header("Authorization", format!("Bearer {TOKEN}"))
.send()
.await?
.json()
.await?;
let listed: Vec<i64> = history
.iter()
.map(|v| v["version"].as_i64().unwrap())
.collect();
assert_eq!(
listed,
vec![second, first],
"the history lists in deployed order, so it leads with the head"
);
// Enough versions that a page cannot hold them, so "asked for nothing" and "asked for
// a page" are visibly different answers.
let mut appended = vec![second, first];
for _ in 0..24 {
let extra: i64 = sqlx::query_scalar(
"INSERT INTO app_version (app_id, value, created_by, created_at, raw_app)
SELECT app_id, value, 'bulk', created_at, raw_app FROM app_version WHERE id = $1
RETURNING id",
)
.bind(first)
.fetch_one(&db)
.await?;
sqlx::query(
"UPDATE app SET versions = array_append(versions, $1::bigint)
WHERE workspace_id = 'test-workspace' AND path = 'u/test-user/order_app'",
)
.bind(extra)
.execute(&db)
.await?;
appended.insert(0, extra);
}
let versions_at = |query: &str| {
let url = format!("{ws}/apps/history/p/u/test-user/order_app{query}");
let client = client.clone();
async move {
let rows: Vec<serde_json::Value> = client
.get(url)
.header("Authorization", format!("Bearer {TOKEN}"))
.send()
.await?
.json()
.await?;
Ok::<_, anyhow::Error>(
rows.iter()
.map(|v| v["version"].as_i64().unwrap())
.collect::<Vec<_>>(),
)
}
};
// The deployment-history panel and the CLI read this endpoint without paging, so
// asking for no page has to keep answering with the whole history.
assert_eq!(
versions_at("").await?,
appended,
"an unpaginated request still answers whole"
);
assert_eq!(
versions_at("?per_page=10").await?,
appended[..10],
"a page holds what was asked for, newest first"
);
assert_eq!(
versions_at("?per_page=10&page=2").await?,
appended[10..20],
"the next page carries on where the first left off, skipping nothing"
);
// A page past the end runs off it rather than overflowing into one. (The clamp on an
// asked-for size is pinned where it lives, in `paginate_optional`'s own test.)
assert!(
versions_at("?per_page=10&page=99999999")
.await?
.is_empty(),
"a page past the end is empty"
);
// Naming only the page still asks for a page, rather than an offset into a listing
// sized to hold everything — which would answer nothing at all.
assert_eq!(
versions_at("?page=1").await?,
appended,
"a first page with no size named holds the history"
);
// A version that never entered the deployed sequence — one a restore or a fork copy
// could leave behind — still has to be reachable, after the ones that did.
let stray: i64 = sqlx::query_scalar(
"INSERT INTO app_version (app_id, value, created_by, created_at, raw_app)
SELECT app_id, value, 'stray', created_at, raw_app FROM app_version WHERE id = $1
RETURNING id",
)
.bind(first)
.fetch_one(&db)
.await?;
let mut with_stray = appended.clone();
with_stray.push(stray);
assert_eq!(
versions_at("").await?,
with_stray,
"a version outside app.versions is listed, and after the deployed sequence"
);
Ok(())
}
+2 -4
View File
@@ -521,10 +521,8 @@ def main():
.await
.unwrap();
assert_eq!(
res.text().await.unwrap(),
"app f/rel/root_app updated (npath: \"f/rel/root_app_renamed\")"
);
let deployed: serde_json::Value = res.json().await.unwrap();
assert_eq!(deployed["path"], "f/rel/root_app_renamed", "{deployed}");
let mut completed = listen_for_completed_jobs(&db).await;
in_test_worker(&db, completed.next(), port).await;
+74
View File
@@ -0,0 +1,74 @@
//! Moving a draft must carry both of its path keys.
//!
//! A draft value holds a typed path and a mirror the editors keep beside it while
//! it differs from the row's path (`path`/`draft_path`; which is which depends on
//! the kind). The loaders prefer the mirror, so a move that rewrote only the typed
//! key left the mirror naming the old location: reopening the item restored the
//! old path, and the next autosave wrote it back — undoing the move silently.
//!
//! This pins `move_draft`, including that a draft with no mirror never gains one.
//! A deploy's carry follows the keys only where they still name the old path
//! (`drafts_save_follows_move` pins that).
use serde_json::Value;
use sqlx::{Pool, Postgres};
use windmill_test_utils::*;
async fn move_to(port: u16, from: &str, to: &str) -> anyhow::Result<String> {
let resp = reqwest::Client::new()
.post(format!(
"http://localhost:{port}/api/w/test-workspace/drafts/move/script/{from}"
))
.header("Authorization", "Bearer SECRET_TOKEN")
.json(&serde_json::json!({ "new_path": to }))
.send()
.await?;
// Asserted here so a rejected move fails as itself, rather than as a JSON
// parse error in the read-back below.
let status = resp.status();
let body = resp.text().await?;
assert!(status.is_success(), "move {from} -> {to} failed: {body}");
Ok(body)
}
/// The stored draft value at `path`, read back through the API so this test needs
/// no `sqlx::query!` (which would want an offline cache entry of its own).
async fn value_at(port: u16, path: &str) -> anyhow::Result<Value> {
let body: Value = reqwest::Client::new()
.get(format!(
"http://localhost:{port}/api/w/test-workspace/drafts/get_own/script/{path}"
))
.header("Authorization", "Bearer SECRET_TOKEN")
.send()
.await?
.json()
.await?;
Ok(body
.get("value")
.cloned()
.unwrap_or_else(|| panic!("no draft at {path}: {body}")))
}
#[sqlx::test(fixtures("base", "drafts_move_mirror"))]
async fn test_move_carries_both_path_keys(db: Pool<Postgres>) -> anyhow::Result<()> {
initialize_tracing().await;
let server = ApiServer::start(db.clone()).await?;
let port = server.addr.port();
move_to(port, "u/test-user/draft_mirror", "u/test-user/renamed").await?;
move_to(port, "u/test-user/draft_plain", "u/test-user/plain2").await?;
// The mirror follows: left at `u/test-user/friendly` it would win at load and
// walk the item back there.
let moved = value_at(port, "u/test-user/renamed").await?;
assert_eq!(moved["path"], "u/test-user/renamed");
assert_eq!(moved["draft_path"], "u/test-user/renamed");
// A draft that never had a mirror must not be given one.
let plain = value_at(port, "u/test-user/plain2").await?;
assert_eq!(plain["path"], "u/test-user/plain2");
assert_eq!(plain.get("draft_path"), None, "mirror injected: {plain}");
Ok(())
}
+250
View File
@@ -0,0 +1,250 @@
//! A rename onto a path that already holds a draft is refused.
//!
//! Nothing deployed can sit at a rename's destination (the deploy conflicts on
//! that), but a draft can: a never-deployed item, or a draft left on an archived
//! script. Moving onto it would merge two items or strand a row, so the rename
//! itself fails, in its own transaction, and the source stays deployed. The
//! destination draft here is the deployer's own, which is the same collision.
use serde_json::{json, Value};
use sqlx::{Pool, Postgres};
use windmill_test_utils::*;
/// Hex form of script hash 7010, the way the API takes a parent hash.
const HEAD_HASH: &str = "0000000000001b62";
#[sqlx::test(fixtures("base", "drafts_move_taken"))]
async fn test_rename_onto_a_draft_is_refused(db: Pool<Postgres>) -> anyhow::Result<()> {
initialize_tracing().await;
let server = ApiServer::start(db.clone()).await?;
let port = server.addr.port();
let client = reqwest::Client::new();
let resp = client
.post(format!(
"http://localhost:{port}/api/w/test-workspace/scripts/create"
))
.header("Authorization", "Bearer SECRET_TOKEN")
.json(&json!({
"path": "u/test-user/mvtaken_b",
"parent_hash": HEAD_HASH,
"summary": "A",
"description": "",
"content": "export function main() { return 1 }",
"language": "deno",
"schema": {}
}))
.send()
.await?;
let status = resp.status();
let body = resp.text().await?;
assert_eq!(status, 400, "rename onto a draft was not refused: {body}");
assert!(
body.contains("already has a draft"),
"unexpected refusal: {body}"
);
// The whole deploy rolled back: the source is still the live head, and the
// draft at the destination is untouched.
let head: Value = client
.get(format!(
"http://localhost:{port}/api/w/test-workspace/scripts/get/p/u/test-user/mvtaken_a"
))
.header("Authorization", "Bearer SECRET_TOKEN")
.send()
.await?
.json()
.await?;
assert_eq!(head["hash"], HEAD_HASH, "source was replaced: {head}");
assert_eq!(head["archived"], false, "source was archived: {head}");
let draft: Value = client
.get(format!("http://localhost:{port}/api/w/test-workspace/drafts/get_own/script/u/test-user/mvtaken_b"))
.header("Authorization", "Bearer SECRET_TOKEN")
.send()
.await?
.json()
.await?;
assert_eq!(
draft["value"]["summary"], "B",
"destination draft changed: {draft}"
);
Ok(())
}
/// A legacy (ownerless) draft occupies its path too: a deploy there deletes it together
/// with the caller's own row, so a move that parks a second draft beside it would discard
/// edits the caller never saw. Only an admin can clear it, so the refusal says so.
#[sqlx::test(fixtures("base", "drafts_move_taken"))]
async fn test_draft_move_refuses_a_legacy_destination(db: Pool<Postgres>) -> anyhow::Result<()> {
initialize_tracing().await;
let server = ApiServer::start(db.clone()).await?;
let port = server.addr.port();
let resp = reqwest::Client::new()
.post(format!(
"http://localhost:{port}/api/w/test-workspace/drafts/move/script/u/test-user/mvtaken_b"
))
.header("Authorization", "Bearer SECRET_TOKEN")
.json(&json!({ "new_path": "u/test-user/mvtaken_legacy" }))
.send()
.await?;
let status = resp.status();
let body = resp.text().await?;
assert_eq!(status, 400, "move onto a legacy draft was allowed: {body}");
assert!(
body.contains("legacy workspace draft") && body.contains("workspace admin"),
"the refusal did not point at the one remedy: {body}"
);
// Both rows stayed where they were: the caller's own, and the legacy one the list
// synthesizes under the caller's name.
let list: Vec<Value> = reqwest::Client::new()
.get(format!(
"http://localhost:{port}/api/w/test-workspace/drafts/list"
))
.header("Authorization", "Bearer SECRET_TOKEN")
.send()
.await?
.json()
.await?;
let mut at = list
.iter()
.filter_map(|d| Some((d["kind"].as_str()?, d["path"].as_str()?)))
.filter(|(_, p)| p.starts_with("u/test-user/mvtaken_b") || p.ends_with("mvtaken_legacy"))
.collect::<Vec<_>>();
at.sort();
assert_eq!(
at,
vec![
("script", "u/test-user/mvtaken_b"),
("script", "u/test-user/mvtaken_legacy")
],
"{list:?}"
);
Ok(())
}
/// A classic app and a raw app deploy into the same table, so a draft-only move onto
/// the other kind's draft must be refused: deploying either path afterwards deletes
/// the caller's drafts of both kinds, taking the loser's item with it.
#[sqlx::test(fixtures("base", "drafts_move_taken"))]
async fn test_draft_move_refuses_the_other_app_kind(db: Pool<Postgres>) -> anyhow::Result<()> {
initialize_tracing().await;
let server = ApiServer::start(db.clone()).await?;
let port = server.addr.port();
let resp = reqwest::Client::new()
.post(format!(
"http://localhost:{port}/api/w/test-workspace/drafts/move/raw_app/u/test-user/mvtaken_raw"
))
.header("Authorization", "Bearer SECRET_TOKEN")
.json(&json!({ "new_path": "u/test-user/mvtaken_app" }))
.send()
.await?;
let status = resp.status();
let body = resp.text().await?;
assert_eq!(
status, 400,
"move onto a classic app draft was allowed: {body}"
);
assert!(
body.contains("already have a draft at 'u/test-user/mvtaken_app' (app)"),
"the refusal did not name the occupying kind: {body}"
);
// Both drafts are untouched.
let list: Vec<Value> = reqwest::Client::new()
.get(format!(
"http://localhost:{port}/api/w/test-workspace/drafts/list"
))
.header("Authorization", "Bearer SECRET_TOKEN")
.send()
.await?
.json()
.await?;
let mut at = list
.iter()
.filter(|d| matches!(d["kind"].as_str(), Some("app") | Some("raw_app")))
.filter_map(|d| Some((d["kind"].as_str()?, d["path"].as_str()?)))
.collect::<Vec<_>>();
at.sort();
assert_eq!(
at,
vec![
("app", "u/test-user/mvtaken_app"),
("raw_app", "u/test-user/mvtaken_raw")
],
"{list:?}"
);
Ok(())
}
/// Teammates' drafts of one item share its path by design, so another user's row is no
/// obstacle — except across the app pair, where the two kinds are different items on one
/// deployed path: deploying either strands the other, and deleting the app takes both.
#[sqlx::test(fixtures("base", "drafts_move_taken"))]
async fn test_draft_move_refuses_another_users_other_app_kind(
db: Pool<Postgres>,
) -> anyhow::Result<()> {
initialize_tracing().await;
let server = ApiServer::start(db.clone()).await?;
let port = server.addr.port();
let move_to = |kind: &'static str, from: &'static str, to: &'static str| async move {
let resp = reqwest::Client::new()
.post(format!(
"http://localhost:{port}/api/w/test-workspace/drafts/move/{kind}/{from}"
))
.header("Authorization", "Bearer SECRET_TOKEN")
.json(&json!({ "new_path": to }))
.send()
.await?;
Ok::<_, anyhow::Error>((resp.status(), resp.text().await?))
};
let (status, body) = move_to(
"app",
"u/test-user/mvtaken_app",
"u/test-user/mvtaken_theirs",
)
.await?;
assert_eq!(
status, 400,
"a classic app was moved onto another user's raw app: {body}"
);
assert!(
body.contains("holds another user's raw app draft"),
"the refusal did not name the occupant: {body}"
);
// And the other direction, where the occupant reads as the classic kind.
let (status, body) = move_to(
"raw_app",
"u/test-user/mvtaken_raw",
"u/test-user/mvtaken_app_theirs",
)
.await?;
assert_eq!(
status, 400,
"a raw app was moved onto another user's classic app: {body}"
);
assert!(
body.contains("holds another user's app draft"),
"the refusal did not name the occupant: {body}"
);
// The same-kind case is the ordinary one: two users' drafts of one raw app.
let (status, body) = move_to(
"raw_app",
"u/test-user/mvtaken_raw",
"u/test-user/mvtaken_theirs",
)
.await?;
assert!(
status.is_success(),
"a raw app was refused beside another user's raw-app draft: {body}"
);
Ok(())
}
+43
View File
@@ -78,3 +78,46 @@ async fn test_draft_write_strips_nul(db: Pool<Postgres>) -> anyhow::Result<()> {
Ok(())
}
/// The lineage the server derives for `draft.base` comes from the same value: read before
/// the sanitizer, a NUL in it costs the draft its base, which then reads as up to date
/// whatever the head is.
#[sqlx::test(fixtures("drafts_nul"))]
async fn test_draft_base_is_derived_from_sanitized_value(db: Pool<Postgres>) -> anyhow::Result<()> {
initialize_tracing().await;
let server = ApiServer::start(db.clone()).await?;
let port = server.addr.port();
let base = format!("http://localhost:{port}/api/w/dnul-ws");
let resp = authed(client().post(format!(
"{base}/drafts/update/script/u/dnul-admin/poisoned_base"
)))
.json(&json!({
"value": {
"summary": "s",
"path": "u/dnul-admin/poisoned_base",
"content": "x",
"parent_hash": "0000000000001b62\u{0}"
}
}))
.send()
.await?;
assert_eq!(
resp.status(),
200,
"save refused: {}",
resp.text().await.unwrap_or_default()
);
let stored: Option<String> = sqlx::query_scalar(
"SELECT base FROM draft WHERE workspace_id = 'dnul-ws' AND path = 'u/dnul-admin/poisoned_base'",
)
.fetch_one(&db)
.await?;
assert_eq!(
stored.as_deref(),
Some("0000000000001b62"),
"the NUL cost the draft its base: {stored:?}"
);
Ok(())
}
+587
View File
@@ -0,0 +1,587 @@
//! A draft save addressed to a path its item moved away from lands on the moved draft.
//!
//! A move carries every draft on the item to the new path and records where they
//! went. An editor left open across it still saves to the old path; the server puts
//! the save on the moved draft, keeps the path keys the move gave it, and names the
//! new path so the editor can follow. Without the record the save would plant a
//! phantom draft-only item at the old location.
use serde_json::{json, Value};
use sqlx::{Pool, Postgres};
use windmill_test_utils::*;
/// Hex form of script hash 7030, the way the API takes a parent hash.
const HEAD_HASH: &str = "0000000000001b76";
async fn own_draft_value(port: u16, path: &str) -> anyhow::Result<Value> {
let draft: Value = reqwest::Client::new()
.get(format!(
"http://localhost:{port}/api/w/test-workspace/drafts/get_own/script/{path}"
))
.header("Authorization", "Bearer SECRET_TOKEN")
.send()
.await?
.json()
.await?;
Ok(draft["value"].clone())
}
async fn own_draft_paths(port: u16) -> anyhow::Result<Vec<String>> {
let list: Vec<Value> = reqwest::Client::new()
.get(format!(
"http://localhost:{port}/api/w/test-workspace/drafts/list"
))
.header("Authorization", "Bearer SECRET_TOKEN")
.send()
.await?
.json()
.await?;
Ok(list
.iter()
.filter(|d| d["kind"] == "script")
.filter_map(|d| d["path"].as_str().map(String::from))
.filter(|p| p.starts_with("u/test-user/follow_"))
.collect())
}
#[sqlx::test(fixtures("base", "drafts_save_follows_move"))]
async fn test_save_follows_a_rename(db: Pool<Postgres>) -> anyhow::Result<()> {
initialize_tracing().await;
let server = ApiServer::start(db.clone()).await?;
let port = server.addr.port();
let client = reqwest::Client::new();
// Rename from Home: a redeploy of the deployed content at the new path that
// keeps the deployer's own draft, so the draft is carried rather than consumed.
let resp = client
.post(format!(
"http://localhost:{port}/api/w/test-workspace/scripts/create"
))
.header("Authorization", "Bearer SECRET_TOKEN")
.json(&json!({
"path": "u/test-user/follow_b",
"parent_hash": HEAD_HASH,
"summary": "A",
"description": "",
"content": "export function main() { return 1 }",
"language": "deno",
"schema": {},
"skip_draft_deletion": true
}))
.send()
.await?;
assert_eq!(resp.status(), 201, "rename failed: {}", resp.text().await?);
assert_eq!(own_draft_paths(port).await?, vec!["u/test-user/follow_b"]);
// A script draft's `path` is where deploying it lands, so it moves with the row.
assert_eq!(
own_draft_value(port, "u/test-user/follow_b").await?["path"],
"u/test-user/follow_b"
);
// The editor is still on the old path and writes that path back into the value.
let saved: Value = client
.post(format!(
"http://localhost:{port}/api/w/test-workspace/drafts/update/script/u/test-user/follow_a"
))
.header("Authorization", "Bearer SECRET_TOKEN")
.json(&json!({
"value": {
"path": "u/test-user/follow_a",
"parent_hash": HEAD_HASH,
"summary": "A",
"content": "edited after the move"
}
}))
.send()
.await?
.json()
.await?;
assert_eq!(saved["status"], "saved", "save refused: {saved}");
assert_eq!(
saved["path"], "u/test-user/follow_b",
"save did not follow the row: {saved}"
);
// The write landed on the carried row; nothing reappeared at the old path.
assert_eq!(own_draft_paths(port).await?, vec!["u/test-user/follow_b"]);
let draft = own_draft_value(port, "u/test-user/follow_b").await?;
assert_eq!(draft["content"], "edited after the move", "{draft}");
assert_eq!(draft["path"], "u/test-user/follow_b", "{draft}");
Ok(())
}
/// A draft-only move rewrites both path keys. The owner's open editor still carries
/// the typed path it had, which names neither the old nor the new path; the moved
/// draft's own keys have to win, or the save walks the item back.
#[sqlx::test(fixtures("base", "drafts_save_follows_move"))]
async fn test_save_follows_a_draft_only_move(db: Pool<Postgres>) -> anyhow::Result<()> {
initialize_tracing().await;
let server = ApiServer::start(db.clone()).await?;
let port = server.addr.port();
let client = reqwest::Client::new();
let resp = client
.post(format!(
"http://localhost:{port}/api/w/test-workspace/drafts/move/script/u/test-user/draft_store"
))
.header("Authorization", "Bearer SECRET_TOKEN")
.json(&json!({ "new_path": "u/test-user/moved" }))
.send()
.await?;
assert!(
resp.status().is_success(),
"move failed: {}",
resp.text().await?
);
let saved: Value = client
.post(format!(
"http://localhost:{port}/api/w/test-workspace/drafts/update/script/u/test-user/draft_store"
))
.header("Authorization", "Bearer SECRET_TOKEN")
.json(&json!({
"value": {
"path": "u/test-user/friendly",
"draft_path": "u/test-user/friendly",
"summary": "D",
"content": "edited after the move"
}
}))
.send()
.await?
.json()
.await?;
assert_eq!(saved["path"], "u/test-user/moved", "{saved}");
let draft = own_draft_value(port, "u/test-user/moved").await?;
assert_eq!(draft["content"], "edited after the move", "{draft}");
assert_eq!(draft["path"], "u/test-user/moved", "{draft}");
assert_eq!(draft["draft_path"], "u/test-user/moved", "{draft}");
Ok(())
}
/// Rename `from` to `to` the way Home does: redeploy the deployed content at the new
/// path, keeping the deployer's own draft so it is carried rather than consumed.
/// Returns the new head's hash.
async fn rename(port: u16, from_hash: &str, to: &str) -> anyhow::Result<String> {
let resp = reqwest::Client::new()
.post(format!(
"http://localhost:{port}/api/w/test-workspace/scripts/create"
))
.header("Authorization", "Bearer SECRET_TOKEN")
.json(&json!({
"path": to,
"parent_hash": from_hash,
"summary": "A",
"description": "",
"content": "export function main() { return 1 }",
"language": "deno",
"schema": {},
"skip_draft_deletion": true
}))
.send()
.await?;
let status = resp.status();
let hash = resp.text().await?;
assert_eq!(status, 201, "rename to {to} failed: {hash}");
Ok(hash)
}
/// Save the draft as an editor still bound to `url_path` would. Returns the path the
/// save landed at.
async fn save_at(port: u16, url_path: &str, content: &str) -> anyhow::Result<String> {
let saved: Value = reqwest::Client::new()
.post(format!(
"http://localhost:{port}/api/w/test-workspace/drafts/update/script/{url_path}"
))
.header("Authorization", "Bearer SECRET_TOKEN")
.json(&json!({
"value": { "path": url_path, "summary": "A", "content": content, "language": "deno" }
}))
.send()
.await?
.json()
.await?;
assert_eq!(saved["status"], "saved", "save refused: {saved}");
Ok(saved["path"].as_str().unwrap_or_default().to_string())
}
/// A record is kept to one hop, and a move back to the path it left ends it: both are
/// three statements whose order decides the answer.
#[sqlx::test(fixtures("base", "drafts_save_follows_move"))]
async fn test_move_records_stay_one_hop(db: Pool<Postgres>) -> anyhow::Result<()> {
initialize_tracing().await;
let server = ApiServer::start(db.clone()).await?;
let port = server.addr.port();
let b = rename(port, HEAD_HASH, "u/test-user/follow_b").await?;
let _c = rename(port, &b, "u/test-user/follow_c").await?;
assert_eq!(
save_at(port, "u/test-user/follow_a", "after two moves").await?,
"u/test-user/follow_c",
"a save at the first path did not reach the last"
);
assert_eq!(own_draft_paths(port).await?, vec!["u/test-user/follow_c"]);
Ok(())
}
#[sqlx::test(fixtures("base", "drafts_save_follows_move"))]
async fn test_move_back_ends_the_record(db: Pool<Postgres>) -> anyhow::Result<()> {
initialize_tracing().await;
let server = ApiServer::start(db.clone()).await?;
let port = server.addr.port();
let b = rename(port, HEAD_HASH, "u/test-user/follow_b").await?;
let _a = rename(port, &b, "u/test-user/follow_a").await?;
assert_eq!(
save_at(port, "u/test-user/follow_a", "after moving back").await?,
"u/test-user/follow_a",
"a save was routed off the path the item moved back to"
);
assert_eq!(own_draft_paths(port).await?, vec!["u/test-user/follow_a"]);
Ok(())
}
/// A rename carries a teammate's row too: both its path keys follow, and the version
/// it forked from does not move. A restamp there would clear their out-of-date prompt
/// and let them deploy over the mover's version believing they were current.
#[sqlx::test(fixtures("base", "drafts_save_follows_move"))]
async fn test_a_teammates_draft_follows_with_its_base(db: Pool<Postgres>) -> anyhow::Result<()> {
initialize_tracing().await;
let server = ApiServer::start(db.clone()).await?;
let port = server.addr.port();
rename(port, HEAD_HASH, "u/test-user/follow_b").await?;
// Read from the pool: the teammate's row is another user's, and this asserts on
// `base`, which no endpoint exposes for someone else's draft.
let row: (String, String, Option<String>) = sqlx::query_as(
"SELECT value::jsonb ->> 'path', value::jsonb ->> 'draft_path', base
FROM draft WHERE workspace_id = 'test-workspace' AND typ = 'script'
AND email = 'test2@windmill.dev'",
)
.fetch_one(&db)
.await?;
assert_eq!(row.0, "u/test-user/follow_b", "typed path did not follow");
assert_eq!(row.1, "u/test-user/follow_b", "mirror did not follow");
assert_eq!(
row.2.as_deref(),
Some(HEAD_HASH),
"the teammate's base was restamped by someone else's rename"
);
Ok(())
}
/// An item move and then the owner's own move of what is left: the two records have
/// different scopes, so the owner's move has to extend the chain in its own scope or
/// a save addressed to the first path stops at the abandoned middle one.
#[sqlx::test(fixtures("base", "drafts_save_follows_move"))]
async fn test_an_owner_move_extends_an_item_move(db: Pool<Postgres>) -> anyhow::Result<()> {
initialize_tracing().await;
let server = ApiServer::start(db.clone()).await?;
let port = server.addr.port();
rename(port, HEAD_HASH, "u/test-user/follow_b").await?;
// Archiving the script at the new path leaves the carried draft as a draft-only
// item, which its owner can move through `/drafts/move`.
sqlx::query("UPDATE script SET archived = true WHERE path = 'u/test-user/follow_b'")
.execute(&db)
.await?;
let resp = reqwest::Client::new()
.post(format!(
"http://localhost:{port}/api/w/test-workspace/drafts/move/script/u/test-user/follow_b"
))
.header("Authorization", "Bearer SECRET_TOKEN")
.json(&json!({ "new_path": "u/test-user/follow_c" }))
.send()
.await?;
assert!(
resp.status().is_success(),
"move failed: {}",
resp.text().await?
);
assert_eq!(
save_at(port, "u/test-user/follow_a", "after both moves").await?,
"u/test-user/follow_c",
"a save at the first path stopped at the path the owner's move left"
);
assert_eq!(own_draft_paths(port).await?, vec!["u/test-user/follow_c"]);
Ok(())
}
/// Redeploying at a path an owner's move routed away from ends that route: the live item
/// owns its path again, and its saves must not follow the draft that left.
#[sqlx::test(fixtures("base", "drafts_save_follows_move"))]
async fn test_redeploy_at_a_routed_path_ends_the_route(db: Pool<Postgres>) -> anyhow::Result<()> {
initialize_tracing().await;
let server = ApiServer::start(db.clone()).await?;
let port = server.addr.port();
// `move_draft` ignores archived rows, so an archived script's draft can be moved away.
sqlx::query("UPDATE script SET archived = true WHERE path = 'u/test-user/follow_a'")
.execute(&db)
.await?;
let resp = reqwest::Client::new()
.post(format!(
"http://localhost:{port}/api/w/test-workspace/drafts/move/script/u/test-user/follow_a"
))
.header("Authorization", "Bearer SECRET_TOKEN")
.json(&json!({ "new_path": "u/test-user/follow_b" }))
.send()
.await?;
assert!(
resp.status().is_success(),
"move failed: {}",
resp.text().await?
);
// Unarchiving redeploys at the same path, with the archived version as parent.
rename(port, HEAD_HASH, "u/test-user/follow_a").await?;
assert_eq!(
save_at(port, "u/test-user/follow_a", "for the live script").await?,
"u/test-user/follow_a",
"a save for the redeployed script followed the draft that moved away"
);
Ok(())
}
/// A draft written before the NUL sanitizer still has to follow a move: its path keys are
/// what a deploy of it would land on, so the carry rewrites them, sanitizing the value it
/// could not otherwise parse.
#[sqlx::test(fixtures("base", "drafts_save_follows_move"))]
async fn test_a_poisoned_draft_follows_a_rename(db: Pool<Postgres>) -> anyhow::Result<()> {
initialize_tracing().await;
let server = ApiServer::start(db.clone()).await?;
let port = server.addr.port();
// The teammate's row, rewritten the way a pre-sanitizer client left one: a real NUL
// escape in the content, both path keys naming the path the item is about to leave.
sqlx::query(
r#"UPDATE draft SET value = '{"path": "u/test-user/follow_a", "draft_path": "u/test-user/follow_a",
"parent_hash": "0000000000001b76", "summary": "A", "content": "a\u0000b"}'
WHERE email = 'test2@windmill.dev'"#,
)
.execute(&db)
.await?;
rename(port, HEAD_HASH, "u/test-user/follow_b").await?;
let row: (String, String, String) = sqlx::query_as(
"SELECT value::jsonb ->> 'path', value::jsonb ->> 'draft_path', value::jsonb ->> 'content'
FROM draft WHERE email = 'test2@windmill.dev'",
)
.fetch_one(&db)
.await?;
assert_eq!(row.0, "u/test-user/follow_b", "typed path did not follow");
assert_eq!(row.1, "u/test-user/follow_b", "mirror did not follow");
assert_eq!(row.2, "ab", "the NUL survived the rewrite");
Ok(())
}
/// A discard whose answer is lost is retried, and the retry finds nothing to delete. It
/// still has to name where the item went: the editor asking is on the path it left, and a
/// reload there lands on nothing.
#[sqlx::test(fixtures("base", "drafts_save_follows_move"))]
async fn test_a_retried_discard_still_names_the_destination(
db: Pool<Postgres>,
) -> anyhow::Result<()> {
initialize_tracing().await;
let server = ApiServer::start(db.clone()).await?;
let port = server.addr.port();
let client = reqwest::Client::new();
rename(port, HEAD_HASH, "u/test-user/follow_b").await?;
let discard = |token: &'static str| {
let client = client.clone();
async move {
client
.post(format!(
"http://localhost:{port}/api/w/test-workspace/drafts/update/script/u/test-user/follow_a"
))
.header("Authorization", format!("Bearer {token}"))
.json(&json!({ "value": null }))
.send()
.await?
.json::<Value>()
.await
}
};
let first = discard("SECRET_TOKEN").await?;
assert_eq!(first["status"], "saved", "{first}");
assert_eq!(first["path"], "u/test-user/follow_b", "{first}");
let retry = discard("SECRET_TOKEN").await?;
assert_eq!(
retry["status"], "saved",
"the retry was not idempotent: {retry}"
);
assert_eq!(retry["path"], "u/test-user/follow_b", "{retry}");
// A third user has no draft on this item, so their discard deletes nothing and the
// destination is a move record and nothing else: it is answered only to a caller who
// can read that path.
let other = discard("SECRET_TOKEN_3").await?;
assert_eq!(other["status"], "saved", "{other}");
assert_eq!(
other["path"],
Value::Null,
"a user who cannot read the destination was told where the item went: {other}"
);
Ok(())
}
/// The legacy workspace-level row is carried by a rename like any other draft, and the
/// record that routes saves to it covers every caller — so discarding it from a page that
/// still names the old path has to reach it where it went.
#[sqlx::test(fixtures("base", "drafts_save_follows_move"))]
async fn test_a_legacy_discard_follows_a_rename(db: Pool<Postgres>) -> anyhow::Result<()> {
initialize_tracing().await;
let server = ApiServer::start(db.clone()).await?;
let port = server.addr.port();
sqlx::query(
r#"INSERT INTO draft (workspace_id, path, typ, value, email)
VALUES ('test-workspace', 'u/test-user/follow_a', 'script',
'{"path": "u/test-user/follow_a", "summary": "legacy", "content": "x"}', NULL)"#,
)
.execute(&db)
.await?;
rename(port, HEAD_HASH, "u/test-user/follow_b").await?;
let resp = reqwest::Client::new()
.post(format!(
"http://localhost:{port}/api/w/test-workspace/drafts/update/script/u/test-user/follow_a"
))
.header("Authorization", "Bearer SECRET_TOKEN")
.json(&json!({ "value": null, "legacy": true }))
.send()
.await?;
assert!(
resp.status().is_success(),
"discard failed: {}",
resp.text().await?
);
let left: i64 = sqlx::query_scalar(
"SELECT count(*) FROM draft WHERE workspace_id = 'test-workspace' AND email IS NULL",
)
.fetch_one(&db)
.await?;
assert_eq!(
left, 0,
"the legacy draft survived a discard aimed at its old path"
);
Ok(())
}
/// Deploying a draft that a move carried off an archived script: its parent is still the
/// version at the old path, so the deploy renames from there and carries what is left over
/// — onto the very draft being deployed. That row is this deploy's own, not an item in its
/// way, or the deploy is refused and every retry refuses again.
#[sqlx::test(fixtures("base", "drafts_save_follows_move"))]
async fn test_a_moved_draft_deploys_at_its_new_path(db: Pool<Postgres>) -> anyhow::Result<()> {
initialize_tracing().await;
let server = ApiServer::start(db.clone()).await?;
let port = server.addr.port();
sqlx::query("UPDATE script SET archived = true WHERE path = 'u/test-user/follow_a'")
.execute(&db)
.await?;
let resp = reqwest::Client::new()
.post(format!(
"http://localhost:{port}/api/w/test-workspace/drafts/move/script/u/test-user/follow_a"
))
.header("Authorization", "Bearer SECRET_TOKEN")
.json(&json!({ "new_path": "u/test-user/follow_b" }))
.send()
.await?;
assert!(
resp.status().is_success(),
"move failed: {}",
resp.text().await?
);
// Deploy it where it now lives, still parented on the archived version it forked from.
let resp = reqwest::Client::new()
.post(format!(
"http://localhost:{port}/api/w/test-workspace/scripts/create"
))
.header("Authorization", "Bearer SECRET_TOKEN")
.json(&json!({
"path": "u/test-user/follow_b",
"parent_hash": HEAD_HASH,
"summary": "A",
"description": "",
"content": "export function main() { return 2 }",
"language": "deno",
"schema": {}
}))
.send()
.await?;
let status = resp.status();
let body = resp.text().await?;
assert_eq!(status, 201, "the moved draft could not be deployed: {body}");
Ok(())
}
/// A route is only as good as the item it points at: when an unrelated item claims the
/// destination, a save still addressed to the old path must stay where it is rather than
/// land on that item's draft.
#[sqlx::test(fixtures("base", "drafts_save_follows_move"))]
async fn test_a_reused_destination_ends_the_route(db: Pool<Postgres>) -> anyhow::Result<()> {
initialize_tracing().await;
let server = ApiServer::start(db.clone()).await?;
let port = server.addr.port();
sqlx::query("UPDATE script SET archived = true WHERE path = 'u/test-user/follow_a'")
.execute(&db)
.await?;
let resp = reqwest::Client::new()
.post(format!(
"http://localhost:{port}/api/w/test-workspace/drafts/move/script/u/test-user/follow_a"
))
.header("Authorization", "Bearer SECRET_TOKEN")
.json(&json!({ "new_path": "u/test-user/follow_b" }))
.send()
.await?;
assert!(
resp.status().is_success(),
"move failed: {}",
resp.text().await?
);
// Someone else's item takes the destination, and the moved draft goes with the
// deploy that consumes it.
sqlx::query("DELETE FROM draft WHERE workspace_id = 'test-workspace' AND path = 'u/test-user/follow_b'")
.execute(&db)
.await?;
let resp = reqwest::Client::new()
.post(format!(
"http://localhost:{port}/api/w/test-workspace/scripts/create"
))
.header("Authorization", "Bearer SECRET_TOKEN")
.json(&json!({
"path": "u/test-user/follow_b",
"summary": "unrelated",
"description": "",
"content": "export function main() { return 3 }",
"language": "deno",
"schema": {}
}))
.send()
.await?;
assert_eq!(resp.status(), 201, "create failed: {}", resp.text().await?);
assert_eq!(
save_at(port, "u/test-user/follow_a", "after the destination was reused").await?,
"u/test-user/follow_a",
"a save was routed onto the item that now owns the destination"
);
Ok(())
}
+16
View File
@@ -0,0 +1,16 @@
-- Fixture for the two-path-key move test.
--
-- Two draft-only scripts owned by test-user, both parked at a generated storage
-- key the way a session-created draft is. One carries the `draft_path` mirror the
-- session editor writes while the typed path differs from that key; the other
-- carries no mirror at all, which is what a plain draft looks like.
INSERT INTO draft (workspace_id, path, typ, value, email) VALUES
('test-workspace', 'u/test-user/draft_mirror', 'script',
'{"path": "u/test-user/friendly", "draft_path": "u/test-user/friendly",
"content": "x", "language": "bun", "summary": "S"}',
'test@windmill.dev'),
('test-workspace', 'u/test-user/draft_plain', 'script',
'{"path": "u/test-user/draft_plain",
"content": "x", "language": "bun", "summary": "S"}',
'test@windmill.dev');
+37
View File
@@ -0,0 +1,37 @@
-- Fixture for refusing a rename onto a path a draft already occupies.
--
-- A deployed script at `u/test-user/mvtaken_a` (hash 7010 = 0x1b62), and a
-- never-deployed draft of test-user's own at `u/test-user/mvtaken_b`, the path
-- the rename will target. Nothing deployed lives at the target, so only the
-- draft can refuse the move.
INSERT INTO script (workspace_id, hash, path, content, language, kind, created_by,
schema, summary, description, lock, extra_perms)
VALUES ('test-workspace', 7010, 'u/test-user/mvtaken_a',
'export function main() { return 1 }',
'deno', 'script', 'test-user', '{}', 'A', '', '', '{}');
INSERT INTO draft (workspace_id, path, typ, value, email)
VALUES ('test-workspace', 'u/test-user/mvtaken_b', 'script',
'{"path": "u/test-user/mvtaken_b", "summary": "B", "content": ""}',
'test@windmill.dev');
-- An ownerless (pre per-user drafts) row at a third path: a deploy there deletes it
-- together with the caller's, so it occupies the path just as an own draft does.
INSERT INTO draft (workspace_id, path, typ, value, email)
VALUES ('test-workspace', 'u/test-user/mvtaken_legacy', 'script',
'{"path": "u/test-user/mvtaken_legacy", "summary": "legacy", "content": ""}',
NULL);
-- A draft-only classic app and a draft-only raw app of the same owner. They share
-- the `app` table, so one occupies the other's path. The third is another user's raw
-- app: a different item at its own path, not a second copy of anyone's.
INSERT INTO draft (workspace_id, path, typ, value, email) VALUES
('test-workspace', 'u/test-user/mvtaken_app', 'app',
'{"summary": "classic", "value": {}}', 'test@windmill.dev'),
('test-workspace', 'u/test-user/mvtaken_raw', 'raw_app',
'{"summary": "raw", "files": {}}', 'test@windmill.dev'),
('test-workspace', 'u/test-user/mvtaken_theirs', 'raw_app',
'{"summary": "theirs", "files": {}}', 'test2@windmill.dev'),
('test-workspace', 'u/test-user/mvtaken_app_theirs', 'app',
'{"summary": "theirs", "value": {}}', 'test2@windmill.dev');
+29
View File
@@ -0,0 +1,29 @@
-- Fixture for saving a draft to the path its item moved away from.
--
-- A deployed script at `u/test-user/follow_a` (hash 7030 = 0x1b76) with the
-- deployer's own draft on it. The test renames the script and then saves the
-- draft from an editor still bound to the old path.
INSERT INTO script (workspace_id, hash, path, content, language, kind, created_by,
schema, summary, description, lock, extra_perms)
VALUES ('test-workspace', 7030, 'u/test-user/follow_a',
'export function main() { return 1 }',
'deno', 'script', 'test-user', '{}', 'A', '', '', '{}');
INSERT INTO draft (workspace_id, path, typ, value, email, base)
VALUES ('test-workspace', 'u/test-user/follow_a', 'script',
'{"path": "u/test-user/follow_a", "parent_hash": "0000000000001b76", "summary": "A", "content": "draft"}',
'test@windmill.dev', '0000000000001b76');
-- A draft-only script parked at a generated storage key, its typed path elsewhere.
INSERT INTO draft (workspace_id, path, typ, value, email)
VALUES ('test-workspace', 'u/test-user/draft_store', 'script',
'{"path": "u/test-user/friendly", "draft_path": "u/test-user/friendly", "summary": "D", "content": "draft"}',
'test@windmill.dev');
-- A teammate's draft on the same deployed script, forked from the same head. The
-- rename must carry it too, without touching the version it forked from.
INSERT INTO draft (workspace_id, path, typ, value, email, base)
VALUES ('test-workspace', 'u/test-user/follow_a', 'script',
'{"path": "u/test-user/follow_a", "draft_path": "u/test-user/follow_a", "parent_hash": "0000000000001b76", "summary": "A", "content": "teammate draft"}',
'test2@windmill.dev', '0000000000001b76');
+57
View File
@@ -3312,3 +3312,60 @@ export function main(i: number) {
Ok(())
}
// A `$flow_expr[...]` step tag is resolved from the flow's state before the step is pushed, and
// one that cannot be resolved fails the step instead of queueing it on a tag no worker serves.
#[cfg(feature = "deno_core")]
#[sqlx::test(fixtures("base"))]
async fn test_flow_expr_step_tag(db: Pool<Postgres>) -> anyhow::Result<()> {
initialize_tracing().await;
let server = ApiServer::start(db.clone()).await?;
let step = |id: &str, tag: Option<&str>| {
flow_module(
id,
FlowModuleValue::RawScript {
input_transforms: Default::default(),
language: ScriptLang::Deno,
content: "export function main() { return { lang: 'bun' } }".to_string(),
path: None,
lock: None,
tag: tag.map(str::to_string),
concurrency_settings: Default::default(),
is_trigger: None,
assets: None,
},
)
};
let flow = FlowValue {
modules: vec![
step("a", None),
step("b", Some("$flow_expr[results.a.lang]")),
step("c", Some("nobody-serves-$flow_expr[a.lang]")),
],
same_worker: false,
..Default::default()
};
let job = RunJob::from(JobPayload::RawFlow { value: flow, path: None, restarted_from: None })
.run_until_complete(&db, false, server.addr.port())
.await;
let b_tag = sqlx::query_scalar::<_, String>(
"SELECT tag FROM v2_job WHERE parent_job = $1 AND flow_step_id = 'b'",
)
.bind(job.id)
.fetch_one(&db)
.await?;
assert_eq!(b_tag, "bun");
assert!(!job.success);
let result = job.json_result().unwrap();
let message = result["error"]["message"].as_str().unwrap_or_default();
assert!(
message.contains("Could not resolve the step tag `nobody-serves-$flow_expr[a.lang]`"),
"got {result:?}"
);
Ok(())
}
+25
View File
@@ -1530,6 +1530,31 @@ async fn declarative_sync_rejects_an_unusable_instance_banner(db: Pool<Postgres>
);
}
#[sqlx::test(fixtures("base"))]
async fn declarative_sync_rejects_a_malformed_max_token_expiration(db: Pool<Postgres>) {
clear_settings_and_configs(&db).await;
let before = count_global_settings(&db).await;
let mut desired = BTreeMap::new();
desired.insert(
"max_token_expiration_days".to_string(),
serde_json::json!("7.0"),
);
let err = windmill_common::instance_config::sync_global_settings_declarative(
&db,
&BTreeMap::new(),
&desired,
)
.await
.expect_err("a ceiling the token routes cannot read must fail the sync");
assert!(
err.to_string().contains("max_token_expiration_days"),
"the error should name the offending setting, got: {err}"
);
assert_eq!(count_global_settings(&db).await, before);
}
#[sqlx::test(fixtures("base"))]
async fn declarative_sync_rejects_an_unusable_default_allowed_origins(db: Pool<Postgres>) {
// The declarative writers (the sync-config CLI, the operator's ConfigMap
+108
View File
@@ -0,0 +1,108 @@
/*
* The job's own token (`$WM_TOKEN`) stays valid well past the job it was minted
* for, and job logs are persisted to `job_logs` and optionally to object storage,
* so a script that echoes the token would otherwise park a live credential in
* durable storage. `run_worker` registers the token with `sensitive_log_masks`
* for the job it pulled; this pins that the persisted log carries the masked form.
*/
use sqlx::{Pool, Postgres};
use windmill_common::{
jobs::{JobPayload, RawCode},
scripts::ScriptLang,
};
use windmill_test_utils::*;
/// Prefix of a serialized job token: `jwt_` plus the base64 of a JWT header.
/// The masked form keeps only `jwt` + the last three characters, so it never matches.
const RAW_TOKEN_PREFIX: &str = "jwt_ey";
#[sqlx::test(fixtures("base"))]
async fn test_job_token_masked_in_persisted_logs(db: Pool<Postgres>) -> anyhow::Result<()> {
initialize_tracing().await;
let server = ApiServer::start(db.clone()).await?;
let port = server.addr.port();
let job = RunJob::from(JobPayload::Code(RawCode {
hash: None,
content: "echo \"running with --token $WM_TOKEN\"".to_string(),
path: None,
lock: None,
language: ScriptLang::Bash,
cache_ttl: None,
cache_ignore_s3_path: None,
dedicated_worker: None,
concurrency_settings: windmill_common::runnable_settings::ConcurrencySettings::default()
.into(),
debouncing_settings: windmill_common::runnable_settings::DebouncingSettings::default(),
modules: None,
tag: None,
}))
.run_until_complete(&db, false, port)
.await;
assert!(job.success, "job should have succeeded");
let logs =
sqlx::query_scalar::<_, Option<String>>("SELECT logs FROM job_logs WHERE job_id = $1")
.bind(job.id)
.fetch_one(&db)
.await?
.unwrap_or_default();
assert!(
!logs.contains(RAW_TOKEN_PREFIX),
"an unmasked job token reached the persisted logs: {logs}"
);
assert!(
logs.contains("secret value was masked"),
"expected the masking notice in logs: {logs}"
);
Ok(())
}
/// nativets runs V8 in-process and persists `console.log` output through its own
/// channel, so it is masked by a different mechanism than the bash case above and
/// needs its own guard.
#[cfg(feature = "deno_core")]
#[sqlx::test(fixtures("base"))]
async fn test_job_token_masked_in_nativets_logs(db: Pool<Postgres>) -> anyhow::Result<()> {
initialize_tracing().await;
let server = ApiServer::start(db.clone()).await?;
let port = server.addr.port();
let job = RunJob::from(JobPayload::Code(RawCode {
hash: None,
content: "export async function main() {\n console.log('running with --token ' + process.env.WM_TOKEN);\n return 'ok';\n}".to_string(),
path: None,
lock: None,
language: ScriptLang::Nativets,
cache_ttl: None,
cache_ignore_s3_path: None,
dedicated_worker: None,
concurrency_settings: windmill_common::runnable_settings::ConcurrencySettings::default()
.into(),
debouncing_settings: windmill_common::runnable_settings::DebouncingSettings::default(),
modules: None,
tag: None,
}))
.run_until_complete(&db, false, port)
.await;
assert!(job.success, "job should have succeeded");
let logs =
sqlx::query_scalar::<_, Option<String>>("SELECT logs FROM job_logs WHERE job_id = $1")
.bind(job.id)
.fetch_one(&db)
.await?
.unwrap_or_default();
assert!(
!logs.contains(RAW_TOKEN_PREFIX),
"an unmasked job token reached the persisted logs: {logs}"
);
assert!(
logs.contains("secret value was masked"),
"expected the masking notice in logs: {logs}"
);
Ok(())
}
+4
View File
@@ -1249,6 +1249,10 @@ pub async fn register_token_expiry_notification(
if !windmill_common::auth::is_user_token(label) {
return;
}
let warning_days = windmill_common::auth::TOKEN_EXPIRY_WARNING_DAYS;
if expiration <= chrono::Utc::now() + chrono::Duration::days(warning_days.into()) {
return;
}
if let Err(e) = sqlx::query!(
"INSERT INTO token_expiry_notification (token_hash, expiration) VALUES ($1, $2) ON CONFLICT DO NOTHING",
token_hash,
+42 -6
View File
@@ -51,7 +51,10 @@ use windmill_common::{
jobs::JobPayload,
schedule::Schedule,
triggers::MovedNativeTrigger,
utils::{http_get_from_hub, not_found_if_none, paginate, Pagination, RunnableKind, StripPath},
utils::{
http_get_from_hub, not_found_if_none, paginate, paginate_optional, Pagination,
RunnableKind, StripPath,
},
};
use windmill_dep_map::scoped_dependency_map::ScopedDependencyMap;
use windmill_git_sync::{handle_deployment_metadata, DeployedObject};
@@ -747,6 +750,14 @@ async fn create_flow(
.execute(&mut *tx)
.await?;
}
windmill_common::user_drafts::clear_draft_moves_from(
&mut tx,
&w_id,
&[UserDraftItemKind::Flow],
&nf.path,
None,
)
.await?;
audit_log(
&mut *tx,
@@ -915,25 +926,36 @@ pub struct FlowVersion {
pub created_at: chrono::DateTime<chrono::Utc>,
#[serde(skip_serializing_if = "Option::is_none")]
pub deployment_msg: Option<String>,
/// Who deployed this version — the diff's version picker names them so a reader
/// can tell their own deploys from a teammate's.
#[serde(skip_serializing_if = "Option::is_none")]
pub created_by: Option<String>,
}
async fn get_flow_history(
authed: ApiAuthed,
Extension(user_db): Extension<UserDB>,
Path((w_id, path)): Path<(String, StripPath)>,
Query(pagination): Query<Pagination>,
) -> JsonResult<Vec<FlowVersion>> {
let path = path.to_path();
check_scopes(&authed, || format!("flows:read:{}", path))?;
// Unasked-for, this listing stays whole: the history panels, the restart picker and
// the CLI all read it without paging. The diff picker asks for a page.
let (per_page, offset) = paginate_optional(pagination);
let mut tx = user_db.begin(&authed).await?;
let flows = sqlx::query_as!(
FlowVersion,
"SELECT flow_version.id, flow_version.created_at, deployment_metadata.deployment_msg FROM flow_version
"SELECT flow_version.id, flow_version.created_at, flow_version.created_by, deployment_metadata.deployment_msg FROM flow_version
LEFT JOIN deployment_metadata ON flow_version.id = deployment_metadata.flow_version
WHERE flow_version.path = $1 AND flow_version.workspace_id = $2
ORDER BY flow_version.created_at DESC",
WHERE flow_version.path = $1 AND flow_version.workspace_id = $2
ORDER BY flow_version.created_at DESC
LIMIT $3 OFFSET $4",
path,
w_id
w_id,
per_page,
offset,
)
.fetch_all(&mut *tx)
.await?;
@@ -953,7 +975,7 @@ async fn get_latest_version(
let version = sqlx::query_as!(
FlowVersion,
"SELECT flow_version.id, flow_version.created_at, deployment_metadata.deployment_msg FROM flow_version
"SELECT flow_version.id, flow_version.created_at, flow_version.created_by, deployment_metadata.deployment_msg FROM flow_version
LEFT JOIN deployment_metadata ON flow_version.id = deployment_metadata.flow_version
WHERE flow_version.path = $1 AND flow_version.workspace_id = $2
ORDER BY flow_version.created_at DESC",
@@ -1438,6 +1460,20 @@ async fn update_flow(
.await?;
}
if is_new_path {
// Everything left at the old path is a draft this deploy didn't consume
// — teammates' rows, and the deployer's own when the caller asked us to
// keep it. Carry them rather than strand them.
windmill_common::user_drafts::move_drafts_for_path(
&mut tx,
&w_id,
&[UserDraftItemKind::Flow],
flow_path,
&nf.path,
)
.await?;
}
audit_log(
&mut *tx,
&authed,
@@ -311,3 +311,130 @@ async fn test_fork_keeps_only_resolvable_on_behalf_of(db: Pool<Postgres>) -> any
Ok(())
}
/// Apps, schedules, triggers and their drafts cannot drop an identity the way scripts and flows
/// do, so one naming nobody in the fork goes to its creator while one that still resolves stays.
/// Forked as an admin, whose app policies the clone otherwise keeps.
#[sqlx::test(migrations = "../migrations", fixtures("base"))]
async fn test_fork_repoints_unresolvable_identities(db: Pool<Postgres>) -> anyhow::Result<()> {
initialize_tracing().await;
let server = ApiServer::start(db.clone()).await?;
let base_url = format!("http://localhost:{}/api", server.addr.port());
let stranger = json!({
"on_behalf_of": "u/test-user-2",
"on_behalf_of_email": "test2@windmill.dev",
"execution_mode": "publisher",
});
sqlx::query(
"INSERT INTO app (workspace_id, path, summary, policy, versions)
VALUES ('test-workspace', 'u/test-user/stranger', '', $1, '{}'),
('test-workspace', 'u/test-user/group', '', $2, '{}')",
)
.bind(&stranger)
.bind(json!({
"on_behalf_of": "g/all",
"on_behalf_of_email": "group-all@windmill.dev",
"execution_mode": "publisher",
}))
.execute(&db)
.await?;
// The clone re-aggregates `versions` from `app_version`, and the column is NOT NULL.
sqlx::query(
"WITH v AS (
INSERT INTO app_version (app_id, value, created_by)
SELECT id, '{}'::json, 'test-user' FROM app WHERE workspace_id = 'test-workspace'
RETURNING id, app_id
)
UPDATE app SET versions = ARRAY[v.id] FROM v WHERE app.id = v.app_id",
)
.execute(&db)
.await?;
sqlx::query(
"INSERT INTO draft (workspace_id, path, typ, value, created_at, email)
VALUES ('test-workspace', 'u/test-user/stranger', 'raw_app', $1::json, NOW(), 'test@windmill.dev'),
('test-workspace', 'u/test-user/stranger', 'trigger_websocket', $2::json, NOW(), 'test@windmill.dev'),
('test-workspace', 'u/test-user/nul', 'raw_app', $3::json, NOW(), 'test@windmill.dev')",
)
.bind(json!({ "policy": stranger }))
.bind(json!({ "permissioned_as": "u/test-user-2" }))
// Saved before drafts were stripped of NULs: any jsonb parse of it raises, so it must be
// skipped rather than abort the fork. Built from parts because a NUL escape can't sit in source.
.bind(format!(
r#"{{"policy":{{"on_behalf_of":"u/test-user-2"}},"files":{{"f":"a{}u0000"}}}}"#,
"\\"
))
.execute(&db)
.await?;
sqlx::query(
"INSERT INTO schedule (workspace_id, path, edited_by, schedule, script_path, email, permissioned_as, enabled)
VALUES ('test-workspace', 'u/test-user/stranger', 'test-user', '0 0 * * * *', 'u/test-user/s', 'test2@windmill.dev', 'u/test-user-2', false)",
)
.execute(&db)
.await?;
sqlx::query(
"INSERT INTO websocket_trigger (workspace_id, path, url, script_path, is_flow, edited_by, permissioned_as, mode)
VALUES ('test-workspace', 'u/test-user/stranger', 'ws://localhost', 'u/test-user/s', false, 'test-user', 'u/test-user-2', 'disabled')",
)
.execute(&db)
.await?;
let resp = reqwest::Client::new()
.post(format!(
"{base_url}/w/test-workspace/workspaces/create_fork"
))
.header("Authorization", "Bearer SECRET_TOKEN")
.json(&json!({ "id": "wm-fork-repoint", "name": "Fork", "color": "#0000ff" }))
.send()
.await?;
assert!(
resp.status().is_success(),
"creating the fork: {}",
resp.text().await?
);
let text = |sql: &'static str| sqlx::query_scalar::<_, String>(sql).fetch_one(&db);
assert_eq!(
text("SELECT (policy->>'on_behalf_of') || ' ' || (policy->>'on_behalf_of_email') FROM app WHERE workspace_id = 'wm-fork-repoint' AND path = 'u/test-user/stranger'").await?,
"u/test-user test@windmill.dev"
);
assert_eq!(
text("SELECT policy->>'on_behalf_of' FROM app WHERE workspace_id = 'wm-fork-repoint' AND path = 'u/test-user/group'").await?,
"g/all"
);
assert_eq!(
text("SELECT value->'policy'->>'on_behalf_of' FROM draft WHERE workspace_id = 'wm-fork-repoint' AND path = 'u/test-user/stranger' AND typ = 'raw_app'").await?,
"u/test-user"
);
// `clone_drafts` strips a NUL escape as it copies, so the row reaches the fork
// parseable and the repoint below reaches it like any other draft's. The rule this
// guards is that the fork completes and no identity naming nobody survives it; the
// skip only ever existed because `to_jsonb` raises on a value still holding one.
assert_eq!(
text("SELECT CASE WHEN strpos(value::text, 'u/test-user-2') > 0 THEN 'kept' ELSE 'rewritten' END FROM draft WHERE workspace_id = 'wm-fork-repoint' AND path = 'u/test-user/nul'").await?,
"rewritten"
);
// And it arrives without the poison that made it a special case.
assert_eq!(
text("SELECT CASE WHEN position(chr(92) || 'u0000' in value::text) > 0 THEN 'poisoned' ELSE 'clean' END FROM draft WHERE workspace_id = 'wm-fork-repoint' AND path = 'u/test-user/nul'").await?,
"clean"
);
assert_eq!(
text("SELECT value->>'permissioned_as' FROM draft WHERE workspace_id = 'wm-fork-repoint' AND typ = 'trigger_websocket'").await?,
"u/test-user"
);
assert_eq!(
text("SELECT permissioned_as || ' ' || email FROM schedule WHERE workspace_id = 'wm-fork-repoint'").await?,
"u/test-user test@windmill.dev"
);
assert_eq!(
text(
"SELECT permissioned_as FROM websocket_trigger WHERE workspace_id = 'wm-fork-repoint'"
)
.await?,
"u/test-user"
);
Ok(())
}
@@ -0,0 +1,75 @@
use serde_json::json;
use sqlx::{Pool, Postgres};
use windmill_test_utils::*;
/// With `add_admins_and_developers_to_forks` on, a fork starts with the parent's admins and
/// developers at their parent role, even when a developer forks it; operators are left out. The
/// copies are manual members: a parent membership that came from an instance group must not carry
/// that provenance into a fork that does not configure the group.
#[sqlx::test(migrations = "../migrations", fixtures("base"))]
async fn test_fork_adds_parent_admins_and_developers(db: Pool<Postgres>) -> anyhow::Result<()> {
initialize_tracing().await;
let server = ApiServer::start(db.clone()).await?;
let base_url = format!(
"http://localhost:{}/api/w/test-workspace/workspaces",
server.addr.port()
);
let client = reqwest::Client::new();
sqlx::query(
"UPDATE usr SET operator = true WHERE workspace_id = 'test-workspace' AND username = 'test-user-3'",
)
.execute(&db)
.await?;
sqlx::query(
"INSERT INTO usr (workspace_id, email, username, is_admin, added_via)
VALUES ('test-workspace', 'test4@windmill.dev', 'test-user-4', false,
'{\"source\": \"instance_group\", \"group\": \"devs\"}')",
)
.execute(&db)
.await?;
let resp = client
.post(format!(
"{base_url}/edit_add_admins_and_developers_to_forks"
))
.header("Authorization", "Bearer SECRET_TOKEN")
.json(&json!({ "add_admins_and_developers_to_forks": true }))
.send()
.await?;
assert!(
resp.status().is_success(),
"enabling the setting: {}",
resp.text().await?
);
let resp = client
.post(format!("{base_url}/create_fork"))
.header("Authorization", "Bearer SECRET_TOKEN_2")
.json(&json!({ "id": "wm-fork-team", "name": "Team fork" }))
.send()
.await?;
assert!(
resp.status().is_success(),
"creating the fork: {}",
resp.text().await?
);
let members: Vec<(String, bool, bool)> = sqlx::query_as(
"SELECT username, is_admin, added_via IS NULL FROM usr
WHERE workspace_id = 'wm-fork-team' ORDER BY username",
)
.fetch_all(&db)
.await?;
assert_eq!(
members,
vec![
("test-user".to_string(), true, true),
("test-user-2".to_string(), false, true),
("test-user-4".to_string(), false, true),
]
);
Ok(())
}
@@ -115,6 +115,60 @@ async fn login_link_is_single_use_and_same_origin(db: Pool<Postgres>) -> anyhow:
Ok(())
}
#[sqlx::test(migrations = "../migrations", fixtures("base"))]
async fn confirmed_login_link_is_spent_by_the_click_not_the_page(
db: Pool<Postgres>,
) -> anyhow::Result<()> {
initialize_tracing().await;
let server = ApiServer::start(db.clone()).await?;
let port = server.addr.port();
let base = format!("http://localhost:{port}/api");
let resp = client()
.post(format!("{base}/users/login_links"))
.header("Authorization", "Bearer SECRET_TOKEN")
.json(&json!({"email": "test2@windmill.dev", "confirm": true}))
.send()
.await?;
assert_eq!(resp.status(), 201);
let link = resp.json::<serde_json::Value>().await?;
// The URL handed out is the frontend page, not the API path that signs in on a GET.
let token = link["url"]
.as_str()
.unwrap()
.split_once("/user/login_link?token=")
.expect("confirmation page url")
.1
.to_string();
let confirm = || {
client()
.post(format!("{base}/auth/login_link/{token}"))
.send()
};
let resp = confirm().await?;
assert_eq!(resp.status(), 200);
assert!(resp
.headers()
.get_all("set-cookie")
.iter()
.any(|c| c.to_str().unwrap().starts_with("token=")));
assert_eq!(
resp.json::<serde_json::Value>().await?["location"],
"/user/workspaces"
);
let resp = confirm().await?;
assert_eq!(resp.status(), 200);
assert!(resp.headers().get("set-cookie").is_none());
assert_eq!(
resp.json::<serde_json::Value>().await?["location"],
"/user/login_link_expired?reason=used"
);
Ok(())
}
#[sqlx::test(migrations = "../migrations", fixtures("base"))]
async fn login_link_mint_can_require_a_login_type(db: Pool<Postgres>) -> anyhow::Result<()> {
initialize_tracing().await;
@@ -0,0 +1,239 @@
//! `max_token_expiration_days`: the instance-wide ceiling on how far ahead a token a caller
//! picks the lifetime of may expire, and the service-account exemption.
use serde_json::json;
use sqlx::types::chrono::{DateTime, Utc};
use sqlx::{Pool, Postgres};
use windmill_test_utils::*;
const DAY: u64 = 24 * 60 * 60;
fn client() -> reqwest::Client {
reqwest::Client::new()
}
fn from_now(secs: u64) -> DateTime<Utc> {
Utc::now() + std::time::Duration::from_secs(secs)
}
async fn set_max(db: &Pool<Postgres>, value: serde_json::Value) {
sqlx::query(
"INSERT INTO global_settings (name, value) VALUES ('max_token_expiration_days', $1)
ON CONFLICT (name) DO UPDATE SET value = EXCLUDED.value",
)
.bind(value)
.execute(db)
.await
.unwrap();
}
/// Mints as `test2@windmill.dev`, a plain member of `test-workspace`.
async fn create_token(port: u16, body: serde_json::Value) -> reqwest::Response {
client()
.post(format!("http://localhost:{port}/api/users/tokens/create"))
.header("Authorization", "Bearer SECRET_TOKEN_2")
.json(&body)
.send()
.await
.unwrap()
}
async fn stored_expiration(db: &Pool<Postgres>, label: &str) -> Option<DateTime<Utc>> {
sqlx::query_scalar::<_, Option<DateTime<Utc>>>("SELECT expiration FROM token WHERE label = $1")
.bind(label)
.fetch_one(db)
.await
.unwrap()
}
#[sqlx::test(migrations = "../migrations", fixtures("base"))]
async fn test_max_token_expiration_days_shortens_user_tokens(
db: Pool<Postgres>,
) -> anyhow::Result<()> {
initialize_tracing().await;
let server = ApiServer::start(db.clone()).await?;
let port = server.addr.port();
let resp = create_token(port, json!({ "label": "unset" })).await;
assert_eq!(resp.status(), 201);
assert_eq!(
stored_expiration(&db, "unset").await,
None,
"with no setting a token may still have no expiration"
);
// Refused at write time: the token routes can only read a value they cannot parse as no
// ceiling at all.
let resp = client()
.post(format!(
"http://localhost:{port}/api/settings/global/max_token_expiration_days"
))
.header("Authorization", "Bearer SECRET_TOKEN")
.json(&json!({ "value": 7.5 }))
.send()
.await?;
assert_eq!(resp.status(), 400);
set_max(&db, json!(7)).await;
// The token form reads the ceiling as whoever is creating the token, usually not a
// superadmin, so it can offer only expirations the server would keep.
let resp = client()
.get(format!(
"http://localhost:{port}/api/settings/global/max_token_expiration_days"
))
.header("Authorization", "Bearer SECRET_TOKEN_2")
.send()
.await?;
assert_eq!(resp.status(), 200);
assert_eq!(resp.text().await?, "7");
let resp = create_token(port, json!({ "label": "none asked" })).await;
assert_eq!(resp.status(), 201);
let expiration = stored_expiration(&db, "none asked")
.await
.expect("a token asking for no expiration gets the ceiling");
assert!(
expiration > from_now(6 * DAY) && expiration <= from_now(7 * DAY),
"expected the 7 day ceiling, got {expiration}"
);
let resp = create_token(
port,
json!({ "label": "past the ceiling", "expiration": from_now(30 * DAY) }),
)
.await;
assert_eq!(resp.status(), 201);
let expiration = stored_expiration(&db, "past the ceiling").await.unwrap();
assert!(
expiration > from_now(6 * DAY) && expiration <= from_now(7 * DAY),
"expected an expiration past the ceiling to be shortened to it, got {expiration}"
);
let resp = create_token(
port,
json!({ "label": "within", "expiration": from_now(3 * DAY) }),
)
.await;
assert_eq!(resp.status(), 201);
let expiration = stored_expiration(&db, "within").await.unwrap();
assert!(
expiration <= from_now(3 * DAY),
"an expiration within the ceiling must be kept, got {expiration}"
);
// The settings UI stores an integer, but the YAML instance config and config sync can write
// the same whole number as a string or as `5.0`. Reading either as "unset" would silently
// drop the ceiling, while the token form (`parseMaxTokenExpirationDays`) still showed it.
for (stored, label) in [
(json!("5"), "string setting"),
(json!(5.0), "float setting"),
] {
set_max(&db, stored).await;
let resp = create_token(port, json!({ "label": label })).await;
assert_eq!(resp.status(), 201);
let expiration = stored_expiration(&db, label).await;
assert!(
expiration.is_some_and(|e| e > from_now(4 * DAY) && e <= from_now(5 * DAY)),
"{label}: expected the 5 day ceiling, got {expiration:?}"
);
}
// A superadmin impersonating a user picks the lifetime too, so the ceiling applies there;
// left out, it would be the one way to mint a token that never expires.
let resp = client()
.post(format!(
"http://localhost:{port}/api/users/tokens/impersonate"
))
.header("Authorization", "Bearer SECRET_TOKEN")
.json(&json!({ "label": "impersonated", "impersonate_email": "test3@windmill.dev" }))
.send()
.await?;
assert_eq!(resp.status(), 201);
assert!(
stored_expiration(&db, "impersonated").await.is_some(),
"an impersonation token asking for no expiration gets the ceiling"
);
Ok(())
}
#[sqlx::test(migrations = "../migrations", fixtures("base"))]
async fn test_service_accounts_are_exempt(db: Pool<Postgres>) -> anyhow::Result<()> {
initialize_tracing().await;
let server = ApiServer::start(db.clone()).await?;
let port = server.addr.port();
set_max(&db, json!(7)).await;
// The same email is a service account in one workspace and an ordinary user in another.
sqlx::query(
"UPDATE usr SET is_service_account = true
WHERE email = 'test2@windmill.dev' AND workspace_id = 'test-workspace'",
)
.execute(&db)
.await?;
sqlx::query("INSERT INTO workspace (id, name, owner) VALUES ('other', 'other', 'test-user')")
.execute(&db)
.await?;
sqlx::query("INSERT INTO workspace_settings (workspace_id) VALUES ('other')")
.execute(&db)
.await?;
sqlx::query(
"INSERT INTO usr (workspace_id, email, username, is_admin, role)
VALUES ('other', 'test2@windmill.dev', 'test-user-2', false, 'User')",
)
.execute(&db)
.await?;
// The token form decides the exemption from this list, so it must carry each membership's flag.
let memberships: serde_json::Value = client()
.get(format!("http://localhost:{port}/api/workspaces/users"))
.header("Authorization", "Bearer SECRET_TOKEN_2")
.send()
.await?
.json()
.await?;
for (workspace, is_service_account) in [("test-workspace", true), ("other", false)] {
let membership = memberships["workspaces"]
.as_array()
.and_then(|ws| ws.iter().find(|w| w["id"] == workspace))
.unwrap_or_else(|| panic!("{workspace} missing from {memberships}"));
assert_eq!(membership["is_service_account"], json!(is_service_account));
}
for (label, workspace_id, exempt) in [
("own workspace", Some("test-workspace"), true),
("other workspace", Some("other"), false),
// A workspace-less token has no workspace to match, so a service account anywhere counts.
("global", None, true),
] {
let resp = create_token(
port,
json!({ "label": label, "workspace_id": workspace_id }),
)
.await;
assert_eq!(resp.status(), 201);
assert_eq!(
stored_expiration(&db, label).await.is_none(),
exempt,
"{label}: expected exempt = {exempt}"
);
}
// Impersonation checks the impersonated account, not the superadmin minting the token.
let resp = client()
.post(format!(
"http://localhost:{port}/api/users/tokens/impersonate"
))
.header("Authorization", "Bearer SECRET_TOKEN")
.json(&json!({ "label": "impersonated service account", "impersonate_email": "test2@windmill.dev" }))
.send()
.await?;
assert_eq!(resp.status(), 201);
assert_eq!(
stored_expiration(&db, "impersonated service account").await,
None
);
Ok(())
}
@@ -0,0 +1,102 @@
//! The `mcp_disable_token_query_param` switch closes the URL-borne credential path.
//!
//! The rejection is a middleware layered between the `WWW-Authenticate` decorator and
//! everything that reads a token, on both the workspaced and the gateway mount. Each half of
//! that sandwich is pinned: the `WWW-Authenticate` header on the refusal catches the layer
//! being moved outward (a client would lose the pointer that starts OAuth discovery), and
//! refusing a token that was never valid catches it being moved inward past authentication
//! (the URL-borne token would be hashed and looked up before anything refused it).
#![cfg(feature = "mcp")]
use std::sync::atomic::Ordering;
use serde_json::json;
use sqlx::{Pool, Postgres};
use windmill_common::global_settings::MCP_DISABLE_TOKEN_QUERY_PARAM;
use windmill_test_utils::*;
/// Workspace-less with an `mcp:` scope, which is what the gateway mount requires; the
/// workspaced mount takes its workspace from the path, so one token reaches both.
async fn insert_mcp_token(db: &Pool<Postgres>) -> anyhow::Result<()> {
sqlx::query(
"INSERT INTO token (token_hash, token_prefix, token, email, label, super_admin, scopes)
VALUES (encode(sha256('MCP_TOKEN'::bytea), 'hex'), 'MCP_TOK', 'MCP_TOKEN', 'test@windmill.dev', 'mcp token', true, ARRAY['mcp:all'])",
)
.execute(db)
.await?;
Ok(())
}
/// A token that is not in `token` at all. Authentication would refuse it on its own, so a
/// refusal carrying the middleware's own wording is evidence nothing looked it up first.
const BOGUS_TOKEN: &str = "NOT_A_REAL_TOKEN";
async fn tools_list(url: &str) -> anyhow::Result<reqwest::Response> {
Ok(reqwest::Client::new()
.post(url)
.header("Accept", "application/json, text/event-stream")
.json(&json!({ "jsonrpc": "2.0", "id": 1, "method": "tools/list", "params": {} }))
.send()
.await?)
}
#[sqlx::test(migrations = "../migrations", fixtures("base"))]
async fn test_mcp_token_query_param_switch(db: Pool<Postgres>) -> anyhow::Result<()> {
initialize_tracing().await;
set_jwt_secret().await;
insert_mcp_token(&db).await?;
let server = ApiServer::start_mcp(db.clone()).await?;
let port = server.addr.port();
let workspaced =
format!("http://localhost:{port}/api/mcp/w/test-workspace/mcp?token=MCP_TOKEN");
let gateway = format!("http://localhost:{port}/api/mcp/gateway?token=MCP_TOKEN");
assert_eq!(
tools_list(&workspaced).await?.status(),
200,
"a URL-borne token is the documented default and must keep working while the switch is off"
);
assert_eq!(tools_list(&gateway).await?.status(), 200);
MCP_DISABLE_TOKEN_QUERY_PARAM.store(true, Ordering::Relaxed);
for url in [&workspaced, &gateway] {
let resp = tools_list(url).await?;
assert_eq!(
resp.status(),
401,
"{url} still admitted a token in the URL"
);
// What sends the client into the OAuth flow rather than leaving it stuck on a 401.
assert!(
resp.headers().contains_key("www-authenticate"),
"{url} rejected without pointing at the authorization server"
);
}
// Refused before authentication, not after: an invalid token gets the middleware's own
// message rather than the generic 401 that looking it up would produce.
let resp = tools_list(&format!(
"http://localhost:{port}/api/mcp/w/test-workspace/mcp?token={BOGUS_TOKEN}"
))
.await?;
assert_eq!(resp.status(), 401);
assert!(
resp.text().await?.contains("does not accept a token in the MCP URL"),
"an invalid URL token was answered by authentication, so the token was read before \
the switch refused it"
);
// The header stays open: it is the channel the OAuth flow itself hands tokens over on.
let resp = reqwest::Client::new()
.post(format!("http://localhost:{port}/api/mcp/gateway"))
.header("Accept", "application/json, text/event-stream")
.header("Authorization", "Bearer MCP_TOKEN")
.json(&json!({ "jsonrpc": "2.0", "id": 1, "method": "tools/list", "params": {} }))
.send()
.await?;
assert_eq!(resp.status(), 200);
Ok(())
}
@@ -0,0 +1,49 @@
//! Which user tokens get an "expiring soon" warning queued when they are created.
use serde_json::json;
use sqlx::types::chrono::Utc;
use sqlx::{Pool, Postgres};
use windmill_test_utils::*;
const DAY: u64 = 24 * 60 * 60;
async fn warning_queued(db: &Pool<Postgres>, label: &str) -> bool {
sqlx::query_scalar::<_, bool>(
"SELECT EXISTS(SELECT 1 FROM token_expiry_notification n
JOIN token t ON t.token_hash = n.token_hash WHERE t.label = $1)",
)
.bind(label)
.fetch_one(db)
.await
.unwrap()
}
#[sqlx::test(migrations = "../migrations", fixtures("base"))]
async fn test_short_lived_tokens_get_no_expiry_warning(db: Pool<Postgres>) -> anyhow::Result<()> {
initialize_tracing().await;
let server = ApiServer::start(db.clone()).await?;
let port = server.addr.port();
for (label, lifetime) in [("short", DAY), ("long", 30 * DAY)] {
let resp = reqwest::Client::new()
.post(format!("http://localhost:{port}/api/users/tokens/create"))
.header("Authorization", "Bearer SECRET_TOKEN_2")
.json(&json!({
"label": label,
"expiration": Utc::now() + std::time::Duration::from_secs(lifetime),
}))
.send()
.await?;
assert_eq!(resp.status(), 201);
}
assert!(
!warning_queued(&db, "short").await,
"a token whose whole lifetime fits in the warning window must not be warned about"
);
assert!(
warning_queued(&db, "long").await,
"a longer-lived token still gets its warning"
);
Ok(())
}
@@ -275,6 +275,67 @@ async fn test_encryption_key_rotation_dispatches_batched_git_sync(
Ok(())
}
/// Stored repository tokens and webhook secrets are encrypted under the
/// workspace key but never synced, so a rotation has to carry them over even
/// when the caller skips re-encrypting variables.
#[sqlx::test(migrations = "../migrations", fixtures("base"))]
async fn test_encryption_key_rotation_reencrypts_git_sync_secrets(
db: Pool<Postgres>,
) -> anyhow::Result<()> {
use windmill_common::variables::{build_crypt, crypt_from_key_with_suffix, decrypt, encrypt};
initialize_tracing().await;
create_folder(&db, "28103").await?;
create_git_repo_resource(&db).await?;
let sync_script_path = "f/28103/test_sync_script_git_secrets";
create_sync_script(&db, sync_script_path).await?;
setup_git_sync_config(&db, sync_script_path).await?;
let mc = build_crypt(&db, "test-workspace").await?;
sqlx::query(
r#"
UPDATE workspace_settings SET
git_credentials = jsonb_build_array(jsonb_build_object(
'token', $1::text, 'repo_identity', 'https://gitlab.example.com/grp/proj')),
git_sync = jsonb_set(git_sync, '{repositories,0,auto_pull}', jsonb_build_object(
'enabled', true, 'mode', 'webhook', 'webhook_id', 1, 'webhook_secret', $2::text))
WHERE workspace_id = 'test-workspace'
"#,
)
.bind(encrypt(&mc, "stored-token"))
.bind(encrypt(&mc, "hook-secret"))
.execute(&db)
.await?;
let server = ApiServer::start(db.clone()).await?;
let port = server.addr.port();
let base = format!("http://localhost:{port}/api/w/test-workspace/workspaces");
let new_key = "c".repeat(64);
let resp = authed(client().post(format!("{base}/encryption_key")))
.json(&json!({"new_key": new_key, "skip_reencrypt": true}))
.send()
.await?;
assert_eq!(
resp.status(),
200,
"set_encryption_key failed: {}",
resp.text().await?
);
let (token, secret): (String, String) = sqlx::query_as(
"SELECT git_credentials->0->>'token', git_sync#>>'{repositories,0,auto_pull,webhook_secret}'
FROM workspace_settings WHERE workspace_id = 'test-workspace'",
)
.fetch_one(&db)
.await?;
let new_mc = crypt_from_key_with_suffix(&new_key, "");
assert_eq!(decrypt(&new_mc, token)?, "stored-token");
assert_eq!(decrypt(&new_mc, secret)?, "hook-secret");
Ok(())
}
/// Regression test for the non-debouncing fallback: a workspace whose sync
/// script predates hub version 28103 must still receive git-sync jobs for the
/// encryption_key entry and every re-encrypted secret. Before the fallback was
@@ -1166,3 +1166,47 @@ async fn test_create_service_account_drops_orphaned_group_memberships(
Ok(())
}
#[cfg(feature = "private")]
#[sqlx::test(migrations = "../migrations", fixtures("base"))]
async fn test_edit_auto_invite_preserves_instance_groups(db: Pool<Postgres>) -> anyhow::Result<()> {
initialize_tracing().await;
sqlx::query(
r#"UPDATE workspace_settings
SET auto_invite = '{"instance_groups": ["eng"], "instance_groups_roles": {"eng": "developer"}}'
WHERE workspace_id = 'test-workspace'"#,
)
.execute(&db)
.await?;
let server = ApiServer::start(db.clone()).await?;
let port = server.addr.port();
let base = format!("http://localhost:{port}/api/w/test-workspace/workspaces");
// enable, then disable
for body in [
json!({"operator": false, "invite_all": true, "auto_add": false}),
json!({}),
] {
let resp = authed(client().post(format!("{base}/edit_auto_invite")))
.json(&body)
.send()
.await?;
assert_eq!(resp.status(), 200, "{body}: {}", resp.text().await?);
let auto_invite: serde_json::Value = sqlx::query_scalar(
"SELECT auto_invite FROM workspace_settings WHERE workspace_id = 'test-workspace'",
)
.fetch_one(&db)
.await?;
assert_eq!(auto_invite["instance_groups"], json!(["eng"]), "{body}");
assert_eq!(
auto_invite["instance_groups_roles"],
json!({"eng": "developer"}),
"{body}"
);
}
Ok(())
}
+52 -16
View File
@@ -55,7 +55,7 @@ use windmill_common::{
min_version_supports_runnable_settings_v0, RunnableSettings, RunnableSettingsTrait,
},
scripts::{hash_script, ScriptRunnableSettingsHandle, ScriptRunnableSettingsInline},
utils::{paginate_without_limits, WarnAfterExt},
utils::{paginate_optional, paginate_without_limits, WarnAfterExt},
worker::CLOUD_HOSTED,
};
use windmill_object_store::upload_artifact_to_store;
@@ -2312,6 +2312,20 @@ async fn create_script_internal<'c>(
.await?;
}
if p_path != &ns.path {
// Everything left at the old path is a draft this deploy didn't
// consume — teammates' rows, and the deployer's own when the caller
// asked us to keep it. Carry them rather than strand them.
windmill_common::user_drafts::move_drafts_for_path(
&mut tx,
&w_id,
&[UserDraftItemKind::Script],
p_path,
&ns.path,
)
.await?;
}
sqlx::query!(
"UPDATE capture_config SET path = $1 WHERE path = $2 AND workspace_id = $3 AND is_flow IS FALSE",
ns.path,
@@ -2389,19 +2403,32 @@ async fn create_script_internal<'c>(
tx = push_scheduled_job(&db, tx, &schedule, None, None).await?;
}
}
} else if !skip_draft_deletion {
// See the matching branch above — only wipe the deployer's own
// draft (plus the legacy NULL-email row).
sqlx::query!(
"DELETE FROM draft WHERE path = $1 AND workspace_id = $2 AND typ = 'script' \
AND (email = $3 OR email IS NULL)",
ns.path,
&w_id,
&authed.email,
)
.execute(&mut *tx)
.await?;
} else {
if !skip_draft_deletion {
// See the matching branch above — only wipe the deployer's own
// draft (plus the legacy NULL-email row).
sqlx::query!(
"DELETE FROM draft WHERE path = $1 AND workspace_id = $2 AND typ = 'script' \
AND (email = $3 OR email IS NULL)",
ns.path,
&w_id,
&authed.email,
)
.execute(&mut *tx)
.await?;
}
}
// Every deploy, not only a new script: an archived script's draft can be moved away
// (`move_draft` ignores archived rows), and unarchiving redeploys at the same path,
// where a route left behind would send the live script's saves to the moved draft.
windmill_common::user_drafts::clear_draft_moves_from(
&mut tx,
&w_id,
&[UserDraftItemKind::Script],
&ns.path,
p_path_opt.as_deref(),
)
.await?;
if p_hashes.is_some() && !p_hashes.unwrap().is_empty() {
audit_log(
&mut *tx,
@@ -3061,17 +3088,24 @@ async fn get_script_history(
authed: ApiAuthed,
Extension(user_db): Extension<UserDB>,
Path((w_id, path)): Path<(String, StripPath)>,
Query(pagination): Query<Pagination>,
) -> JsonResult<Vec<ScriptHistory>> {
let path = path.to_path();
check_scopes(&authed, || format!("scripts:read:{}", path))?;
// Unasked-for, this listing stays whole: the deployment-history panels, the restart
// picker and the CLI all read it without paging. The diff picker asks for a page.
let (per_page, offset) = paginate_optional(pagination);
let mut tx = user_db.begin(&authed).await?;
let query_result = sqlx::query!(
"SELECT s.hash as hash, dm.deployment_msg as deployment_msg, s.created_at as created_at
"SELECT s.hash as hash, dm.deployment_msg as deployment_msg, s.created_at as created_at, s.created_by as created_by
FROM script s LEFT JOIN deployment_metadata dm ON s.hash = dm.script_hash
WHERE s.workspace_id = $1 AND s.path = $2
ORDER by s.created_at DESC",
ORDER by s.created_at DESC
LIMIT $3 OFFSET $4",
w_id,
path,
per_page,
offset,
)
.fetch_all(&mut *tx)
.await?;
@@ -3083,6 +3117,7 @@ async fn get_script_history(
script_hash: ScriptHash(row.hash),
deployment_msg: row.deployment_msg,
created_at: Some(row.created_at),
created_by: Some(row.created_by),
})
.collect();
return Ok(Json(result));
@@ -3097,7 +3132,7 @@ async fn get_latest_version(
check_scopes(&authed, || format!("scripts:read:{}", path))?;
let mut tx = user_db.begin(&authed).await?;
let row_o = sqlx::query!(
"SELECT s.hash as hash, dm.deployment_msg as deployment_msg, s.created_at as created_at
"SELECT s.hash as hash, dm.deployment_msg as deployment_msg, s.created_at as created_at, s.created_by as created_by
FROM script s LEFT JOIN deployment_metadata dm ON s.hash = dm.script_hash
WHERE s.workspace_id = $1 AND s.path = $2
ORDER by s.created_at DESC LIMIT 1",
@@ -3113,6 +3148,7 @@ async fn get_latest_version(
script_hash: ScriptHash(row.hash),
deployment_msg: row.deployment_msg,
created_at: Some(row.created_at),
created_by: Some(row.created_by),
};
return Ok(Json(Some(result)));
} else {
+13
View File
@@ -64,6 +64,7 @@ use windmill_common::{
GITHUB_APP_WEBHOOK_BASE_URL_SETTING, HTTP_ROUTE_DEFAULT_ALLOWED_ORIGINS_SETTING,
HTTP_ROUTE_WORKSPACED_ROUTE_SETTING, HUB_ACCESSIBLE_URL_SETTING, HUB_BASE_URL_SETTING,
INSTANCE_BANNER_SETTING, MAX_RETENTION_OVERRIDE_WORKSPACES,
MAX_TOKEN_EXPIRATION_DAYS_SETTING, MCP_DISABLE_TOKEN_QUERY_PARAM_SETTING,
RETENTION_PERIOD_SECS_OVERRIDES_SETTING, RUFF_CONFIG_SETTING, UNIQUE_ID_SETTING,
WORKSPACE_FAIRNESS_DURATION_SECS_SETTING, WORKSPACE_FAIRNESS_ENABLED_SETTING,
WORKSPACE_FAIRNESS_MAX_PERCENT_SETTING, WORKSPACE_FAIRNESS_MIN_TOTAL_SETTING,
@@ -1195,6 +1196,12 @@ async fn run_setting_pre_write_hook(
}
}
}
MAX_TOKEN_EXPIRATION_DAYS_SETTING => {
windmill_common::global_settings::parse_max_token_expiration_days(Some(value))
.map_err(|e| {
error::Error::BadRequest(format!("{MAX_TOKEN_EXPIRATION_DAYS_SETTING}: {e}"))
})?;
}
INSTANCE_BANNER_SETTING => {
match value {
// Clearing (delete row) is handled by the caller; allow it through.
@@ -1356,6 +1363,12 @@ pub async fn get_global_setting(
&& key != HTTP_ROUTE_DEFAULT_ALLOWED_ORIGINS_SETTING
&& key != WS_BASE_URL_SETTING
&& key != INSTANCE_BANNER_SETTING
// The token form reads it to stop offering expirations the server would shorten.
&& key != MAX_TOKEN_EXPIRATION_DAYS_SETTING
// Whoever is wiring up an MCP client reads it to know whether a URL-borne token
// would be refused, and they are usually not a superadmin. Not a secret: pointing
// any MCP client at the instance discovers the same answer.
&& key != MCP_DISABLE_TOKEN_QUERY_PARAM_SETTING
{
require_super_admin(&db, &authed).await?;
}
+127 -22
View File
@@ -47,7 +47,10 @@ use windmill_audit::audit_oss::audit_log;
use windmill_audit::ActionKind;
use windmill_common::audit::AuditAuthor;
use windmill_common::auth::{hash_token, safe_token_prefix, TOKEN_PREFIX_LEN};
use windmill_common::global_settings::AUTOMATE_USERNAME_CREATION_SETTING;
use windmill_common::global_settings::{
load_value_from_global_settings, parse_max_token_expiration_days,
AUTOMATE_USERNAME_CREATION_SETTING, MAX_TOKEN_EXPIRATION_DAYS_SETTING,
};
use windmill_common::oauth2::InstanceEvent;
use windmill_common::per_minute_counter::PerMinuteCounter;
use windmill_common::users::truncate_token;
@@ -168,7 +171,10 @@ pub fn make_unauthed_service() -> Router {
.route("/logout", post(logout).get(logout))
.route("/is_first_time_setup", get(is_first_time_setup))
.route("/request_password_reset", post(request_password_reset))
.route("/login_link/{token}", get(consume_login_link))
.route(
"/login_link/{token}",
get(consume_login_link).post(confirm_login_link),
)
.route("/is_smtp_configured", get(is_smtp_configured))
.route(
"/is_password_login_disabled",
@@ -3092,11 +3098,68 @@ pub async fn create_guest_session_token<'c>(
// create_token_internal is re-exported from windmill-api-auth above
/// Applies the instance-wide ceiling on how long a token a caller picks the lifetime of may
/// live (`create_token`, and `impersonate` for superadmins), returning the expiration to store:
/// the requested one while it fits, the ceiling otherwise, and the ceiling as well when none was
/// requested. Only the stored expiration is capped: tokens already stored when the setting is
/// turned on or lowered keep theirs, since the auth lookup never reads the setting.
///
/// It shortens rather than refuses because most callers do not comply on their own. The CLI
/// authorization page, `wmill user create-token` and the editor's language-server token each
/// pick a lifetime, often none at all, without reading the setting (and CLIs already installed
/// never will), so refusing would break logging in and the editor instead of the long-lived
/// tokens the setting is aimed at.
///
/// Read from `global_settings` on each call rather than cached: token creation is rare
/// enough that the round trip costs nothing, and the ceiling is then never served stale.
///
/// A token owned by a service account is exempt: in the workspace the token names, or in any
/// workspace for a workspace-less token, which has none to match. Service accounts are the
/// identity automation that needs a long-lived credential runs as. The cost is that any
/// workspace admin can create and impersonate one to hold an uncapped token, so the ceiling
/// bounds personal tokens rather than what an admin can obtain.
async fn cap_token_expiration(
db: &DB,
owner_email: &str,
workspace_id: Option<&str>,
requested: Option<chrono::DateTime<chrono::Utc>>,
) -> Result<Option<chrono::DateTime<chrono::Utc>>> {
let value = load_value_from_global_settings(db, MAX_TOKEN_EXPIRATION_DAYS_SETTING).await?;
let max_days = match parse_max_token_expiration_days(value.as_ref()) {
Ok(Some(max_days)) => max_days,
Ok(None) => return Ok(requested),
// Both write paths reject this, so only a row written around them gets here.
Err(e) => {
tracing::warn!("ignoring {MAX_TOKEN_EXPIRATION_DAYS_SETTING}: {e}");
return Ok(requested);
}
};
let max = chrono::Utc::now() + chrono::Duration::days(max_days);
let is_service_account = sqlx::query_scalar!(
"SELECT EXISTS(SELECT 1 FROM usr WHERE email = $1 AND is_service_account IS true
AND ($2::varchar IS NULL OR workspace_id = $2))",
owner_email,
workspace_id,
)
.fetch_one(db)
.await?
.unwrap_or(false);
if is_service_account {
return Ok(requested);
}
Ok(Some(match requested {
Some(expiration) if expiration < max => expiration,
_ => max,
}))
}
async fn create_token(
Extension(db): Extension<DB>,
authed: ApiAuthed,
OptJobAuthed { job_id, .. }: OptJobAuthed,
Json(token_config): Json<NewToken>,
Json(mut token_config): Json<NewToken>,
) -> Result<(StatusCode, String)> {
forbid_elevated_job_token(&db, &authed.email, job_id).await?;
check_token_create_rate_limit(&authed.username)?;
@@ -3118,6 +3181,14 @@ async fn create_token(
windmill_api_auth::ensure_scopes_within_caller(&authed, token_config.scopes.as_deref())?;
token_config.expiration = cap_token_expiration(
&db,
&authed.email,
token_config.workspace_id.as_deref(),
token_config.expiration,
)
.await?;
let mut tx = db.begin().await?;
let token = create_token_internal(&mut *tx, &db, &authed, token_config).await?;
@@ -3174,6 +3245,7 @@ async fn impersonate(
.fetch_optional(&db)
.await?
.unwrap_or(false);
let expiration = cap_token_expiration(&db, &impersonated, None, new_token.expiration).await?;
let mut tx = db.begin().await?;
sqlx::query!(
@@ -3185,7 +3257,7 @@ async fn impersonate(
plaintext as Option<&str>,
impersonated,
new_token.label,
new_token.expiration,
expiration,
is_super_admin
)
.execute(&mut *tx)
@@ -3195,7 +3267,7 @@ async fn impersonate(
&mut *tx,
&t_hash,
new_token.label.as_deref(),
new_token.expiration,
expiration,
)
.await;
@@ -3215,9 +3287,12 @@ async fn impersonate(
}
const LOGIN_LINK_DEFAULT_TTL_S: u32 = 600;
const LOGIN_LINK_MAX_TTL_S: u32 = 900;
// Long enough for a link sent by email to still work when it is read. `require_login_type` is
// only checked at mint, so a much longer cap would need re-checking it when the link is opened.
const LOGIN_LINK_MAX_TTL_S: u32 = 7200;
const LOGIN_LINK_DEFAULT_RD: &str = "/user/workspaces";
const LOGIN_LINK_EXPIRED_PAGE: &str = "/user/login_link_expired";
const LOGIN_LINK_CONFIRM_PAGE: &str = "/user/login_link";
#[derive(Deserialize)]
pub struct NewLoginLink {
@@ -3228,6 +3303,9 @@ pub struct NewLoginLink {
/// account it created can require `pending_oauth`, so the link stops working once the
/// owner has set a password or signed in with a provider.
pub require_login_type: Option<String>,
/// Hand out a page that signs in only when its button is clicked. Mail scanners open links
/// on delivery, and opening the plain link spends it, so a link sent by email sets this.
pub confirm: Option<bool>,
}
#[derive(Serialize)]
@@ -3378,11 +3456,12 @@ async fn create_login_link(
.await?;
tx.commit().await?;
let url = format!(
"{}/api/auth/login_link/{}",
(**BASE_URL.load()).clone(),
token
);
let base_url = (**BASE_URL.load()).clone();
let url = if nl.confirm.unwrap_or(false) {
format!("{base_url}{LOGIN_LINK_CONFIRM_PAGE}?token={token}")
} else {
format!("{base_url}/api/auth/login_link/{token}")
};
Ok((StatusCode::CREATED, Json(LoginLink { url, expires_at })))
}
@@ -3628,19 +3707,45 @@ async fn consume_login_link(
Path(token): Path<String>,
Query(query): Query<LoginLinkQuery>,
) -> Result<Response> {
let bounce = |reason: &str| {
Ok(login_link_redirect(format!(
"{LOGIN_LINK_EXPIRED_PAGE}?reason={reason}"
)))
};
let location = redeem_login_link(&headers, cookies, &db, &token, query.rd).await?;
Ok(login_link_redirect(location))
}
#[derive(Serialize)]
struct LoginLinkLocation {
location: String,
}
/// The confirmation page's click. It answers with where to go rather than redirecting, and the
/// page navigates there itself.
async fn confirm_login_link(
headers: axum::http::HeaderMap,
cookies: Cookies,
Extension(db): Extension<DB>,
Path(token): Path<String>,
) -> JsonResult<LoginLinkLocation> {
let location = redeem_login_link(&headers, cookies, &db, &token, None).await?;
Ok(Json(LoginLinkLocation { location }))
}
/// Spends the link and sets the session cookie, returning the post-login destination; or
/// returns the explanation page, with no session, when the link cannot be used.
async fn redeem_login_link(
headers: &axum::http::HeaderMap,
cookies: Cookies,
db: &DB,
token: &str,
requested_rd: Option<String>,
) -> Result<String> {
let bounce = |reason: &str| Ok(format!("{LOGIN_LINK_EXPIRED_PAGE}?reason={reason}"));
if token.len() != 32 {
return bounce("invalid");
}
let t_hash = hash_token(&token);
let t_hash = hash_token(token);
// The account is unknown until the row is read, so only the global and per-IP tiers
// apply here; a 32-char random token leaves nothing for the per-account tier to guard.
windmill_common::login_rate_limit::check_and_increment_login_attempt(
&headers,
headers,
&t_hash[..TOKEN_PREFIX_LEN],
)?;
@@ -3707,11 +3812,10 @@ async fn consume_login_link(
.await?;
tx.commit().await?;
let rd = link
Ok(link
.rd
.or_else(|| same_origin_rd(query.rd))
.unwrap_or_else(|| LOGIN_LINK_DEFAULT_RD.to_string());
Ok(login_link_redirect(rd))
.or_else(|| same_origin_rd(requested_rd))
.unwrap_or_else(|| LOGIN_LINK_DEFAULT_RD.to_string()))
}
#[derive(Deserialize)]
@@ -3945,6 +4049,7 @@ async fn update_token_label(
AND NOT starts_with(label, 'embed_app:')
AND NOT starts_with(label, 'sdk_app:')
AND NOT starts_with(label, 'impersonation:')
AND NOT starts_with(label, 'cli-login:')
))
RETURNING token_prefix",
req.label.as_deref(),
+296 -25
View File
@@ -38,7 +38,7 @@ use windmill_common::global_settings::HTTP_ROUTE_WORKSPACED_ROUTE;
use windmill_common::query_builders::{render_db_quoted_identifier, DbType};
use windmill_common::users::username_to_permissioned_as;
use windmill_common::variables::{
build_crypt, decrypt, encrypt, SECRET_SALT, WORKSPACE_CRYPT_CACHE,
crypt_from_key_with_suffix, decrypt, encrypt, WORKSPACE_CRYPT_CACHE,
};
use windmill_common::worker::{to_raw_value, CLOUD_HOSTED};
use windmill_common::workspaces::GitRepositorySettings;
@@ -155,6 +155,10 @@ pub fn workspaced_service() -> Router {
.route("/edit_deploy_ui_config", post(edit_deploy_ui_config))
.route("/edit_default_app", post(edit_default_app))
.route("/edit_guest_access", post(edit_guest_access))
.route(
"/edit_add_admins_and_developers_to_forks",
post(edit_add_admins_and_developers_to_forks),
)
.route("/edit_guest_jwt_key", post(edit_guest_jwt_key))
.route("/guest_usage", get(get_guest_usage))
.route("/default_app", get(get_default_app))
@@ -338,6 +342,7 @@ pub struct WorkspaceSettings {
pub guest_jwt_public_key: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub guest_jwt_jwks_url: Option<String>,
pub add_admins_and_developers_to_forks: bool,
}
/// Subset of `WorkspaceSettings` that is safe to return to any workspace
@@ -363,6 +368,8 @@ pub struct WorkspacePublicSettings {
/// Not sensitive, and the app editor needs it to say whether the guest rung is
/// live -- an app can be set to `guest` while the workspace has guests off.
pub guest_access_enabled: bool,
/// Read by the fork dialog, which tells the forker who else the fork will include.
pub add_admins_and_developers_to_forks: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub deploy_ui: Option<serde_json::Value>,
#[serde(skip_serializing_if = "Option::is_none")]
@@ -555,6 +562,7 @@ struct UserWorkspace {
/// screen off this.
pub created_by: Option<String>,
pub disabled: bool,
pub is_service_account: bool,
}
#[derive(Deserialize)]
@@ -1125,7 +1133,8 @@ async fn get_settings(
error_handler_fallback_to_instance_alerts,
guest_access_enabled,
guest_jwt_public_key,
guest_jwt_jwks_url
guest_jwt_jwks_url,
add_admins_and_developers_to_forks
FROM
workspace_settings
WHERE
@@ -1167,6 +1176,7 @@ async fn get_public_settings(
teams_team_guid,
mute_critical_alerts,
guest_access_enabled,
add_admins_and_developers_to_forks,
deploy_ui,
large_file_storage,
datatable
@@ -5051,6 +5061,47 @@ async fn edit_guest_access(
))
}
#[derive(Deserialize)]
struct EditAddAdminsAndDevelopersToForks {
add_admins_and_developers_to_forks: bool,
}
async fn edit_add_admins_and_developers_to_forks(
authed: ApiAuthed,
Extension(db): Extension<DB>,
Path(w_id): Path<String>,
Json(EditAddAdminsAndDevelopersToForks { add_admins_and_developers_to_forks }): Json<
EditAddAdminsAndDevelopersToForks,
>,
) -> Result<String> {
require_admin(authed.is_admin, &authed.username)?;
let mut tx = db.begin().await?;
sqlx::query!(
"UPDATE workspace_settings SET add_admins_and_developers_to_forks = $1 WHERE workspace_id = $2",
add_admins_and_developers_to_forks,
&w_id
)
.execute(&mut *tx)
.await?;
audit_log(
&mut *tx,
&authed,
"workspaces.edit_add_admins_and_developers_to_forks",
ActionKind::Update,
&w_id,
Some(&add_admins_and_developers_to_forks.to_string()),
None,
)
.await?;
tx.commit().await?;
Ok(format!(
"Adding admins and developers to new forks set to {add_admins_and_developers_to_forks} for workspace {w_id}"
))
}
#[derive(Deserialize)]
struct EditGuestJwtKey {
/// A PEM public key (RS or ES family), or a JWKS URL, at most one. Both empty clears the
@@ -5639,9 +5690,6 @@ async fn set_encryption_key(
));
}
// Build the previous cipher before the transaction (reads from cache/pool)
let previous_encryption_key = build_crypt(&db, w_id.as_str()).await?;
let mut tx = db.begin().await?;
// Under the row's lock, so two rotations racing serialize and each sees the key the
@@ -5675,17 +5723,14 @@ async fn set_encryption_key(
None
};
// From the keys read and written under the lock, never from `build_crypt`: its
// cache can still hold a key an earlier rotation replaced, and the git-sync
// secrets below are skipped rather than failed when they do not decrypt.
let previous_encryption_key = crypt_from_key_with_suffix(&previous_key, "");
let new_encryption_key = crypt_from_key_with_suffix(&request.new_key, "");
let mut reencrypted_secret_paths: Vec<String> = Vec::new();
if !request.skip_reencrypt.unwrap_or(false) {
// Build the new cipher directly from the key string, since the transaction
// hasn't committed yet and build_crypt() would read the old key from the pool.
let crypt_key = if let Some(ref salt) = SECRET_SALT.as_ref() {
format!("{}{}", request.new_key, salt)
} else {
request.new_key.clone()
};
let new_encryption_key = magic_crypt::new_magic_crypt!(crypt_key, 256);
let mut truncated_new_key = request.new_key.clone();
truncated_new_key.truncate(8);
tracing::warn!(
@@ -5725,6 +5770,14 @@ async fn set_encryption_key(
}
}
reencrypt_git_sync_secrets(
&mut tx,
&w_id,
&previous_encryption_key,
&new_encryption_key,
)
.await?;
tx.commit().await?;
// Invalidate the cache only after the transaction has committed
@@ -5762,6 +5815,64 @@ async fn set_encryption_key(
return Ok(());
}
/// Move the git-sync secrets the server keeps under the workspace key (stored
/// repository tokens, webhook secrets) to the new key. They are never synced, so
/// unlike variables they are still under the old key when the caller skips
/// re-encryption.
async fn reencrypt_git_sync_secrets(
conn: &mut sqlx::PgConnection,
w_id: &str,
old: &magic_crypt::MagicCrypt256,
new: &magic_crypt::MagicCrypt256,
) -> Result<()> {
let Some((mut credentials, mut git_sync)) =
sqlx::query_as::<_, (serde_json::Value, Option<serde_json::Value>)>(
"SELECT git_credentials, git_sync FROM workspace_settings WHERE workspace_id = $1 FOR UPDATE",
)
.bind(w_id)
.fetch_optional(&mut *conn)
.await?
else {
return Ok(());
};
let reencrypt = |value: &mut serde_json::Value| {
let Some(ciphertext) = value.as_str() else {
return;
};
match decrypt(old, ciphertext.to_string()) {
Ok(plain) => *value = serde_json::Value::String(encrypt(new, &plain)),
// Left by an earlier rotation and unrecoverable either way; failing here
// would block every later rotation of the workspace.
Err(e) => tracing::warn!(
"a git-sync secret of workspace {w_id} does not decrypt under its current key, leaving it as is: {e}"
),
}
};
for entry in credentials.as_array_mut().into_iter().flatten() {
if let Some(token) = entry.get_mut("token") {
reencrypt(token);
}
}
let repositories = git_sync
.as_mut()
.and_then(|g| g.get_mut("repositories"))
.and_then(|r| r.as_array_mut());
for repo in repositories.into_iter().flatten() {
if let Some(secret) = repo.pointer_mut("/auto_pull/webhook_secret") {
reencrypt(secret);
}
}
sqlx::query(
"UPDATE workspace_settings SET git_credentials = $2, git_sync = $3 WHERE workspace_id = $1",
)
.bind(w_id)
.bind(credentials)
.bind(git_sync)
.execute(&mut *conn)
.await?;
Ok(())
}
#[derive(Serialize)]
struct UsedTriggers {
pub websocket_used: bool,
@@ -5890,7 +6001,7 @@ async fn user_workspaces(
workspace.is_dev_workspace, workspace.dev_workspace_label,
workspace.owner AS \"created_by?\",
CASE WHEN usr.operator THEN workspace_settings.operator_settings ELSE NULL END as operator_settings,
usr.disabled
usr.disabled, usr.is_service_account
FROM workspace
JOIN usr ON usr.workspace_id = workspace.id
JOIN workspace_settings ON workspace_settings.workspace_id = workspace.id
@@ -6711,7 +6822,8 @@ async fn update_workspace_settings(
ducklake = source_ws.ducklake,
dbt_warehouses = source_ws.dbt_warehouses,
datatable = source_ws.datatable,
git_app_installations = source_ws.git_app_installations
git_app_installations = source_ws.git_app_installations,
add_admins_and_developers_to_forks = source_ws.add_admins_and_developers_to_forks
FROM workspace_settings source_ws
WHERE source_ws.workspace_id = $1
AND workspace_settings.workspace_id = $2
@@ -6852,14 +6964,21 @@ async fn copy_workspace_members(
tx: &mut Transaction<'_, Postgres>,
source_workspace_id: &str,
target_workspace_id: &str,
admins_and_developers_only: bool,
) -> Result<()> {
// Admins and developers join as manual members: the fork does not inherit the source's
// instance-group config, so a copied `instance_group` provenance would let the fork's
// reconciliation delete them and their data.
sqlx::query!(
"INSERT INTO usr (workspace_id, username, email, is_admin, created_at, operator, disabled, role, is_service_account, added_via)
SELECT $1, username, email, is_admin, created_at, operator, disabled, role, is_service_account, added_via
SELECT $1, username, email, is_admin, created_at, operator, disabled, role, is_service_account,
CASE WHEN $3 THEN NULL ELSE added_via END
FROM usr WHERE workspace_id = $2
AND (NOT $3 OR (NOT operator AND NOT disabled AND NOT is_service_account))
ON CONFLICT DO NOTHING",
target_workspace_id,
source_workspace_id,
admins_and_developers_only,
)
.execute(&mut **tx)
.await?;
@@ -7276,6 +7395,127 @@ async fn clear_orphaned_compat_address(
Ok(())
}
/// SQL boolean: the principal the `principal` expression yields resolves in the workspace bound as
/// `$1`. The same predicate `clone_scripts` and `clone_flows` inline, whose `query!` macros cannot
/// take a composed string, so keep the three in step.
fn principal_resolves_sql(principal: &str) -> String {
format!(
"CASE WHEN {principal} LIKE 'u/%' THEN EXISTS (
SELECT 1 FROM usr u WHERE u.workspace_id = $1
AND u.username = substring({principal} from 3)
UNION ALL
SELECT 1 FROM password pw WHERE pw.super_admin
AND (pw.username = substring({principal} from 3)
OR pw.email = substring({principal} from 3)))
WHEN {principal} LIKE 'g/%' THEN EXISTS (
SELECT 1 FROM group_ g WHERE g.workspace_id = $1
AND g.name = substring({principal} from 3))
ELSE EXISTS (
SELECT 1 FROM usr u WHERE u.workspace_id = $1 AND u.username = {principal}
UNION ALL
SELECT 1 FROM password pw WHERE pw.email = {principal} AND pw.super_admin)
END"
)
}
/// Re-point the identities a fork clones verbatim at its creator when they name nobody in the fork,
/// once its membership is final so copied members keep theirs. Unlike scripts and flows these
/// cannot drop the identity: an app deploy rejects a preserved one that does not resolve, and
/// publisher apps, schedules and triggers need one to run.
async fn repoint_unresolvable_cloned_identities(
tx: &mut Transaction<'_, Postgres>,
target_workspace_id: &str,
authed: &ApiAuthed,
) -> Result<()> {
let principal = username_to_permissioned_as(&authed.username);
sqlx::query(&format!(
"UPDATE app SET policy = policy
|| jsonb_build_object('on_behalf_of', $2::text, 'on_behalf_of_email', $3::text)
WHERE workspace_id = $1 AND policy->>'on_behalf_of' IS NOT NULL
AND NOT ({})",
principal_resolves_sql("(policy->>'on_behalf_of')")
))
.bind(target_workspace_id)
.bind(&principal)
.bind(&authed.email)
.execute(&mut **tx)
.await?;
// A draft holding a genuine NUL escape (the rule of `json_text_has_nul_escape`) is left as it
// is, since parsing it would abort the fork. The check must stay in a CASE: json `->>` raises on
// a NUL anywhere in the value, and Postgres reorders plain AND conditions.
let nul_escape = r"(^|[^\\])(\\\\)*\\u0000";
sqlx::query(&format!(
"UPDATE draft SET value = to_json(jsonb_set(jsonb_set(to_jsonb(value),
ARRAY['policy', 'on_behalf_of'], to_jsonb($2::text)),
ARRAY['policy', 'on_behalf_of_email'], to_jsonb($3::text)))
WHERE workspace_id = $1 AND typ IN ('app', 'raw_app')
AND CASE WHEN value::text ~ $4 THEN false
ELSE value->'policy'->>'on_behalf_of' IS NOT NULL AND NOT ({}) END",
principal_resolves_sql("(value->'policy'->>'on_behalf_of')")
))
.bind(target_workspace_id)
.bind(&principal)
.bind(&authed.email)
.bind(nul_escape)
.execute(&mut **tx)
.await?;
sqlx::query(&format!(
"UPDATE draft SET value = to_json(jsonb_set(to_jsonb(value),
ARRAY['permissioned_as'], to_jsonb($2::text)))
WHERE workspace_id = $1 AND starts_with(typ::text, 'trigger_')
AND CASE WHEN value::text ~ $3 THEN false
ELSE value->>'permissioned_as' IS NOT NULL AND NOT ({}) END",
principal_resolves_sql("(value->>'permissioned_as')")
))
.bind(target_workspace_id)
.bind(&principal)
.bind(nul_escape)
.execute(&mut **tx)
.await?;
let column_resolves = principal_resolves_sql("permissioned_as");
// SAFETY: every table name is a literal from this list, never user input.
for table in [
"http_trigger",
"websocket_trigger",
"kafka_trigger",
"nats_trigger",
"postgres_trigger",
"mqtt_trigger",
"amqp_trigger",
"sqs_trigger",
"gcp_trigger",
"azure_trigger",
"email_trigger",
] {
sqlx::query(&format!(
"UPDATE {table} SET permissioned_as = $2
WHERE workspace_id = $1 AND NOT ({column_resolves})"
))
.bind(target_workspace_id)
.bind(&principal)
.execute(&mut **tx)
.await?;
}
// `email` is still written for workers that predate `permissioned_as`.
sqlx::query(&format!(
"UPDATE schedule SET permissioned_as = $2, email = $3
WHERE workspace_id = $1 AND NOT ({column_resolves})"
))
.bind(target_workspace_id)
.bind(&principal)
.bind(&authed.email)
.execute(&mut **tx)
.await?;
Ok(())
}
/// Carries over the recorded principal under the rule spelled out on [`clone_scripts`].
async fn clone_flows(
tx: &mut Transaction<'_, Postgres>,
@@ -7721,14 +7961,32 @@ async fn clone_drafts(
// filtered like `clone_scripts`: the address the draft still carries re-derives the
// clone's own principal at deploy time, which is the more accurate answer of the two.
sqlx::query!(
"INSERT INTO draft (workspace_id, path, typ, value, created_at, email)
// A script hash is content-addressed and copied as-is, so a script draft's base
// still names a version the clone has. `clone_flows` / `clone_apps` mint new ids,
// so those drafts arrive with no base (staleness falls back to the timestamps),
// lineage field included, or the next autosave would re-derive the source id.
//
// `clean` is `strip_json_nul`'s parity rule in SQL, so a pre-sanitizer U+0000
// escape cannot abort the clone on `to_jsonb` or arrive with its principal
// unstripped: escaped backslashes park on chr(1) (lossless, a `json` value's text
// cannot hold a raw control byte) so only a real NUL is removed, and chr(92)
// spells the backslash so no escape sequence reaches this source file.
r#"INSERT INTO draft (workspace_id, path, typ, value, created_at, email, base)
SELECT $2, path, typ,
CASE WHEN typ IN ('script', 'flow')
THEN to_json(to_jsonb(value) - 'on_behalf_of')
ELSE value END,
created_at, email
FROM draft
WHERE workspace_id = $1 AND (email = $3 OR email IS NULL)",
to_json(
CASE WHEN typ IN ('script', 'flow') THEN clean - 'on_behalf_of' ELSE clean END
- CASE WHEN typ = 'flow' THEN 'version_id'
WHEN typ IN ('app', 'raw_app') THEN 'parent_version'
ELSE '' END
),
created_at, email,
CASE WHEN typ = 'script' THEN base END
FROM (
SELECT d.path, d.typ, d.created_at, d.email, d.base,
replace(replace(replace(d.value::text, chr(92) || chr(92), chr(1)), chr(92) || 'u0000', ''), chr(1), chr(92) || chr(92))::jsonb AS clean
FROM draft d
WHERE d.workspace_id = $1 AND (d.email = $3 OR d.email IS NULL)
) s"#,
source_workspace_id,
target_workspace_id,
authed_email,
@@ -8529,8 +8787,19 @@ async fn create_workspace_fork(
// intended. Dev creation is already admin-gated, so this is transitively admin-only too. Done before
// the explicit creator insert below so the creator (a parent member) is copied with full metadata
// (operator/role/is_service_account/added_via), not the bare row the insert alone would leave.
// Independently, the parent's admins can have every fork of it start with its admins and
// developers; the forker cannot opt out, since the point is that those admins can review it.
if nw.copy_members && nw.is_dev_workspace {
copy_workspace_members(&mut tx, &parent_workspace_id, &forked_id).await?;
copy_workspace_members(&mut tx, &parent_workspace_id, &forked_id, false).await?;
} else if sqlx::query_scalar!(
"SELECT add_admins_and_developers_to_forks FROM workspace_settings WHERE workspace_id = $1",
parent_workspace_id
)
.fetch_optional(&mut *tx)
.await?
.unwrap_or(false)
{
copy_workspace_members(&mut tx, &parent_workspace_id, &forked_id, true).await?;
}
// Ensure the creator is a member of the fork even without copy_members (or if they aren't a parent
@@ -8592,6 +8861,8 @@ async fn create_workspace_fork(
// re-enables in the fork, with parent-conflict warnings on enable.
clone_triggers_and_schedules(&mut tx, &parent_workspace_id, &forked_id).await?;
repoint_unresolvable_cloned_identities(&mut tx, &forked_id, &authed).await?;
// Update forked datatable settings to point to new databases
for fdt in &nw.forked_datatables {
apply_forked_datatable(&db, &mut tx, &authed, &parent_workspace_id, &forked_id, fdt)
@@ -113,7 +113,7 @@ pub(crate) async fn change_workspace_id(
// Duplicate workspace settings (keep copy in old workspace for reference)
info!("Duplicating workspace_settings table");
sqlx::query!(
"INSERT INTO workspace_settings (workspace_id, slack_team_id, slack_name, slack_command_script, slack_email, customer_id, plan, webhook, ai_config, large_file_storage, git_sync, default_app, default_scripts, deploy_ui, mute_critical_alerts, color, operator_settings, teams_command_script, teams_team_id, teams_team_name, git_app_installations, git_credentials, ducklake, dbt_warehouses, slack_oauth_client_id, slack_oauth_client_secret, datatable, teams_team_guid, auto_invite, error_handler, success_handler, public_app_execution_limit_per_minute, error_handler_fallback_to_instance_alerts, guest_access_enabled, guest_jwt_public_key, guest_jwt_jwks_url) SELECT $1, slack_team_id, slack_name, slack_command_script, slack_email, customer_id, plan, webhook, ai_config, large_file_storage, git_sync, default_app, default_scripts, deploy_ui, mute_critical_alerts, color, operator_settings, teams_command_script, teams_team_id, teams_team_name, git_app_installations, git_credentials, ducklake, dbt_warehouses, slack_oauth_client_id, slack_oauth_client_secret, datatable, teams_team_guid, auto_invite, error_handler, success_handler, public_app_execution_limit_per_minute, error_handler_fallback_to_instance_alerts, guest_access_enabled, guest_jwt_public_key, guest_jwt_jwks_url FROM workspace_settings WHERE workspace_id = $2",
"INSERT INTO workspace_settings (workspace_id, slack_team_id, slack_name, slack_command_script, slack_email, customer_id, plan, webhook, ai_config, large_file_storage, git_sync, default_app, default_scripts, deploy_ui, mute_critical_alerts, color, operator_settings, teams_command_script, teams_team_id, teams_team_name, git_app_installations, git_credentials, ducklake, dbt_warehouses, slack_oauth_client_id, slack_oauth_client_secret, datatable, teams_team_guid, auto_invite, error_handler, success_handler, public_app_execution_limit_per_minute, error_handler_fallback_to_instance_alerts, guest_access_enabled, guest_jwt_public_key, guest_jwt_jwks_url, add_admins_and_developers_to_forks) SELECT $1, slack_team_id, slack_name, slack_command_script, slack_email, customer_id, plan, webhook, ai_config, large_file_storage, git_sync, default_app, default_scripts, deploy_ui, mute_critical_alerts, color, operator_settings, teams_command_script, teams_team_id, teams_team_name, git_app_installations, git_credentials, ducklake, dbt_warehouses, slack_oauth_client_id, slack_oauth_client_secret, datatable, teams_team_guid, auto_invite, error_handler, success_handler, public_app_execution_limit_per_minute, error_handler_fallback_to_instance_alerts, guest_access_enabled, guest_jwt_public_key, guest_jwt_jwks_url, add_admins_and_developers_to_forks FROM workspace_settings WHERE workspace_id = $2",
&rw.new_id,
&old_id
)
@@ -382,6 +382,13 @@ pub(crate) async fn change_workspace_id(
)
.execute(&mut *tx)
.await?;
sqlx::query!(
"UPDATE draft_move SET workspace_id = $1 WHERE workspace_id = $2",
&rw.new_id,
&old_id
)
.execute(&mut *tx)
.await?;
info!("Updating favorite table");
sqlx::query!(
+161 -8
View File
@@ -1,7 +1,7 @@
openapi: "3.0.3"
info:
version: 1.814.0
version: 1.815.0
title: Windmill API
contact:
@@ -529,6 +529,30 @@ paths:
responses:
"302":
description: redirected to the post-login destination, or to /user/login_link_expired when the link is used, expired or unknown
post:
security: []
summary: consume a single-use login link from its confirmation page, set the session cookie and answer where to go
operationId: confirmLoginLink
tags:
- user
parameters:
- name: token
in: path
required: true
schema:
type: string
responses:
"200":
description: the post-login destination, or /user/login_link_expired when the link is used, expired or unknown
content:
application/json:
schema:
type: object
required:
- location
properties:
location:
type: string
/auth/reset_password:
post:
@@ -4066,9 +4090,13 @@ paths:
guest_access_enabled:
type: boolean
description: Whether this workspace admits guest sessions. An app's own `guest` execution mode is inert while this is false.
add_admins_and_developers_to_forks:
type: boolean
description: Whether every new fork of this workspace starts with its admins and developers as members, keeping their role.
required:
- workspace_id
- guest_access_enabled
- add_admins_and_developers_to_forks
/w/{workspace}/workspaces/get_settings:
get:
@@ -4159,6 +4187,9 @@ paths:
guest_jwt_jwks_url:
type: string
description: JWKS URL a guest JWT (`jwt_guest_`) is verified against for this workspace. Mutually exclusive with `guest_jwt_public_key`.
add_admins_and_developers_to_forks:
type: boolean
description: Whether every new fork of this workspace starts with its admins and developers as members, keeping their role.
/w/{workspace}/workspaces/get_deploy_to:
get:
@@ -6289,6 +6320,39 @@ paths:
schema:
type: string
/w/{workspace}/workspaces/edit_add_admins_and_developers_to_forks:
post:
summary: choose whether new forks of this workspace start with its admins and developers
description: >-
When on, every fork created from this workspace gets the workspace's admins and
developers as members, with the role they hold here; operators, disabled users and
service accounts are left out. The setting is copied into each fork, so forks of a
fork follow it too. Off by default. Workspace-admin gated.
operationId: editAddAdminsAndDevelopersToForks
tags:
- workspace
parameters:
- $ref: "#/components/parameters/WorkspaceId"
requestBody:
description: Whether new forks start with this workspace's admins and developers
required: true
content:
application/json:
schema:
type: object
properties:
add_admins_and_developers_to_forks:
type: boolean
required:
- add_admins_and_developers_to_forks
responses:
"200":
description: status
content:
text/plain:
schema:
type: string
/w/{workspace}/workspaces/edit_guest_jwt_key:
post:
summary: set the key guest JWTs are verified against for this workspace
@@ -6753,7 +6817,7 @@ paths:
type: string
expires_in_s:
type: integer
description: link lifetime in seconds, at most 900 (default 600)
description: link lifetime in seconds, at most 7200 (default 600)
rd:
type: string
description: same-origin path the browser lands on after login (default /user/workspaces)
@@ -6763,6 +6827,12 @@ paths:
mint only while the account still has this login type (for example
pending_oauth), so a link stops working once the owner has set a password
or signed in with a provider
confirm:
type: boolean
description: >-
return a /user/login_link page that signs in only when its button is
clicked, instead of a link spent by opening it; set it for links sent by
email, which mail scanners open on delivery (default false)
responses:
"201":
description: login link minted
@@ -10322,8 +10392,49 @@ paths:
current_timestamp:
type: string
format: date-time
path:
type: string
description: "`saved` only, upsert or delete: where the write landed. Differs from the URL path when the item had moved away from it; the editor follows it there. Absent when a delete found nothing to remove and the caller cannot read the path it moved to."
required: [status, current_timestamp]
/w/{workspace}/drafts/move/{kind}/{path}:
post:
summary: move the current user's draft-only item to another path
description: Relocates the authed user's own draft row, along with both path keys inside its value (the typed path and the mirror the editors keep beside it). Only for draft-only items — a deployed item must be moved through its own deploy endpoint, which carries every draft with it. Restricted to script, flow, app and raw_app; any other kind is rejected with 400, because only these keep their deploy target where this endpoint rewrites it.
operationId: moveDraft
tags:
- draft
parameters:
- $ref: "#/components/parameters/WorkspaceId"
- name: kind
in: path
required: true
description: script, flow, app or raw_app only.
schema:
type: string
enum: [script, flow, app, raw_app]
- $ref: "#/components/parameters/ScriptPath"
requestBody:
required: true
content:
application/json:
schema:
type: object
properties:
new_path:
type: string
summary:
type: string
description: Also restate the draft's summary.
required: [new_path]
responses:
"200":
description: move result
content:
text/plain:
schema:
type: string
/w/{workspace}/drafts/migrate_legacy/{kind}/{path}:
post:
summary: resolve a legacy (workspace-level) draft (admin only)
@@ -10834,6 +10945,8 @@ paths:
parameters:
- $ref: "#/components/parameters/WorkspaceId"
- $ref: "#/components/parameters/ScriptPath"
- $ref: "#/components/parameters/Page"
- $ref: "#/components/parameters/PerPage"
responses:
"200":
description: script history
@@ -12239,6 +12352,8 @@ paths:
parameters:
- $ref: "#/components/parameters/WorkspaceId"
- $ref: "#/components/parameters/ScriptPath"
- $ref: "#/components/parameters/Page"
- $ref: "#/components/parameters/PerPage"
tags:
- flow
responses:
@@ -14117,6 +14232,8 @@ paths:
parameters:
- $ref: "#/components/parameters/WorkspaceId"
- $ref: "#/components/parameters/ScriptPath"
- $ref: "#/components/parameters/Page"
- $ref: "#/components/parameters/PerPage"
responses:
"200":
description: app history
@@ -14384,9 +14501,9 @@ paths:
"200":
description: app updated
content:
text/plain:
application/json:
schema:
type: string
$ref: "#/components/schemas/AppDeployed"
/w/{workspace}/apps/create_raw_source:
post:
@@ -14526,9 +14643,9 @@ paths:
"200":
description: app updated
content:
text/plain:
application/json:
schema:
type: string
$ref: "#/components/schemas/AppDeployed"
/w/{workspace}/apps/update_raw/{path}:
post:
@@ -14582,9 +14699,9 @@ paths:
"200":
description: app updated
content:
text/plain:
application/json:
schema:
type: string
$ref: "#/components/schemas/AppDeployed"
/w/{workspace}/apps/custom_path_exists/{custom_path}:
get:
@@ -27632,6 +27749,13 @@ components:
draft_saved_at:
type: string
format: date-time
draft_base:
type: string
description: |
The deployed version the draft forked from, as text whatever the
kind (script hash, flow version id, app version id). Compare to the
deployed head to tell a draft that is behind. Absent when there is
no draft or it was never forked from a deploy.
no_deployed:
type: boolean
draft:
@@ -29527,6 +29651,11 @@ components:
type: string
deployment_msg:
type: string
created_at:
type: string
format: date-time
created_by:
type: string
required:
- script_hash
@@ -34731,6 +34860,9 @@ components:
nullable: true
disabled:
type: boolean
is_service_account:
type: boolean
description: Whether this membership is a service account.
required:
- id
- name
@@ -35420,6 +35552,20 @@ components:
- raw_app
AppDeployed:
type: object
description: "What a deploy of an existing app answers with. `version` is the one this call wrote, which is what an editor pins as the fork base of the draft it starts next: reading the head back afterwards cannot tell it from a deploy that landed beside it. A metadata-only update writes none and reports the head it kept."
properties:
path:
type: string
description: Where the app now lives, which differs from the request path on a rename.
version:
type: integer
format: int64
required:
- path
- version
AppHistory:
type: object
properties:
@@ -35427,6 +35573,11 @@ components:
type: integer
deployment_msg:
type: string
created_at:
type: string
format: date-time
created_by:
type: string
required:
- version
@@ -35496,6 +35647,8 @@ components:
format: date-time
deployment_msg:
type: string
created_by:
type: string
required:
- id
- created_at
+91 -17
View File
@@ -69,8 +69,8 @@ use windmill_common::{
user_drafts::{overlay_or_draft_only, DraftUserRef, UserDraftItemKind, WithDraftOverlay},
users::username_to_permissioned_as,
utils::{
http_get_from_hub, not_found_if_none, paginate, query_elems_from_hub, require_admin,
strip_json_nul, Pagination, RunnableKind, StripPath,
http_get_from_hub, not_found_if_none, paginate, paginate_optional,
query_elems_from_hub, require_admin, strip_json_nul, Pagination, RunnableKind, StripPath,
},
variables::{build_crypt, build_crypt_with_key_suffix, encrypt},
worker::{to_raw_value, CLOUD_HOSTED},
@@ -274,6 +274,12 @@ pub struct AppHistory {
pub version: i64,
#[serde(skip_serializing_if = "Option::is_none")]
pub deployment_msg: Option<String>,
/// Who deployed this version, and when — the diff's version picker names them so
/// a reader can tell their own deploys from a teammate's.
#[serde(skip_serializing_if = "Option::is_none")]
pub created_by: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub created_at: Option<chrono::DateTime<chrono::Utc>>,
}
#[derive(Deserialize)]
@@ -567,6 +573,17 @@ pub struct CreateApp {
pub skip_draft_deletion: Option<bool>,
}
/// What a deploy of an existing app answers with. `version` is the one this call wrote,
/// which is what an editor pins as the fork base of the draft it starts next: reading the
/// head back afterwards cannot tell it from a deploy that landed beside it. A
/// metadata-only update writes none and reports the head it kept.
#[derive(Serialize)]
pub struct AppDeployed {
/// Where the app now lives, which differs from the request path on a rename.
pub path: String,
pub version: i64,
}
#[derive(Serialize, Deserialize)]
pub struct EditApp {
pub path: Option<String>,
@@ -1220,18 +1237,42 @@ async fn get_app_history(
authed: ApiAuthed,
Extension(user_db): Extension<UserDB>,
Path((w_id, path)): Path<(String, StripPath)>,
Query(pagination): Query<Pagination>,
) -> JsonResult<Vec<AppHistory>> {
let path = path.to_path();
check_scopes(&authed, || format!("apps:read:{}", &path))?;
// Unasked-for, this listing stays whole: the deployment-history panel reads it
// without paging. The diff picker asks for a page.
let (per_page, offset) = paginate_optional(pagination);
let mut tx = user_db.begin(&authed).await?;
// Newest first in deployed order, which is a version's position in `app.versions` and
// not its `created_at`: the latter is the deploying transaction's start time, so two
// that overlap can carry it in the opposite order from the one they landed in. A row
// outside the array never sat in that sequence, so it sorts after the ones that did.
// Paging happens before the metadata joins, so a page costs its own rows.
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
"SELECT a.id as app_id, av.id as version_id, dm.deployment_msg as deployment_msg,
av.created_by as created_by, av.created_at as created_at
FROM app a
JOIN LATERAL (
SELECT av2.id, COALESCE(v.ord, 0) AS ord
FROM app_version av2
LEFT JOIN unnest(a.versions) WITH ORDINALITY AS v(id, ord) ON v.id = av2.id
WHERE av2.app_id = a.id
ORDER BY ord DESC, av2.id DESC
LIMIT $3 OFFSET $4
) page ON TRUE
JOIN app_version av ON av.id = page.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",
ORDER BY page.ord DESC",
w_id,
path,
).fetch_all(&mut *tx).await?;
per_page,
offset,
)
.fetch_all(&mut *tx)
.await?;
tx.commit().await?;
let result: Vec<AppHistory> = query_result
@@ -1240,6 +1281,8 @@ async fn get_app_history(
app_id: row.app_id,
version: row.version_id,
deployment_msg: row.deployment_msg,
created_by: Some(row.created_by),
created_at: Some(row.created_at),
})
.collect();
return Ok(Json(result));
@@ -1253,14 +1296,22 @@ async fn get_latest_version(
let path = path.to_path();
check_scopes(&authed, || format!("apps:read:{}", path))?;
let mut tx = user_db.begin(&authed).await?;
// The head is the tail of `app.versions` — the version the runtime serves. Deploys
// append to it under the app row's lock, whereas `app_version.created_at` is the
// deploying transaction's start time, so two that overlap can carry it in either
// order and the newest timestamp is then not the one that landed last.
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",
"SELECT a.id as app_id, av.id as version_id, dm.deployment_msg as deployment_msg,
av.created_by as created_by, av.created_at as created_at
FROM app a JOIN app_version av
ON av.id = a.versions[array_upper(a.versions, 1)] AND av.app_id = a.id
LEFT JOIN deployment_metadata dm ON av.id = dm.app_version
WHERE a.workspace_id = $1 AND a.path = $2",
w_id,
path,
).fetch_optional(&mut *tx).await?;
)
.fetch_optional(&mut *tx)
.await?;
tx.commit().await?;
if let Some(row) = row {
@@ -1268,6 +1319,8 @@ async fn get_latest_version(
app_id: row.app_id,
version: row.version_id,
deployment_msg: row.deployment_msg,
created_by: Some(row.created_by),
created_at: Some(row.created_at),
};
return Ok(Json(Some(result)));
@@ -2591,6 +2644,14 @@ async fn create_app_internal<'a>(
.execute(&mut *tx)
.await?;
}
windmill_common::user_drafts::clear_draft_moves_from(
&mut tx,
&w_id,
&[UserDraftItemKind::App, UserDraftItemKind::RawApp],
&app.path,
None,
)
.await?;
let id = sqlx::query_scalar!(
"INSERT INTO app
(workspace_id, path, summary, policy, versions, custom_path, labels)
@@ -2947,7 +3008,7 @@ async fn update_app(
Extension(webhook): Extension<WebhookShared>,
Path((w_id, path)): Path<(String, StripPath)>,
Json(ns): Json<EditApp>,
) -> Result<String> {
) -> JsonResult<AppDeployed> {
if authed.is_operator {
return Err(Error::NotAuthorized(
"Operators cannot update apps for security reasons".to_string(),
@@ -2986,7 +3047,7 @@ async fn update_app(
},
);
Ok(format!("app {} updated (npath: {:?})", opath, npath))
Ok(Json(AppDeployed { path: npath, version: v_id }))
}
/// Deploy a raw app from its sources, compiling them on a worker. `update_raw`
@@ -3000,7 +3061,7 @@ async fn update_app_raw_source(
Extension(webhook): Extension<WebhookShared>,
Path((w_id, path)): Path<(String, StripPath)>,
Json(ns): Json<EditApp>,
) -> Result<String> {
) -> JsonResult<AppDeployed> {
if authed.is_operator {
return Err(Error::NotAuthorized(
"Operators cannot update apps for security reasons".to_string(),
@@ -3098,7 +3159,7 @@ async fn update_app_raw_source(
},
);
Ok(format!("app {} updated (npath: {:?})", opath, npath))
Ok(Json(AppDeployed { path: npath, version: v_id }))
}
/// Whether the caller may create an app at `path` — asked of the database rather
@@ -3324,7 +3385,7 @@ async fn update_app_raw<'a>(
Extension(webhook): Extension<WebhookShared>,
Path((w_id, path)): Path<(String, StripPath)>,
multipart: Multipart,
) -> Result<String> {
) -> JsonResult<AppDeployed> {
if authed.is_operator {
return Err(Error::NotAuthorized(
"Operators cannot update apps for security reasons".to_string(),
@@ -3372,7 +3433,7 @@ async fn update_app_raw<'a>(
},
);
Ok(format!("app {} updated (npath: {:?})", opath, npath))
Ok(Json(AppDeployed { path: npath, version: v_id }))
}
// async fn create_app_internal<'a>(
// authed: ApiAuthed,
@@ -3749,6 +3810,19 @@ async fn update_app_internal<'a>(
.execute(&mut *tx)
.await?;
}
if npath != path {
// Everything left at the old path is a draft this deploy didn't consume
// — teammates' rows, and the deployer's own when the caller asked us to
// keep it. Carry them rather than strand them.
windmill_common::user_drafts::move_drafts_for_path(
&mut tx,
&w_id,
&[UserDraftItemKind::App, UserDraftItemKind::RawApp],
path,
&npath,
)
.await?;
}
audit_log(
&mut *tx,
&authed,
+468 -22
View File
@@ -19,7 +19,7 @@ use windmill_common::{
error::{Error, Result},
user_drafts::{DraftUserRef, UserDraftItemKind, ENCRYPTED_DRAFT_PREFIX},
users::resolve_username_to_email,
utils::strip_json_nul,
utils::{check_proper_path, strip_json_nul},
variables::{build_crypt, encrypt},
};
@@ -29,6 +29,7 @@ pub fn workspaced_service() -> Router {
.route("/get/{kind}/{*path}", get(get_draft_for_user))
.route("/get_own/{kind}/{*path}", get(get_own_draft))
.route("/update/{kind}/{*path}", post(update_draft))
.route("/move/{kind}/{*path}", post(move_draft))
.route("/migrate_legacy/{kind}/{*path}", post(migrate_legacy_draft))
}
@@ -320,12 +321,71 @@ pub struct SaveDraftResponse {
/// On `saved`: when the change was applied (client remembers it as the
/// next `last_sync`). On `conflict`: the existing row's `created_at`.
pub current_timestamp: chrono::DateTime<chrono::Utc>,
/// `saved` only: where the write landed. Differs from the URL path when the item
/// had moved away from it; the editor follows it there. Absent when a delete found
/// nothing to remove and the caller cannot read the path it moved to.
#[serde(skip_serializing_if = "Option::is_none")]
pub path: Option<String>,
}
/// Apply the current user's draft at (workspace, kind, path): non-null `value`
/// upserts, `null` (or omitted) deletes. Either way, when the existing row is
/// newer than `last_sync` (and `force` is false) the op is skipped and the
/// response is `status = conflict` + the server's current timestamp.
/// The version a draft forked from, as the editors write it into `draft.value`.
/// Each kind names it differently and only one is ever set.
#[derive(Deserialize)]
struct DraftBaseVersion {
/// Scripts: hex-encoded script hash.
#[serde(default)]
parent_hash: Option<String>,
/// Flows: `flow_version.id`.
#[serde(default)]
version_id: Option<i64>,
/// Apps / raw apps: `app_version.id`.
#[serde(default)]
parent_version: Option<i64>,
}
impl DraftBaseVersion {
/// The base as the `draft.base` column stores it: one opaque text id whatever
/// the kind, so a reader compares it to the head without knowing the kind's
/// own field name or type.
fn as_text(&self, kind: UserDraftItemKind) -> Option<String> {
use UserDraftItemKind::*;
match kind {
Script => self.parent_hash.clone(),
Flow => self.version_id.map(|v| v.to_string()),
_ => self.parent_version.map(|v| v.to_string()),
}
}
}
/// The version this draft forked from, or `None` when it has none — a kind
/// that keeps no lineage, a malformed payload, or a draft that was never forked
/// from a deploy. Pure: no queries.
fn draft_lineage(kind: UserDraftItemKind, value: &str) -> Option<DraftBaseVersion> {
use UserDraftItemKind::*;
if !matches!(kind, Script | Flow | App | RawApp) {
return None;
}
let base = serde_json::from_str::<DraftBaseVersion>(value).ok()?;
let has_base = match kind {
Script => base
.parent_hash
.as_deref()
.and_then(|h| windmill_common::scripts::to_i64(h).ok())
.is_some(),
Flow => base.version_id.is_some(),
_ => base.parent_version.is_some(),
};
has_base.then_some(base)
}
/// Apply the current user's draft: non-null `value` upserts, `null` (or
/// omitted) deletes. Either way, when the existing row is newer than
/// `last_sync` (and `force` is false) the op is skipped and the response is
/// `status = conflict` + the server's current timestamp.
///
/// A save addressed to a path its item moved away from lands where the move took
/// the drafts (`draft_move`), unless the caller still has a draft of their own at
/// that path. The response names where it landed.
async fn update_draft(
authed: ApiAuthed,
Extension(db): Extension<DB>,
@@ -334,7 +394,7 @@ async fn update_draft(
Json(req): Json<SaveDraftRequest>,
) -> Result<Json<SaveDraftResponse>> {
let email = &authed.email;
let path = path.to_path();
let url_path = path.to_path();
// Saving a draft requires write permission on the underlying path. Deleting
// (discarding) one's OWN draft does not: the email-scoped row belongs to the
// authed user, so they can always discard it even after losing write access
@@ -343,11 +403,68 @@ async fn update_draft(
// touch the caller's own row. Legacy (NULL-email) rows aren't owned by anyone
// — they keep the write gate.
let is_own_discard = req.value.is_none() && !req.legacy;
// `legacy` targets the workspace-level row and is delete-only: an upsert writes the
// caller's own row whatever it says. Every read of that rule goes through this.
let legacy_delete = req.value.is_none() && req.legacy;
// Whose row this write is for: the caller's, or the workspace-level one on a legacy
// DELETE (`legacy` is delete-only, so an upsert is the caller's own row either way).
// It picks both the record that applies — an item's move (`email IS NULL`) covers the
// legacy row too, since the same rename carried it — and the draft whose presence
// means this path is still the write's own.
let owner: Option<&str> = (!legacy_delete).then_some(email.as_str());
// The caller's own draft-only move outranks the move of the deployed item. Only the
// kinds whose value carries a deploy target are ever recorded as moved, so for the
// rest this would be a guaranteed-empty query on the autosave hot path.
let moved_to = match kind.typed_path_field() {
None => None,
Some(_) => {
sqlx::query_scalar!(
r#"SELECT m.new_path FROM draft_move m
WHERE m.workspace_id = $1 AND m.typ = $2 AND m.old_path = $3
AND (m.email IS NULL OR m.email = $4)
AND NOT EXISTS (
SELECT 1 FROM draft d
WHERE d.workspace_id = $1 AND d.typ = $2 AND d.path = $3
AND d.email IS NOT DISTINCT FROM $4
)
ORDER BY m.email IS NULL
LIMIT 1"#,
&w_id,
kind as UserDraftItemKind,
url_path,
owner,
)
.fetch_optional(&db)
.await?
}
};
let path: &str = moved_to.as_deref().unwrap_or(url_path);
// Everything past here writes, so the gate applies from here on. Answered
// without the path when the item moved: it may have gone somewhere the caller
// cannot see.
if !is_own_discard {
require_can_write_path(&authed, &db, &user_db, &w_id, kind, path).await?;
match require_can_write_path(&authed, &db, &user_db, &w_id, kind, path).await {
// Naming the move is for whoever was editing the item: it tells them why a
// save they were already making stopped landing. Someone who cannot read the
// path they addressed gets the plain denial, or the wording itself would
// answer whether an item was moved away from a path they only guessed at.
Err(Error::NotAuthorized(e)) if moved_to.is_some() => {
return Err(Error::NotAuthorized(
match require_can_read_path(&authed, &user_db, &w_id, kind, url_path).await {
Ok(()) => {
"this draft's item was moved to a path you cannot write".to_string()
}
Err(_) => e,
},
));
}
other => other?,
}
}
let applied_at = if let Some(value) = &req.value {
let applied = if let Some(value) = &req.value {
// Secret variable values must never sit in `draft.value` in plaintext
// (see `encrypt_secret_variable_value`).
let serialized = if kind == UserDraftItemKind::Variable {
@@ -359,19 +476,49 @@ async fn update_draft(
// escape and later make any `->>`/`to_jsonb` extraction raise `22P05`.
// Strip it here so a NUL never reaches the column.
let serialized = strip_json_nul(&serialized);
// `base` is derived here from the value's per-kind field rather than sent
// by the client, so every writer (editors, chat, CLI) fills it the same way.
// Read from the sanitized text, which is what the value column gets: a NUL in the
// lineage field otherwise costs the draft its base (the hash no longer parses),
// leaving a draft that reads as up to date whatever the head is.
let base = draft_lineage(kind, serialized.as_ref()).and_then(|l| l.as_text(kind));
// Upsert. The conflict check rides on the DO UPDATE WHERE clause —
// when the row is newer than `last_sync`, RETURNING yields nothing.
// `created_at` defaults to `now()` but the migration overrides it ($8)
// so a migrated draft keeps its original age instead of jumping to top.
sqlx::query_scalar!(
r#"INSERT INTO draft (workspace_id, email, path, typ, value, created_at)
VALUES ($1, $2, $3, $4, $5::text::json, COALESCE($8::timestamptz, now()))
//
// A moved save ($10) carries the path keys its editor had before the move.
// One naming the path it addressed ($11) follows to where it landed; a draft
// already there keeps the keys the move gave it. A pre-sanitizer NUL escape
// in that draft makes `to_jsonb` raise, so it takes the incoming keys.
sqlx::query!(
r#"INSERT INTO draft (workspace_id, email, path, typ, value, created_at, base)
VALUES ($1, $2, $3::text, $4,
CASE WHEN $10::bool
THEN to_json($5::text::jsonb || jsonb_strip_nulls(jsonb_build_object(
'path', CASE WHEN $5::text::jsonb -> 'path' = to_jsonb($11::text)
THEN to_jsonb($3::text) END,
'draft_path', CASE WHEN $5::text::jsonb -> 'draft_path' = to_jsonb($11::text)
THEN to_jsonb($3::text) END)))
ELSE $5::text::json
END,
COALESCE($8::timestamptz, now()), $9)
ON CONFLICT (workspace_id, path, typ, email) WHERE email IS NOT NULL
DO UPDATE SET value = EXCLUDED.value, created_at = EXCLUDED.created_at
DO UPDATE SET value = CASE
WHEN NOT $10::bool
OR position(chr(92) || 'u0000' in replace(draft.value::text, chr(92) || chr(92), '')) > 0
THEN EXCLUDED.value
ELSE to_json((to_jsonb(EXCLUDED.value) - 'path' - 'draft_path')
|| jsonb_strip_nulls(jsonb_build_object(
'path', to_jsonb(draft.value) -> 'path',
'draft_path', to_jsonb(draft.value) -> 'draft_path')))
END,
created_at = EXCLUDED.created_at,
base = EXCLUDED.base
WHERE $7::bool = true
OR $6::timestamptz IS NULL
OR draft.created_at <= $6::timestamptz
RETURNING created_at"#,
RETURNING path, created_at"#,
&w_id,
email,
path,
@@ -380,9 +527,13 @@ async fn update_draft(
req.last_sync,
req.force,
req.created_at,
base.as_deref(),
moved_to.is_some(),
url_path,
)
.fetch_optional(&db)
.await?
.map(|r| (r.created_at, Some(r.path)))
} else {
// Delete, same conflict rule in the WHERE clause. Returns NULL when
// the row was too new (conflict) OR already absent (idempotent) —
@@ -403,16 +554,20 @@ async fn update_draft(
kind as UserDraftItemKind,
req.last_sync,
req.force,
req.legacy,
legacy_delete,
)
.fetch_optional(&db)
.await?
// Named for the same reason an upsert is: the editor that discarded is still on
// the path the item left, and reloading there would land on nothing.
.map(|ts| (ts, moved_to.clone()))
};
if let Some(ts) = applied_at {
if let Some((ts, path)) = applied {
return Ok(Json(SaveDraftResponse {
status: SaveDraftStatus::Saved,
current_timestamp: ts,
path,
}));
}
@@ -428,7 +583,7 @@ async fn update_draft(
email,
path,
kind as UserDraftItemKind,
req.legacy,
legacy_delete,
)
.fetch_optional(&db)
.await?;
@@ -437,20 +592,307 @@ async fn update_draft(
Some(ts) => Ok(Json(SaveDraftResponse {
status: SaveDraftStatus::Conflict,
current_timestamp: ts,
path: None,
})),
// Delete + nothing-was-there ⇒ report success with server's NOW().
None => {
let now = sqlx::query_scalar!(r#"SELECT now() as "now!""#)
.fetch_one(&db)
.await?;
// A retry of a routed discard whose answer was lost lands here: the row is
// gone but the editor is still on the path the item left, so it needs the
// destination as much as the first attempt did. Unlike the arm above there is
// no deleted row proving the caller ever held that draft, and an own discard
// is not gated, so this names a path to someone who may have none of it: the
// read gate, which is what keeps a path from being disclosed elsewhere in this
// module. Without it, discarding at a guessed path reads `draft_move`.
let disclosed = match moved_to {
Some(dest) => {
match require_can_read_path(&authed, &user_db, &w_id, kind, &dest).await {
Ok(()) => Some(dest),
Err(Error::NotFound(_))
| Err(Error::NotAuthorized(_))
| Err(Error::BadRequest(_)) => None,
Err(e) => return Err(e),
}
}
None => None,
};
Ok(Json(SaveDraftResponse {
status: SaveDraftStatus::Saved,
current_timestamp: now,
path: disclosed,
}))
}
}
}
#[derive(Deserialize)]
pub struct MoveDraftRequest {
pub new_path: String,
/// Also restate the draft's summary, so the same drawer that renames a
/// deployed item can retitle a draft-only one.
#[serde(default)]
pub summary: Option<String>,
}
/// Relocate the authed user's own DRAFT-ONLY item. Such an item is nothing but
/// its draft row, so moving it is a rewrite of that row's path plus both path
/// keys inside its value — there is no deployed row, schedule or trigger to
/// cascade to.
///
/// The owner's own open editor follows: its next save, still addressed to the old
/// path, lands at the new one through the move record, and it is told where.
///
/// Scoped to the caller's own row on purpose: two users can each have a draft
/// at the same never-deployed path, and those are two separate items.
///
/// A DEPLOYED item must move through its own deploy endpoint instead, which
/// cascades everything that references the path and carries every draft along.
async fn move_draft(
authed: ApiAuthed,
Extension(db): Extension<DB>,
Extension(user_db): Extension<UserDB>,
Path((w_id, kind, path)): Path<(String, UserDraftItemKind, windmill_common::utils::StripPath)>,
Json(req): Json<MoveDraftRequest>,
) -> Result<String> {
let path = path.to_path();
let new_path = req.new_path.as_str();
// Only the full-page editor kinds, which is exactly the set that has a typed
// path to rewrite. Reading the movable set off the same mapping the rewrite
// uses keeps them from drifting apart: a resource, a variable or a trigger
// keeps its deploy target in `value.path` with no editor to stage a rename,
// so moving one would leave the real target naming the old location and the
// next deploy would recreate it where it came from.
let (Some(typed_field), Some(mirror_field)) =
(kind.typed_path_field(), kind.mirror_path_field())
else {
return Err(Error::BadRequest(format!(
"moving a {kind:?} draft is not supported — only scripts, flows and apps"
)));
};
// Validate before authorizing: `require_can_write_path` is not a format check
// (an admin returns immediately, and a user returns early inside their own
// namespace), so without this a malformed path is stored as-is, and an over-long
// or NUL-bearing one reaches Postgres as a raw server error. The source is a URL
// segment and arrives decoded, so it needs the same check as the destination.
check_proper_path(path)?;
check_proper_path(new_path)?;
// A summary-only edit is a legitimate use of this endpoint: the drawer edits
// both fields, and for a draft-only script the path it posts back is the row
// path unchanged (`list_scripts` only reports `draft_path` when it differs).
// Returning early on the path alone would drop the new summary silently.
if new_path == path && req.summary.is_none() {
return Ok("unchanged".to_string());
}
require_can_write_path(&authed, &db, &user_db, &w_id, kind, path).await?;
if new_path != path {
require_can_write_path(&authed, &db, &user_db, &w_id, kind, new_path).await?;
}
if let Some(table) = kind.deployed_table() {
// `table` is from the closed `deployed_table()` enum, never user input.
// Archived and soft-deleted rows keep sitting at their path — a script
// move archives its parent in place — so an existence check that counted
// them would refuse a move away from, or into, a path nothing occupies.
// `create_script_internal` resolves its own path clashes the same way.
let archived_filter = if table == "script" {
" AND NOT archived AND NOT deleted"
} else {
""
};
let query = format!(
"SELECT 1 FROM {table} WHERE path = $1 AND workspace_id = $2{archived_filter} LIMIT 1"
);
let mut tx = user_db.clone().begin(&authed).await?;
let deployed_at_old = sqlx::query_scalar::<_, i32>(&query)
.bind(path)
.bind(&w_id)
.fetch_optional(&mut *tx)
.await?;
let deployed_at_new = sqlx::query_scalar::<_, i32>(&query)
.bind(new_path)
.bind(&w_id)
.fetch_optional(&mut *tx)
.await?;
tx.commit().await?;
if deployed_at_old.is_some() {
return Err(Error::BadRequest(format!(
"'{path}' is deployed — move it from its editor so schedules and triggers follow"
)));
}
if deployed_at_new.is_some() {
return Err(Error::BadRequest(format!(
"'{new_path}' already has a deployed item — moving there would turn this into a draft on top of it"
)));
}
}
// A NUL in the summary reaches Postgres as a raw byte and fails the statement with
// an encoding error, so it is dropped here the way `strip_json_nul` drops one from a
// draft value. `json` cannot store it either.
let summary = req.summary.as_ref().map(|s| s.replace('\0', ""));
// A classic app and a raw app share the `app` table, so a draft of either kind
// occupies the destination for both: deploying there deletes the caller's drafts
// of both kinds, taking the item that lost the collision with it.
let collision_typs: Vec<&str> = match kind {
UserDraftItemKind::App | UserDraftItemKind::RawApp => vec![
UserDraftItemKind::App.as_str(),
UserDraftItemKind::RawApp.as_str(),
],
_ => vec![kind.as_str()],
};
// One transaction with the move record, so a save addressed to the old path
// never sees the row gone without knowing where it went.
let mut tx = db.begin().await?;
let moved = sqlx::query_scalar!(
r#"UPDATE draft
SET path = $3,
-- Both path keys, not just the typed one: the editors mirror the
-- typed path into the other while it differs from the row's path,
-- and the loaders prefer the mirror left naming the old location
-- it un-does this move on the next save. `create_missing = false`
-- on both, so a draft carrying only one keeps only one.
value = to_json(
jsonb_set(
jsonb_set(
CASE WHEN $7::text IS NULL THEN to_jsonb(value)
ELSE jsonb_set(to_jsonb(value), ARRAY['summary'], to_jsonb($7::text))
END,
ARRAY[$5::text], to_jsonb($3::text), false
),
ARRAY[$8::text], to_jsonb($3::text), false
)
)
WHERE workspace_id = $1
AND path = $2
AND typ = $4
AND email = $6
-- A pre-sanitizer NUL escape makes `to_jsonb` raise 22P05. Excluded
-- here so the statement can't 500; reported below instead. Unlike the
-- passive carry, rewriting the value IS this operation, so skipping it
-- silently would move the row and leave its typed path stale.
AND position(chr(92) || 'u0000' in replace(value::text, chr(92) || chr(92), '')) = 0
-- Skipped on a summary-only edit, where the "target" row is this
-- row and the guard would refuse the update against itself.
AND ($2 = $3 OR NOT EXISTS (
SELECT 1 FROM draft o
WHERE o.workspace_id = $1 AND o.path = $3 AND o.typ::text = ANY($9::text[])
-- Of this kind only the caller's own row and the legacy one collide:
-- teammates' drafts of one item share its path by design, but a deploy
-- there wipes those two together, so a second would discard edits the
-- caller never saw. The other app kind is a different item on the same
-- deployed path, so it collides whoever owns it.
AND (o.typ <> $4 OR o.email = $6 OR o.email IS NULL)
))
RETURNING id"#,
&w_id,
path,
new_path,
kind as UserDraftItemKind,
typed_field,
&authed.email,
summary,
mirror_field,
&collision_typs as &[&str],
)
.fetch_optional(&mut *tx)
.await?;
if moved.is_some() && new_path != path {
windmill_common::user_drafts::record_draft_move(
&mut tx,
&w_id,
&[kind],
path,
new_path,
Some(&authed.email),
)
.await?;
}
tx.commit().await?;
if moved.is_none() {
let row = sqlx::query!(
r#"SELECT
-- The guard's own predicate, ordered own row, then legacy, then another
-- user's other-kind row: each needs a different remedy, and a bare
-- LIMIT 1 would name an arbitrary one.
(SELECT typ::text FROM draft WHERE workspace_id = $1 AND path = $3
AND typ::text = ANY($6::text[])
AND (typ <> $2 OR email = $4 OR email IS NULL)
ORDER BY CASE WHEN email = $4 THEN 0 WHEN email IS NULL THEN 1 ELSE 2 END
LIMIT 1) as "at_target",
EXISTS(SELECT 1 FROM draft WHERE workspace_id = $1 AND path = $3
AND typ::text = ANY($6::text[]) AND email = $4) as "at_target_own!",
EXISTS(SELECT 1 FROM draft WHERE workspace_id = $1 AND path = $3
AND typ::text = ANY($6::text[]) AND email IS NULL) as "at_target_legacy!",
EXISTS(SELECT 1 FROM draft WHERE workspace_id = $1 AND path = $5
AND typ = $2 AND email = $4
AND position(chr(92) || 'u0000' in replace(value::text, chr(92) || chr(92), '')) > 0
) as "poisoned!",
EXISTS(SELECT 1 FROM draft WHERE workspace_id = $1 AND path = $5
AND typ = $2 AND email IS NULL) as "legacy!" "#,
&w_id,
kind as UserDraftItemKind,
new_path,
&authed.email,
path,
&collision_typs as &[&str],
)
.fetch_one(&db)
.await?;
return Err(Error::BadRequest(if row.poisoned {
// This endpoint also serves a summary-only edit, so name the operation
// the caller actually asked for rather than always saying "moved".
let attempted = if new_path == path { "updated" } else { "moved" };
format!(
"'{path}' contains a NUL character and predates the sanitizer, so it cannot be \
{attempted}. Reopen it, re-save to rewrite it cleanly, then retry."
)
} else if row.legacy {
// The home list synthesizes a draft-only row for the legacy draft with the
// caller's own name on it, so this is reachable from the row menu. Only an
// admin can claim or discard that row, and only from the drafts page.
format!(
"'{path}' is a legacy workspace draft with no owner, so it cannot be moved. \
A workspace admin can claim or discard it on the Review & deploy drafts page."
)
} else if let Some(occupant) = row.at_target.filter(|_| new_path != path) {
// Naming the kind matters for the app pair: a classic-app draft refusing a
// raw-app move is invisible in the raw-app list the caller is looking at.
let occupant = occupant.replace('_', " ");
if row.at_target_own {
format!("You already have a draft at '{new_path}' ({occupant})")
} else if row.at_target_legacy {
// An ownerless row the caller cannot clear themselves, so send them to
// the one place it can be resolved rather than to "discard your draft".
format!(
"A legacy workspace draft with no owner is already at '{new_path}' \
({occupant}). A workspace admin can claim or discard it on the Review & \
deploy drafts page."
)
} else {
// The other app kind, owned by someone else: one deployed path cannot hold
// both, so this is the other item's path, not a teammate's copy of this one.
format!(
"'{new_path}' holds another user's {occupant} draft, and an app and a raw \
app cannot share a path. Pick another path, or ask them to move or \
discard theirs."
)
}
} else {
format!("You have no draft at '{path}'")
}));
}
if new_path == path {
return Ok(format!("updated draft {path}"));
}
Ok(format!("moved draft {path} to {new_path}"))
}
#[derive(Deserialize, Debug)]
#[serde(rename_all = "snake_case")]
pub enum MigrateLegacyDraftAction {
@@ -504,12 +946,12 @@ async fn migrate_legacy_draft(
r#"WITH legacy AS (
DELETE FROM draft
WHERE workspace_id = $1 AND path = $2 AND typ = $3 AND email IS NULL
RETURNING value
RETURNING value, base
)
INSERT INTO draft (workspace_id, email, path, typ, value, created_at)
SELECT $1, $4, $2, $3, value, now() FROM legacy
INSERT INTO draft (workspace_id, email, path, typ, value, created_at, base)
SELECT $1, $4, $2, $3, value, now(), base FROM legacy
ON CONFLICT (workspace_id, path, typ, email) WHERE email IS NOT NULL
DO UPDATE SET value = EXCLUDED.value, created_at = now()
DO UPDATE SET value = EXCLUDED.value, created_at = now(), base = EXCLUDED.base
RETURNING 1 as "one!""#,
&w_id,
path,
@@ -767,8 +1209,8 @@ async fn require_can_write_path(
/// Resolves to `Ok(())` if `authed` can read at `path`. Three layers:
/// 1. admin → always.
/// 2. Path-prefix match against own `u/{username}` or any folder in
/// `authed.folders` (the precomputed read set, with groups + direct
/// 2. Path-prefix match against own `u/{username}`, a group in `authed.groups`, or any
/// folder in `authed.folders` (the precomputed read set, with groups + direct
/// grants already factored in).
/// 3. RLS-aware `SELECT 1` against the backing table — covers item-level
/// extra_perms grants that bypass folder/owner checks.
@@ -793,6 +1235,10 @@ async fn require_can_read_path(
if parts.len() >= 2 {
match parts[0] {
"u" if parts[1] == authed.username => return Ok(()),
// As `require_can_write_path` and the `see_member` RLS policy read it: a
// draft-only `g/` path has no row for the probe below to fall back on, so
// without this a member cannot see a draft their group owns.
"g" if authed.groups.iter().any(|g| g == parts[1]) => return Ok(()),
"f" => {
let folder = parts[1];
if authed.folders.iter().any(|(name, _, _)| name == folder) {
+5 -3
View File
@@ -560,7 +560,7 @@ pub async fn run_server(
if server_mode || mcp_mode {
use mcp::{
add_www_authenticate_header, add_www_authenticate_header_gateway,
extract_workspace_from_token,
extract_workspace_from_token, reject_token_query_param,
};
let (mcp_router, mcp_cancellation_token) = setup_mcp_server(
db.clone(),
@@ -573,15 +573,17 @@ pub async fn run_server(
let workspaced_mcp_router = mcp_router
.clone()
.route_layer(from_extractor::<ApiAuthed>())
.layer(axum::middleware::from_fn(reject_token_query_param))
.layer(axum::middleware::from_fn(add_www_authenticate_header))
.layer(axum::middleware::from_fn(extract_and_store_workspace_id));
// Gateway MCP router — resolves workspace from token
let gateway_mcp_router = mcp_router
.route_layer(from_extractor::<ApiAuthed>())
.layer(axum::middleware::from_fn(extract_workspace_from_token))
.layer(axum::middleware::from_fn(reject_token_query_param))
.layer(axum::middleware::from_fn(
add_www_authenticate_header_gateway,
))
.layer(axum::middleware::from_fn(extract_workspace_from_token));
));
(
workspaced_mcp_router,
gateway_mcp_router,
+28 -2
View File
@@ -43,11 +43,14 @@ use axum::{
extract::{Extension, Path},
http::Request,
middleware::Next,
response::Response,
response::{IntoResponse, Response},
routing::get,
Json, Router,
};
use windmill_common::{auth::hash_token, db::GatewayWorkspaceId, error::JsonResult};
use windmill_common::{
auth::hash_token, db::GatewayWorkspaceId, error::JsonResult,
global_settings::MCP_DISABLE_TOKEN_QUERY_PARAM,
};
// McpAuth impl for ApiAuthed is in windmill-api-auth (same crate as the type)
@@ -446,6 +449,29 @@ pub async fn add_www_authenticate_header(
}
}
/// Middleware refusing a credential carried in the MCP URL once the instance sets
/// `mcp_disable_token_query_param`. Sits outside everything that reads the token, so neither
/// the gateway lookup nor `ApiAuthed` ever sees it, and inside the `WWW-Authenticate` layer,
/// whose header is what sends the client into the OAuth flow instead. Refused rather than
/// ignored: the URL leaked the token whether or not the request used it.
pub async fn reject_token_query_param(request: Request<axum::body::Body>, next: Next) -> Response {
let carries_token = MCP_DISABLE_TOKEN_QUERY_PARAM.load(std::sync::atomic::Ordering::Relaxed)
&& request
.uri()
.query()
.is_some_and(|q| url::form_urlencoded::parse(q.as_bytes()).any(|(k, _)| k == "token"));
if carries_token {
return (
axum::http::StatusCode::UNAUTHORIZED,
"This instance does not accept a token in the MCP URL. Remove the token query \
parameter and let your client sign in through OAuth, or send the token in an \
Authorization header.",
)
.into_response();
}
next.run(request).await
}
/// Extract the bearer token from either the `Authorization` header or the
/// `?token=` query parameter (MCP clients commonly pass it in the URL).
fn extract_gateway_token(request: &Request<axum::body::Body>) -> Option<String> {
+1 -1
View File
@@ -12,5 +12,5 @@ pub mod oauth_server;
pub use core::{
add_www_authenticate_header, add_www_authenticate_header_gateway,
extract_and_store_workspace_id, extract_workspace_from_token, list_tools_service,
setup_mcp_server,
reject_token_query_param, setup_mcp_server,
};
+25 -8
View File
@@ -20,7 +20,7 @@ use crate::{
/// Whether `label` denotes a user-created token rather than a system token
/// (`session`, `guest_session`, `ephemeral*`, `debugger-token`, `mcp-oauth-*`,
/// `embed_app:*`, `sdk_app:*`, `impersonation:*`). System-token labels are load-bearing —
/// `embed_app:*`, `sdk_app:*`, `impersonation:*`, `cli-login:*`). System-token labels are load-bearing —
/// session cleanup, super_admin propagation, expiry notifications and username overrides
/// all key off them — so they must not be user-editable. `None` (no label) is treated as
/// a user token.
@@ -47,10 +47,17 @@ pub fn is_user_token(label: Option<&str>) -> bool {
&& !l.starts_with(APP_EMBED_TOKEN_LABEL_PREFIX)
&& !l.starts_with(RAW_APP_SDK_TOKEN_LABEL_PREFIX)
&& !l.starts_with("impersonation:")
&& !l.starts_with(CLI_LOGIN_TOKEN_LABEL_PREFIX)
}
}
}
/// How far ahead of a user token's expiration its owner is warned (`check_expiring_tokens` in
/// the monitor). A token whose whole lifetime fits in this window gets no warning at all: it
/// would arrive minutes after creation, about a lifetime its creator just picked. Its
/// "expired and deleted" notice still goes out.
pub const TOKEN_EXPIRY_WARNING_DAYS: i32 = 7;
/// Label prefix, followed by the app path, of the token an app viewer's sandboxed iframe
/// runs with. Reserved in [`is_user_token`], whose SQL and frontend mirrors spell it out.
pub const APP_EMBED_TOKEN_LABEL_PREFIX: &str = "embed_app:";
@@ -59,6 +66,13 @@ pub const APP_EMBED_TOKEN_LABEL_PREFIX: &str = "embed_app:";
/// frontend SDK. Reserved in [`is_user_token`], whose SQL and frontend mirrors spell it out.
pub const RAW_APP_SDK_TOKEN_LABEL_PREFIX: &str = "sdk_app:";
/// Label prefix, followed by the username, of the token the CLI authorization page mints for
/// `wmill` logins. Reserved in [`is_user_token`], whose SQL and frontend mirrors spell it out:
/// the CLI signs in again on its own once that token expires, so an expiry email for it asks
/// the user to do nothing. Not in [`is_server_minted_label`], since the page mints it through
/// `/users/tokens/create`.
pub const CLI_LOGIN_TOKEN_LABEL_PREFIX: &str = "cli-login:";
/// Whether `label` belongs to a namespace only the server mints, and which therefore must be
/// rejected by `create_token`. Narrower than [`is_user_token`], which also drives label
/// editability and expiry notifications and can afford to reserve more: `Ephemeral lsp token`,
@@ -475,14 +489,16 @@ async fn fetch_authed_from_permissioned_as_inner(
// principal — a username freed and reassigned while its previous holder keeps a privileged
// account — would mix one account's role with another's instance privileges.
let member = match permissioned_as.split_once('/') {
Some(("u", name)) => sqlx::query!(
"SELECT is_admin, operator, email FROM usr where username = $1 AND \
Some(("u", name)) => {
sqlx::query!(
"SELECT is_admin, operator, email FROM usr where username = $1 AND \
workspace_id = $2 AND disabled = false",
name,
&w_id
)
.fetch_optional(&mut *conn)
.await?,
name,
&w_id
)
.fetch_optional(&mut *conn)
.await?
}
_ => None,
};
let resolved_email;
@@ -980,6 +996,7 @@ mod tests {
assert!(!is_user_token(Some("embed_app:f/team/dashboard")));
assert!(!is_user_token(Some("sdk_app:u/admin/raw app")));
assert!(!is_user_token(Some("impersonation:admin@windmill.dev")));
assert!(!is_user_token(Some("cli-login:admin")));
}
#[test]
@@ -102,6 +102,49 @@ pub const HUB_API_SECRET_SETTING: &str = "hub_api_secret";
pub const AUTOMATE_USERNAME_CREATION_SETTING: &str = "automate_username_creation";
pub const DISABLE_WORKSPACE_INVITE_EMAILS_SETTING: &str = "disable_workspace_invite_emails";
pub const DISABLE_PASSWORD_LOGIN_SETTING: &str = "disable_password_login";
/// Refuse `?token=` on the MCP endpoints, leaving the `Authorization` header as the only way
/// in. A URL-borne credential ends up in browser history, proxy logs and referrers, so an
/// instance that cares sends MCP clients through the OAuth flow instead.
pub const MCP_DISABLE_TOKEN_QUERY_PARAM_SETTING: &str = "mcp_disable_token_query_param";
/// Ceiling, in days, on how far ahead a token minted through `POST /users/tokens/create` or
/// `POST /users/tokens/impersonate` may expire; a request asking for more, or for no
/// expiration at all, is shortened to it rather than refused. On those routes only: server-side
/// mints (webhook tokens, app embed tokens, sessions) choose a lifetime the caller never picks
/// and go straight to `create_token_internal`. Read and validated by
/// [`parse_max_token_expiration_days`].
pub const MAX_TOKEN_EXPIRATION_DAYS_SETTING: &str = "max_token_expiration_days";
/// Largest `max_token_expiration_days` read as a ceiling, about 2,700 years. The token form
/// applies the same bound (`frontend/src/lib/tokenExpiration.ts`) so that it and the server
/// agree on whether a ceiling exists.
pub const MAX_TOKEN_EXPIRATION_DAYS_BOUND: i64 = 1_000_000;
/// Reads a stored `max_token_expiration_days`: `Ok(None)` when unset or cleared (null or an
/// empty string), the ceiling for a whole number of days within
/// `1..=MAX_TOKEN_EXPIRATION_DAYS_BOUND` stored as an integer, an integral float or a string of
/// digits, and an error for anything else.
///
/// The settings API and config sync both reject the error at write time: the token routes can
/// only read an unparseable value as no ceiling, so accepting a typo would silently turn the
/// policy off. `parseMaxTokenExpirationDays` in the frontend must accept exactly the same values.
pub fn parse_max_token_expiration_days(
value: Option<&serde_json::Value>,
) -> Result<Option<i64>, String> {
let days = match value {
None | Some(serde_json::Value::Null) => return Ok(None),
Some(serde_json::Value::String(s)) if s.trim().is_empty() => return Ok(None),
Some(serde_json::Value::Number(n)) => n
.as_i64()
.or_else(|| n.as_f64().filter(|f| f.fract() == 0.0).map(|f| f as i64)),
Some(serde_json::Value::String(s)) => s.trim().parse::<i64>().ok(),
Some(_) => None,
};
match days {
Some(days) if (1..=MAX_TOKEN_EXPIRATION_DAYS_BOUND).contains(&days) => Ok(Some(days)),
_ => Err(format!(
"must be a whole number of days from 1 to {MAX_TOKEN_EXPIRATION_DAYS_BOUND}, or empty for no limit"
)),
}
}
pub const AUTO_LOGIN_PROVIDER_SETTING: &str = "auto_login_provider";
/// Name of the SAML attribute or OIDC userinfo claim carrying the user's IdP groups. Unset or
/// empty leaves instance-group membership entirely to SCIM.
@@ -368,6 +411,7 @@ use std::sync::atomic::AtomicBool;
lazy_static::lazy_static! {
pub static ref HTTP_ROUTE_WORKSPACED_ROUTE: AtomicBool = AtomicBool::new(false);
pub static ref DISABLE_PASSWORD_LOGIN: AtomicBool = AtomicBool::new(false);
pub static ref MCP_DISABLE_TOKEN_QUERY_PARAM: AtomicBool = AtomicBool::new(false);
/// Origins HTTP routes allow cross-origin when they configure none of their
/// own. Empty means unset, which keeps the historical `*`.
pub static ref HTTP_ROUTE_DEFAULT_ALLOWED_ORIGINS: arc_swap::ArcSwap<Vec<String>> =
@@ -811,6 +855,55 @@ pub fn workspace_integration_auth_endpoint(client_name: &str, base_url: &str) ->
mod tests {
use super::*;
// `frontend/src/lib/tokenExpiration.test.ts` holds the same table for the token form's
// parser; the two must stay in step.
#[test]
fn max_token_expiration_days_accepts_only_whole_days_within_the_bound() {
use serde_json::json;
for (stored, days) in [
(json!(7), 7),
(json!(7.0), 7),
(json!("7"), 7),
(json!(" 30 "), 30),
(json!("+7"), 7),
(
json!(MAX_TOKEN_EXPIRATION_DAYS_BOUND),
MAX_TOKEN_EXPIRATION_DAYS_BOUND,
),
] {
assert_eq!(
parse_max_token_expiration_days(Some(&stored)),
Ok(Some(days)),
"{stored}"
);
}
for cleared in [json!(null), json!(""), json!(" ")] {
assert_eq!(
parse_max_token_expiration_days(Some(&cleared)),
Ok(None),
"{cleared}"
);
}
assert_eq!(parse_max_token_expiration_days(None), Ok(None));
for bad in [
json!(7.5),
json!(0),
json!(-3),
json!("7.0"),
json!("1e1"),
json!("0x7"),
json!(MAX_TOKEN_EXPIRATION_DAYS_BOUND + 1),
json!("99999999999999999999"),
json!(true),
json!([7]),
] {
assert!(
parse_max_token_expiration_days(Some(&bad)).is_err(),
"{bad} must be rejected"
);
}
}
#[test]
fn webhook_base_url_errors_never_echo_credentials() {
// These strings reach sync-config output and operator logs, so no branch may
@@ -1358,6 +1358,10 @@ pub async fn sync_global_settings_declarative(
crate::global_settings::parse_allowed_origins_setting(desired.get(origins_key))
.map_err(|e| anyhow::anyhow!("{origins_key}: {e}"))?;
let max_expiration_key = crate::global_settings::MAX_TOKEN_EXPIRATION_DAYS_SETTING;
crate::global_settings::parse_max_token_expiration_days(desired.get(max_expiration_key))
.map_err(|e| anyhow::anyhow!("{max_expiration_key}: {e}"))?;
let diff = diff_global_settings(current, desired, ApplyMode::Replace);
apply_settings_diff(db, &diff).await?;
@@ -10,7 +10,7 @@
use std::borrow::Cow;
use std::collections::{HashMap, HashSet};
use std::sync::RwLock;
use std::sync::{Arc, RwLock};
use uuid::Uuid;
/// Minimum length for a secret to be registered for masking.
@@ -20,9 +20,27 @@ const MIN_SECRET_LENGTH: usize = 8;
const MASKED_NOTICE: &str =
"[windmill] secret value was masked for security reasons, use string transformations to display full value";
/// The secrets registered for one job, plus the automaton compiled from them.
#[derive(Default)]
struct JobMasks {
secrets: HashSet<String>,
/// Built on the first `snapshot` after a change and shared by every later
/// snapshot. Every job registers at least its own token, so without this
/// cache each log batch of each job would rebuild the automaton.
compiled: Option<Arc<CompiledMasks>>,
}
/// Aho-Corasick automaton for O(m) multi-pattern matching in a single pass,
/// regardless of the number of secrets registered, with the replacement
/// strings indexed to match the automaton's pattern order.
struct CompiledMasks {
ac: aho_corasick::AhoCorasick,
replacements: Vec<String>,
}
lazy_static::lazy_static! {
/// Map of job_id -> set of secret values that should be masked in that job's logs.
static ref SENSITIVE_MASKS: RwLock<HashMap<Uuid, HashSet<String>>> =
/// Map of job_id -> secret values that should be masked in that job's logs.
static ref SENSITIVE_MASKS: RwLock<HashMap<Uuid, JobMasks>> =
RwLock::new(HashMap::new());
/// Set of currently running job IDs on this worker process.
@@ -32,13 +50,8 @@ lazy_static::lazy_static! {
}
/// A lock-free snapshot of secrets for a job, taken once per log batch.
/// Uses Aho-Corasick for O(m) multi-pattern matching in a single pass,
/// regardless of the number of secrets registered.
pub struct MaskSnapshot {
/// Aho-Corasick automaton for fast matching.
ac: aho_corasick::AhoCorasick,
/// Replacement strings, indexed to match the automaton's pattern order.
replacements: Vec<String>,
compiled: Arc<CompiledMasks>,
/// Whether the security notice has already been appended for this snapshot.
/// Tracked locally to avoid a global write lock on every masked line.
notice_shown: std::cell::Cell<bool>,
@@ -53,34 +66,104 @@ impl MaskSnapshot {
}
// Single-pass check + replace using the pre-built automaton
if !self.ac.is_match(text) {
if !self.compiled.ac.is_match(text) {
return Cow::Borrowed(text);
}
let mut result = self.ac.replace_all(text, &self.replacements);
let mut result = self
.compiled
.ac
.replace_all(text, &self.compiled.replacements);
// Append the notice only once per snapshot (i.e. per batch)
// Append the notice only once per snapshot (i.e. per batch), as its own line.
// Callers pass either a bare line (`handle_child`) or a chunk that already ends
// in a newline (nativets), and the sinks concatenate what they get verbatim:
// assuming either shape welds the notice onto a neighbouring line.
if !self.notice_shown.get() {
self.notice_shown.set(true);
result.push('\n');
result.push_str(MASKED_NOTICE);
if result.ends_with('\n') {
result.push_str(MASKED_NOTICE);
result.push('\n');
} else {
result.push('\n');
result.push_str(MASKED_NOTICE);
}
}
Cow::Owned(result)
}
}
/// A masker for sinks that mask line by line rather than in batches, like nativets
/// masking each `console.log` chunk as V8 produces it. `snapshot` per line would
/// re-arm the security notice on every one; this keeps it to once per distinct set
/// of secrets while still picking up secrets registered mid-run.
///
/// Masks by job id alone — the caller is the one that knows the text it passes
/// belongs to that job.
pub struct JobMasker {
job_id: Uuid,
snapshot: Option<MaskSnapshot>,
}
impl JobMasker {
pub fn new(job_id: Uuid) -> Self {
JobMasker { job_id, snapshot: snapshot(&job_id) }
}
/// Mask every secret registered for the job. Returns `Cow::Borrowed` when no match.
/// Falls back to the masks it last saw once the job is unregistered, so a sink
/// still draining past the end of a run does not start emitting secrets.
pub fn mask<'a>(&mut self, text: &'a str) -> Cow<'a, str> {
if let Some(fresh) = snapshot(&self.job_id) {
// Replacing an equivalent snapshot would re-arm the notice, so only take
// one built from a secret set we have not seen.
let unchanged = self
.snapshot
.as_ref()
.is_some_and(|cur| Arc::ptr_eq(&cur.compiled, &fresh.compiled));
if !unchanged {
self.snapshot = Some(fresh);
}
}
match self.snapshot.as_ref() {
Some(snapshot) => snapshot.mask(text),
None => Cow::Borrowed(text),
}
}
}
/// Take a snapshot of the current secrets for a job. Returns `None` if no secrets
/// are registered (the caller can then skip masking entirely for the whole batch).
///
/// Call this once per log batch in `write_lines`, not per line.
pub fn snapshot(job_id: &Uuid) -> Option<MaskSnapshot> {
let masks = SENSITIVE_MASKS.read().unwrap_or_else(|e| e.into_inner());
let secrets = masks.get(job_id)?;
if secrets.is_empty() {
return None;
{
let masks = SENSITIVE_MASKS.read().unwrap_or_else(|e| e.into_inner());
let job = masks.get(job_id)?;
if job.secrets.is_empty() {
return None;
}
if let Some(compiled) = job.compiled.as_ref() {
return Some(MaskSnapshot {
compiled: compiled.clone(),
notice_shown: std::cell::Cell::new(false),
});
}
}
let mut masks = SENSITIVE_MASKS.write().unwrap_or_else(|e| e.into_inner());
let job = masks.get_mut(job_id)?;
if job.secrets.is_empty() {
return None;
}
let compiled = job
.compiled
.get_or_insert_with(|| Arc::new(compile(&job.secrets)));
Some(MaskSnapshot { compiled: compiled.clone(), notice_shown: std::cell::Cell::new(false) })
}
fn compile(secrets: &HashSet<String>) -> CompiledMasks {
// Sort longest-first so longer secrets are matched before shorter substrings
let mut sorted: Vec<&String> = secrets.iter().collect();
sorted.sort_by(|a, b| b.len().cmp(&a.len()));
@@ -106,7 +189,7 @@ pub fn snapshot(job_id: &Uuid) -> Option<MaskSnapshot> {
.build(sorted.iter().map(|s| s.as_str()))
.expect("failed to build aho-corasick automaton");
Some(MaskSnapshot { ac, replacements, notice_shown: std::cell::Cell::new(false) })
CompiledMasks { ac, replacements }
}
/// Register a job as currently running. Call this before `handle_queued_job`.
@@ -148,20 +231,110 @@ pub fn register_secret_for_all_running_jobs(secret: &str) {
let mut masks = SENSITIVE_MASKS.write().unwrap_or_else(|e| e.into_inner());
for job_id in job_ids {
if let Some(set) = masks.get_mut(&job_id) {
set.insert(secret.to_string());
if let Some(job) = masks.get_mut(&job_id) {
if job.secrets.insert(secret.to_string()) {
job.compiled = None;
}
}
}
}
/// Register a secret value for a specific job.
/// Used for `$encrypted:` args where we know the job ID.
/// Used for the job's own token and for `$encrypted:` args, where we know the job ID.
pub fn register_secret_for_job(job_id: Uuid, secret: &str) {
if secret.len() < MIN_SECRET_LENGTH {
return;
}
let mut masks = SENSITIVE_MASKS.write().unwrap_or_else(|e| e.into_inner());
if let Some(set) = masks.get_mut(&job_id) {
set.insert(secret.to_string());
if let Some(job) = masks.get_mut(&job_id) {
if job.secrets.insert(secret.to_string()) {
job.compiled = None;
}
}
}
#[cfg(test)]
mod tests {
use super::*;
/// The compiled automaton is cached per job, so a secret registered after the
/// first snapshot only gets masked if the cache is invalidated.
#[test]
fn snapshot_rebuilds_after_a_new_secret_is_registered() {
let job_id = Uuid::new_v4();
register_running_job(job_id);
register_secret_for_job(job_id, "firstsecretvalue");
let _ = snapshot(&job_id)
.expect("secret registered")
.mask("firstsecretvalue");
register_secret_for_job(job_id, "secondsecretvalue");
let snap = snapshot(&job_id).expect("secrets registered");
let masked = snap.mask("firstsecretvalue then secondsecretvalue");
assert!(!masked.contains("firstsecretvalue"), "{masked}");
assert!(!masked.contains("secondsecretvalue"), "{masked}");
unregister_running_job(job_id);
}
/// A line-by-line sink must not repeat the notice on every line, and must still
/// pick up a secret registered after the masker was built.
#[test]
fn job_masker_notices_once_per_secret_set() {
let job_id = Uuid::new_v4();
register_running_job(job_id);
register_secret_for_job(job_id, "firstsecretvalue");
let mut masker = JobMasker::new(job_id);
let first = masker.mask("saw firstsecretvalue").into_owned();
assert!(!first.contains("firstsecretvalue"), "{first}");
assert!(first.contains(MASKED_NOTICE), "{first}");
let second = masker.mask("saw firstsecretvalue again").into_owned();
assert!(!second.contains("firstsecretvalue"), "{second}");
assert!(!second.contains(MASKED_NOTICE), "{second}");
register_secret_for_job(job_id, "secondsecretvalue");
let third = masker.mask("saw secondsecretvalue").into_owned();
assert!(!third.contains("secondsecretvalue"), "{third}");
unregister_running_job(job_id);
}
/// Unregistration must not turn masking off under a sink that is still emitting:
/// the masker keeps working off the masks it last saw rather than going quiet.
#[test]
fn job_masker_masks_after_the_job_is_unregistered() {
let job_id = Uuid::new_v4();
register_running_job(job_id);
register_secret_for_job(job_id, "supersecretvalue");
let mut masker = JobMasker::new(job_id);
unregister_running_job(job_id);
let masked = masker.mask("logged supersecretvalue here");
assert!(!masked.contains("supersecretvalue"), "{masked}");
}
/// The notice has to end up on a line of its own for both shapes callers pass:
/// a bare line (`handle_child`) and a newline-terminated chunk (nativets). The
/// sinks concatenate what they are given verbatim, so getting this wrong welds
/// the notice onto whichever line follows it.
#[test]
fn notice_lands_on_its_own_line_for_both_caller_shapes() {
let job_id = Uuid::new_v4();
register_running_job(job_id);
register_secret_for_job(job_id, "supersecretvalue");
let line = snapshot(&job_id)
.expect("secret registered")
.mask("tok supersecretvalue");
assert_eq!(line, format!("tok s*****e\n{MASKED_NOTICE}"));
let chunk = snapshot(&job_id)
.expect("secret registered")
.mask("tok supersecretvalue\n");
assert_eq!(chunk, format!("tok s*****e\n{MASKED_NOTICE}\n"));
unregister_running_job(job_id);
}
}
+313 -8
View File
@@ -160,6 +160,34 @@ impl UserDraftItemKind {
}
}
/// The `draft.value` key holding the user-typed target path — where a
/// deploy of this draft would land when the user staged a rename. A script
/// draft round-trips its own `path`; flows and apps write a separate
/// `draft_path`, and only when it differs from the row's path.
///
/// `None` for every kind with no editor to stage a rename in — a resource,
/// a variable, a trigger. Their drafts carry no such key, so `Some` is also
/// the test for whether a kind can be moved at all.
pub fn typed_path_field(&self) -> Option<&'static str> {
use UserDraftItemKind::*;
match self {
Script => Some("path"),
Flow | App | RawApp => Some("draft_path"),
_ => None,
}
}
/// The other of the two path keys a draft value can carry. The editors mirror
/// the typed path into it while it differs from the row's path, so a move that
/// rewrote only `typed_path_field` would leave the mirror naming the old
/// location — and the loaders prefer the mirror, which un-does the move.
pub fn mirror_path_field(&self) -> Option<&'static str> {
match self.typed_path_field()? {
"path" => Some("draft_path"),
_ => Some("path"),
}
}
/// Whether OTHER users' drafts at a path are visible to a viewer (the
/// "others are editing" list, owner circles, and the `get_draft_for_user`
/// View JSON / Fork endpoint). Enabled only for the full-page editor items
@@ -212,6 +240,12 @@ pub struct WithDraftOverlay {
pub is_draft: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub draft_saved_at: Option<DateTime<Utc>>,
/// The deployed version the draft forked from (`draft.base`), as text
/// whatever the kind. The editor compares it to the head it loaded to tell
/// a draft that is behind. Absent when there is no draft or it was never
/// forked from a deploy.
#[serde(skip_serializing_if = "Option::is_none")]
pub draft_base: Option<String>,
/// True when no deployed row exists at this path: `inner` is only a
/// best-effort stand-in synthesized from the draft and only `draft` is
/// canonical. Frontend uses this to disable "diff/reset vs deployed" and
@@ -297,6 +331,7 @@ where
inner: Box::new(deployed),
is_draft: false,
draft_saved_at: None,
draft_base: None,
no_deployed: false,
draft: None,
other_drafts_users: Vec::new(),
@@ -317,7 +352,7 @@ where
// row when an owned one exists.
let row = sqlx::query!(
r#"SELECT value as "value!: sqlx::types::Json<Box<serde_json::value::RawValue>>",
created_at
created_at, base
FROM draft
WHERE workspace_id = $1
AND (email = $2 OR email IS NULL)
@@ -338,6 +373,7 @@ where
inner: Box::new(deployed),
is_draft: false,
draft_saved_at: None,
draft_base: None,
no_deployed: false,
draft: None,
other_drafts_users,
@@ -350,6 +386,7 @@ where
inner: Box::new(deployed),
is_draft: true,
draft_saved_at: Some(row.created_at),
draft_base: row.base,
no_deployed: false,
draft: Some(draft_json),
other_drafts_users,
@@ -468,7 +505,8 @@ pub async fn overlay_or_draft_only<T: serde::Serialize + Send + 'static>(
}
}
/// Delete the drafts an address owns, across every workspace.
/// Delete the drafts an address owns, and the move records routing saves to them,
/// across every workspace.
///
/// `draft.email` carries no foreign key to `password`: a draft's owner is any principal the
/// instance authenticates, and an external JWT's subject never has a `password` row. Deleting an
@@ -479,12 +517,14 @@ pub async fn overlay_or_draft_only<T: serde::Serialize + Send + 'static>(
/// No authorization of its own: it acts instance-wide on whatever address it is handed, so the
/// caller must already have authorized removing that account (superadmin, the account's own
/// holder, or SCIM).
pub async fn delete_drafts_of_email<'c>(
executor: impl sqlx::PgExecutor<'c>,
email: &str,
) -> Result<()> {
pub async fn delete_drafts_of_email(conn: &mut sqlx::PgConnection, email: &str) -> Result<()> {
sqlx::query!("DELETE FROM draft WHERE email = $1", email)
.execute(executor)
.execute(&mut *conn)
.await?;
// The records routing saves to those drafts go with them: one left behind would send
// a later holder of the address to a draft that no longer exists.
sqlx::query!("DELETE FROM draft_move WHERE email = $1", email)
.execute(&mut *conn)
.await?;
Ok(())
}
@@ -526,6 +566,29 @@ pub async fn rename_drafts_of_email(
)
.execute(&mut *conn)
.await?;
// The records that route saves to those drafts follow the same address, or a save
// still addressed to a path the account moved away from would start a second draft
// there. The moving account's record for a path wins, as its draft just did.
sqlx::query!(
"DELETE FROM draft_move dest
WHERE dest.email = $1
AND EXISTS (SELECT 1 FROM draft_move src
WHERE src.email = $2
AND src.workspace_id = dest.workspace_id
AND src.old_path = dest.old_path
AND src.typ = dest.typ)",
new_email,
old_email
)
.execute(&mut *conn)
.await?;
sqlx::query!(
"UPDATE draft_move SET email = $1 WHERE email = $2",
new_email,
old_email
)
.execute(&mut *conn)
.await?;
Ok(())
}
@@ -582,6 +645,247 @@ pub async fn delete_own_draft_for_path(
Ok(())
}
/// Carry every draft at `old_path` over to `new_path` when an item MOVES
/// (rename or relocation). A draft is bound to its item by nothing but the path
/// string, so without this a move detaches every draft on the item. No owner
/// filter: teammates' rows and the legacy NULL-email row follow too.
///
/// **The caller must have authorized the underlying item move first.** This rewrites
/// rows the caller has no permission on and enforces nothing itself, taking both paths
/// on trust: it is safe only inside a deploy that has already cleared them, and is a
/// cross-user write with no gate anywhere else.
///
/// The value keeps its base version, so every carried draft reads as behind the head the
/// move minted. Of its two path keys, one still naming `old_path` follows the row (the
/// editors write the item's own path there on every save, so it is the item's path and
/// not a staged rename); any other value is a rename the user staged, and is kept.
///
/// A draft already at `new_path` occupies it the way a deployed item does, so the move is
/// refused with `BadRequest` inside the deploy's transaction, refusing the rename itself:
/// moving onto it would merge two items or strand the row that lost. A row a `draft_move`
/// record already brought there from `old_path` is this rename's own draft and does not
/// count: deploying it would otherwise be refused against itself, with no way out.
pub async fn move_drafts_for_path(
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
w_id: &str,
kinds: &[UserDraftItemKind],
old_path: &str,
new_path: &str,
) -> Result<()> {
let typs = kinds.iter().map(|k| k.as_str()).collect::<Vec<_>>();
// Named by workspace username, as the editors name other users' drafts: the
// caller is often not the owner, and cannot clear a draft they cannot find.
let owners = sqlx::query!(
r#"SELECT d.email IS NULL as "legacy!", COALESCE(u.username, p.username) as username
FROM draft d
LEFT JOIN usr u ON u.workspace_id = d.workspace_id AND u.email = d.email
LEFT JOIN password p ON p.email = d.email AND p.super_admin = true
WHERE d.workspace_id = $1 AND d.path = $2 AND d.typ::text = ANY($3::text[])
-- A row a move already brought here from the very path being renamed is this
-- rename's own draft, not an item in its way: the record says which owners
-- it moved (all of them for an item move, one for a draft-only move). Any
-- other row is a second item and still collides.
AND NOT EXISTS (
SELECT 1 FROM draft_move m
WHERE m.workspace_id = $1 AND m.typ::text = ANY($3::text[])
AND m.old_path = $4 AND m.new_path = $2
AND (m.email IS NULL OR m.email IS NOT DISTINCT FROM d.email)
)
ORDER BY 2"#,
w_id,
new_path,
&typs as &[&str],
old_path,
)
.fetch_all(&mut **tx)
.await?;
if !owners.is_empty() {
let names = owners
.into_iter()
.map(|o| match (o.username, o.legacy) {
(Some(name), _) => name,
(None, true) => "a legacy workspace draft".to_string(),
(None, false) => "another user".to_string(),
})
.collect::<Vec<_>>()
.join(", ");
return Err(crate::error::Error::BadRequest(format!(
"'{new_path}' already has a draft on it ({names}) — it must be moved or discarded first"
)));
}
// `draft.value` is `json`, so a NUL escape left by a pre-sanitizer write makes
// `to_jsonb` raise 22P05. `clean` is `strip_json_nul`'s parity rule in SQL (escaped
// backslashes parked on chr(1), which a `json` value's text cannot hold, so nothing
// collides with it; chr(92) spells the backslash so no escape sequence reaches this
// file). A row whose keys need re-pointing is therefore rewritten clean rather than
// left naming the old path, and one that needs nothing keeps its value byte for byte.
sqlx::query!(
r#"UPDATE draft AS d
SET path = $3::text,
value = (
SELECT CASE
WHEN s.clean -> 'path' = to_jsonb($2::text)
OR s.clean -> 'draft_path' = to_jsonb($2::text)
THEN to_json(
s.clean
|| CASE WHEN s.clean -> 'path' = to_jsonb($2::text)
THEN jsonb_build_object('path', $3::text)
ELSE '{}'::jsonb END
|| CASE WHEN s.clean -> 'draft_path' = to_jsonb($2::text)
THEN jsonb_build_object('draft_path', $3::text)
ELSE '{}'::jsonb END
)
ELSE d.value
END
FROM (SELECT replace(replace(replace(d.value::text, chr(92) || chr(92), chr(1)),
chr(92) || 'u0000', ''), chr(1), chr(92) || chr(92))::jsonb AS clean) s
)
WHERE d.workspace_id = $1
AND d.path = $2::text
AND d.typ::text = ANY($4::text[])"#,
w_id,
old_path,
new_path,
&typs as &[&str],
)
.execute(&mut **tx)
.await?;
record_draft_move(tx, w_id, kinds, old_path, new_path, None).await
}
/// Record that the drafts at `old_path` now live at `new_path`, so a draft save still
/// addressed to `old_path` lands on them (see `update_draft`). `email` scopes the
/// record to one user's draft-only move; `None` is a deployed item's move, for everyone.
///
/// Kept to one hop: records pointing at `old_path` are re-pointed (an owner's move
/// leaves the item's own record alone and copies it into its own scope instead), and
/// records leaving either path are replaced, since `new_path` now holds the item.
///
/// **The caller must have authorized the move first.** A record routes every later
/// draft write at `old_path` (any owner's, for an item move), and enforces nothing
/// itself.
///
/// A record outlives the editors that need it: it ends when a later move touches either
/// path or an item is deployed at `old_path`, so a save meaning to start a NEW draft at a
/// vacated path would be routed instead. Nothing does that today: every surface parks a
/// new item at a minted `u/<user>/draft_<uuid>` key (`mintDraftPath.ts`).
pub async fn record_draft_move(
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
w_id: &str,
kinds: &[UserDraftItemKind],
old_path: &str,
new_path: &str,
email: Option<&str>,
) -> Result<()> {
let typs = kinds.iter().map(|k| k.as_str()).collect::<Vec<_>>();
sqlx::query!(
"DELETE FROM draft_move
WHERE workspace_id = $1 AND typ::text = ANY($2::text[])
AND old_path IN ($3, $4) AND ($5::text IS NULL OR email = $5)",
w_id,
&typs as &[&str],
old_path,
new_path,
email,
)
.execute(&mut **tx)
.await?;
// Routes that ended at the destination before this move describe drafts that were
// carried there for an item this one is replacing: left alive, a save addressed to
// the start of that chain would land on this move's draft instead.
sqlx::query!(
"DELETE FROM draft_move
WHERE workspace_id = $1 AND typ::text = ANY($2::text[])
AND new_path = $4 AND old_path <> $3 AND ($5::text IS NULL OR email = $5)",
w_id,
&typs as &[&str],
old_path,
new_path,
email,
)
.execute(&mut **tx)
.await?;
sqlx::query!(
"UPDATE draft_move SET new_path = $4
WHERE workspace_id = $1 AND typ::text = ANY($2::text[])
AND new_path = $3 AND ($5::text IS NULL OR email = $5)",
w_id,
&typs as &[&str],
old_path,
new_path,
email,
)
.execute(&mut **tx)
.await?;
// An owner's move must not re-point what everyone else follows, so the item's own
// records ending at `old_path` are copied into this scope: a save addressed to the
// start of that chain still reaches this destination in one hop. Only those: another
// user's owner-scoped record routes their own draft-only item, which this move has
// nothing to do with.
if email.is_some() {
sqlx::query!(
"INSERT INTO draft_move (workspace_id, typ, old_path, new_path, email)
SELECT m.workspace_id, m.typ, m.old_path, $4, $5::text
FROM draft_move m
WHERE m.workspace_id = $1 AND m.typ::text = ANY($2::text[])
AND m.new_path = $3 AND m.email IS NULL
AND NOT EXISTS (
SELECT 1 FROM draft_move o
WHERE o.workspace_id = m.workspace_id AND o.typ = m.typ
AND o.old_path = m.old_path AND o.email = $5::text
)",
w_id,
&typs as &[&str],
old_path,
new_path,
email,
)
.execute(&mut **tx)
.await?;
}
sqlx::query!(
"INSERT INTO draft_move (workspace_id, typ, old_path, new_path, email)
SELECT $1, t::draft_kind, $3, $4, $5 FROM unnest($2::text[]) t",
w_id,
&typs as &[&str],
old_path,
new_path,
email,
)
.execute(&mut **tx)
.await?;
Ok(())
}
/// Drop the move records at `path`: an item was just created there, so saves addressed to
/// it are its own (records leaving `path`). A deploy that is not a rename also drops the
/// records arriving, which point at an item that no longer owns the path; `keep_from`,
/// the path a rename came from, suppresses that, since the chain ending here is the one
/// this very deploy just wrote.
///
/// **The caller must have authorized the deploy that created the item first.**
/// Dropping a record sends later draft writes at `path` back to `path`.
pub async fn clear_draft_moves_from(
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
w_id: &str,
kinds: &[UserDraftItemKind],
path: &str,
keep_from: Option<&str>,
) -> Result<()> {
let typs = kinds.iter().map(|k| k.as_str()).collect::<Vec<_>>();
sqlx::query!(
"DELETE FROM draft_move WHERE workspace_id = $1 AND typ::text = ANY($2::text[])
AND (old_path = $3 OR (new_path = $3 AND $4::text IS NULL))",
w_id,
&typs as &[&str],
path,
keep_from,
)
.execute(&mut **tx)
.await?;
Ok(())
}
/// Fetch the authed user's draft as a standalone payload, for "get by path"
/// routes when no deployed row exists but a draft might. Returns it as a
/// `WithDraftOverlay` with `inner` and `draft` both set to the same JSON and
@@ -600,7 +904,7 @@ pub async fn fetch_draft_only(
// Own draft first, legacy NULL-email row as fallback (see `maybe_overlay_draft`).
let row = sqlx::query!(
r#"SELECT value as "value!: sqlx::types::Json<Box<serde_json::value::RawValue>>",
created_at
created_at, base
FROM draft
WHERE workspace_id = $1
AND (email = $2 OR email IS NULL)
@@ -631,6 +935,7 @@ pub async fn fetch_draft_only(
inner: Box::new(draft_json.clone()),
is_draft: true,
draft_saved_at: Some(row.created_at),
draft_base: row.base,
no_deployed: true,
draft: Some(draft_json),
other_drafts_users,
+47
View File
@@ -479,6 +479,28 @@ pub fn paginate(pagination: Pagination) -> (usize, usize) {
(per_page, offset)
}
/// [`paginate`] for a listing that answers whole unless a size is asked for: the deploy
/// histories, which the history panels and the CLI read unpaged while the diff picker takes
/// a page at a time. An asked-for size is still clamped, and the offset saturates rather
/// than wrapping, so no caller can turn this into an unbounded scan or a negative bind.
pub fn paginate_optional(pagination: Pagination) -> (i64, i64) {
// Naming neither parameter asks for the whole listing, the contract these endpoints
// have always answered on. Naming either makes it a page like any other listing's.
if pagination.page.is_none() && pagination.per_page.is_none() {
return (i64::MAX, 0);
}
let per_page = pagination
.per_page
.unwrap_or(DEFAULT_PER_PAGE)
.clamp(1, MAX_PER_PAGE) as i64;
// Bound before Postgres sees it: an unchecked cast of a caller-controlled page becomes
// a negative OFFSET, which is an error rather than an empty page.
let offset = i64::try_from(pagination.page.unwrap_or(1).max(1) - 1)
.unwrap_or(i64::MAX)
.saturating_mul(per_page);
(per_page, offset)
}
pub fn paginate_without_limits(pagination: Pagination) -> (usize, usize) {
let per_page = pagination.per_page.unwrap_or(MAX_PER_PAGE);
let offset = (pagination.page.unwrap_or(1).max(1) - 1) * per_page;
@@ -1672,6 +1694,31 @@ pub fn truncate_with_ellipsis(s: &str, max_chars: usize) -> String {
mod tests {
use super::*;
#[test]
fn test_paginate_optional_answers_whole_but_bounds_what_is_asked_for() {
// Nothing asked for: every row there can be, which is what the history panels and
// the CLI read.
assert_eq!(
paginate_optional(Pagination { page: None, per_page: None }),
(i64::MAX, 0)
);
assert_eq!(
paginate_optional(Pagination { page: Some(3), per_page: Some(20) }),
(20, 40)
);
// An asked-for size is still capped, so no caller turns this into an unbounded scan.
assert_eq!(
paginate_optional(Pagination { page: None, per_page: Some(usize::MAX) }),
(MAX_PER_PAGE as i64, 0)
);
// A page nobody could mean lands past the end rather than going negative, which
// Postgres would reject outright.
let (per_page, offset) =
paginate_optional(Pagination { page: Some(usize::MAX), per_page: Some(20) });
assert_eq!(per_page, 20);
assert_eq!(offset, i64::MAX);
}
/// A 5-field crontab line is the most common way to get a schedule rejected, and both
/// parsers report it in terms a crontab user cannot act on, so the seconds field and the
/// equivalent expression must reach the caller for v1 and v2 alike.

Some files were not shown because too many files have changed in this diff Show More