Files
windmill/backend/windmill-common/src/user_drafts.rs
T
GuilhemandClaude Opus 5 74c5f82c67 feat: let operators with manage rights save schedule and trigger drafts (#11646)
* feat: operators get AI sessions

`prefersSessionHandoff` stops taking the role, so every AI entry point an operator
can reach — Ask AI, the home composer, Open in session, the flow editor's hand-off,
the raw-app template picker — opens a session for them like anyone else. The
sessions page drops its refusal, and the layout stops keeping them on the docked
chat.

In the sidebar they now get the Workspace/AI Sessions switch and session mode, and
lose the Ask AI entry that stood in for them, since the pane behind it is no longer
the one they are sent to.

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

* feat: operators get run-oriented examples in the home composer

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

* docs: note builder_flows in the operator composer examples comment

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

* feat: gate session tools and editors on operator rights

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

* feat: keep operator AI sessions behind a browser opt-in

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

* fix: keep flows refused in the prompt for operators without builder rights

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

* refactor: decide every frontend edit check through one editRights module

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

* fix: let admins who are also operators open session editors, as drafts.rs does

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

* feat: let operators with manage rights save schedule and trigger drafts

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

* fix: keep rebase_draft from operators who can only draft schedules and triggers

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

* fix: list operators' schedule and trigger drafts under their draft rights

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

* docs: say which drafts write_draft gates now that schedules and triggers have their own

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

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-14 15:50:31 +02:00

982 lines
40 KiB
Rust

/*
* Author: Diego Imbert
* Copyright: Windmill Labs, Inc 2026
* This file and its contents are licensed under the AGPLv3 License.
* Please see the included NOTICE for copyright information and
* LICENSE-AGPL for a copy of the license.
*/
//! Shared types and helpers for the per-user `draft` table. Lives in
//! `windmill-common` so entity crates can use it without depending on the
//! top-level `windmill-api` crate. Keep it free of HTTP/axum concerns.
// `DraftUserRef` lives in `windmill-types` (where the list-endpoint row
// structs `ListableScript`/`ListableFlow` declare `Vec<DraftUserRef>` and
// can't reach `windmill-common` without a cycle). Re-exported here so draft
// handlers keep a single import path.
pub use windmill_types::user_drafts::DraftUserRef;
use crate::db::DB;
use crate::error::Result;
use crate::workspaces::ManageKind;
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
/// Item kinds a user can have an autosaved draft on. Must stay in lockstep
/// with the frontend `USER_DRAFT_ITEM_KINDS` and the Postgres `DRAFT_KIND`
/// enum (adding a kind also needs an `ALTER TYPE ... ADD VALUE` migration).
/// `snake_case` is the shared wire/DB encoding (HTTP params, JSON, `draft.typ`).
#[derive(sqlx::Type, Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[sqlx(type_name = "DRAFT_KIND", rename_all = "snake_case")]
#[serde(rename_all = "snake_case")]
pub enum UserDraftItemKind {
Script,
Flow,
App,
RawApp,
Resource,
Variable,
TriggerSchedule,
TriggerWebhook,
TriggerDefaultEmail,
TriggerEmail,
TriggerHttp,
TriggerWebsocket,
TriggerPostgres,
TriggerKafka,
TriggerNats,
TriggerMqtt,
TriggerAmqp,
TriggerSqs,
TriggerGcp,
TriggerAzure,
TriggerPoll,
TriggerCli,
TriggerNextcloud,
TriggerGoogle,
TriggerGithub,
/// All unsaved scripts of one data pipeline, bundled into a single draft
/// keyed at the pipeline's folder path. Not a runnable: it has no deployed
/// backing table and is private to its owner.
DataPipeline,
}
impl UserDraftItemKind {
/// The snake_case wire/DB string, for interpolating into dynamically-built
/// SQL (`?::DRAFT_KIND` binds want a string).
pub fn as_str(&self) -> &'static str {
match self {
UserDraftItemKind::Script => "script",
UserDraftItemKind::Flow => "flow",
UserDraftItemKind::App => "app",
UserDraftItemKind::RawApp => "raw_app",
UserDraftItemKind::Resource => "resource",
UserDraftItemKind::Variable => "variable",
UserDraftItemKind::TriggerSchedule => "trigger_schedule",
UserDraftItemKind::TriggerWebhook => "trigger_webhook",
UserDraftItemKind::TriggerDefaultEmail => "trigger_default_email",
UserDraftItemKind::TriggerEmail => "trigger_email",
UserDraftItemKind::TriggerHttp => "trigger_http",
UserDraftItemKind::TriggerWebsocket => "trigger_websocket",
UserDraftItemKind::TriggerPostgres => "trigger_postgres",
UserDraftItemKind::TriggerKafka => "trigger_kafka",
UserDraftItemKind::TriggerNats => "trigger_nats",
UserDraftItemKind::TriggerMqtt => "trigger_mqtt",
UserDraftItemKind::TriggerAmqp => "trigger_amqp",
UserDraftItemKind::TriggerSqs => "trigger_sqs",
UserDraftItemKind::TriggerGcp => "trigger_gcp",
UserDraftItemKind::TriggerAzure => "trigger_azure",
UserDraftItemKind::TriggerPoll => "trigger_poll",
UserDraftItemKind::TriggerCli => "trigger_cli",
UserDraftItemKind::TriggerNextcloud => "trigger_nextcloud",
UserDraftItemKind::TriggerGoogle => "trigger_google",
UserDraftItemKind::TriggerGithub => "trigger_github",
UserDraftItemKind::DataPipeline => "data_pipeline",
}
}
/// Every variant, for code that must enumerate kinds (e.g. generating
/// the `draft_only` existence SQL).
pub const ALL: [UserDraftItemKind; 26] = [
UserDraftItemKind::Script,
UserDraftItemKind::Flow,
UserDraftItemKind::App,
UserDraftItemKind::RawApp,
UserDraftItemKind::Resource,
UserDraftItemKind::Variable,
UserDraftItemKind::TriggerSchedule,
UserDraftItemKind::TriggerWebhook,
UserDraftItemKind::TriggerDefaultEmail,
UserDraftItemKind::TriggerEmail,
UserDraftItemKind::TriggerHttp,
UserDraftItemKind::TriggerWebsocket,
UserDraftItemKind::TriggerPostgres,
UserDraftItemKind::TriggerKafka,
UserDraftItemKind::TriggerNats,
UserDraftItemKind::TriggerMqtt,
UserDraftItemKind::TriggerAmqp,
UserDraftItemKind::TriggerSqs,
UserDraftItemKind::TriggerGcp,
UserDraftItemKind::TriggerAzure,
UserDraftItemKind::TriggerPoll,
UserDraftItemKind::TriggerCli,
UserDraftItemKind::TriggerNextcloud,
UserDraftItemKind::TriggerGoogle,
UserDraftItemKind::TriggerGithub,
UserDraftItemKind::DataPipeline,
];
/// The deployed table backing this kind, keyed by `(workspace_id, path)`.
/// SINGLE SOURCE for both the draft access check (which table RLS resolves
/// item-level `extra_perms` against) and the `draft_only` existence check.
/// `None` for kinds with no per-path backing table (webhook is a property
/// of a script/flow row; native triggers are keyed by external_id, not
/// path) — callers treat that as "no deployed counterpart": `draft_only =
/// true` and a path-only access check.
pub fn deployed_table(&self) -> Option<&'static str> {
use UserDraftItemKind::*;
match self {
Script => Some("script"),
Flow => Some("flow"),
App | RawApp => Some("app"),
Resource => Some("resource"),
Variable => Some("variable"),
TriggerSchedule => Some("schedule"),
TriggerHttp => Some("http_trigger"),
TriggerWebsocket => Some("websocket_trigger"),
TriggerPostgres => Some("postgres_trigger"),
TriggerKafka => Some("kafka_trigger"),
TriggerNats => Some("nats_trigger"),
TriggerMqtt => Some("mqtt_trigger"),
TriggerAmqp => Some("amqp_trigger"),
TriggerSqs => Some("sqs_trigger"),
TriggerGcp => Some("gcp_trigger"),
TriggerAzure => Some("azure_trigger"),
TriggerEmail | TriggerDefaultEmail => Some("email_trigger"),
TriggerWebhook | TriggerPoll | TriggerCli | TriggerNextcloud | TriggerGoogle
| TriggerGithub => None,
// Keyed at a folder path, not a runnable; access falls back to the
// path-only (folder write) check.
DataPipeline => None,
}
}
/// The withdrawable operator right that governs deploying this kind, which is
/// therefore also the right to draft it. Every trigger kind maps here, native ones
/// included: `gate_operator_writes` covers their routers as well.
pub fn manage_kind(&self) -> Option<ManageKind> {
use UserDraftItemKind::*;
match self {
TriggerSchedule => Some(ManageKind::Schedules),
TriggerWebhook | TriggerDefaultEmail | TriggerEmail | TriggerHttp
| TriggerWebsocket | TriggerPostgres | TriggerKafka | TriggerNats | TriggerMqtt
| TriggerAmqp | TriggerSqs | TriggerGcp | TriggerAzure | TriggerPoll | TriggerCli
| TriggerNextcloud | TriggerGoogle | TriggerGithub => Some(ManageKind::Triggers),
Script | Flow | App | RawApp | Resource | Variable | DataPipeline => None,
}
}
/// 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
/// which have the cross-user draft UI. Drawer items keep drafts private to
/// their owner: they have no such UI, and exposing a secret variable draft
/// would hand out the `$encrypted:` ciphertext, which a viewer could
/// launder into plaintext via a deploy.
pub fn shares_drafts_across_users(&self) -> bool {
use UserDraftItemKind::*;
matches!(self, Script | Flow | App | RawApp)
}
}
/// Query-string flag accepted by every "get by path" route that supports
/// the draft overlay. `#[serde(flatten)]` into a route-specific query struct
/// when the route has other query fields.
#[derive(Debug, Deserialize, Default)]
pub struct WithDraftQuery {
/// When true, attach the authed user's draft (if any) as a separate
/// `draft` field. Defaults to false so non-editor callers see the
/// deployed shape unchanged.
#[serde(default)]
pub get_draft: bool,
}
/// One row of `other_drafts_users`: a draft on the same path owned by
/// someone other than the authed user. `username` is `None` for the legacy
/// NULL-email row, surfaced in the frontend as a "Legacy draft" entry.
#[derive(Debug, Serialize)]
pub struct OtherDraftUser {
/// `None` represents a legacy workspace-level draft (no owner).
pub username: Option<String>,
/// When this user's draft was last saved (the `draft.created_at` upsert
/// timestamp), surfaced in the fork modal as "Last updated".
pub draft_saved_at: DateTime<Utc>,
}
/// Response wrapper: the deployed entity untouched plus the authed user's
/// draft (if any) as a sibling `draft` field, which the frontend pairs to
/// diff/restore/discard. The deployed and the draft are NEVER merged on the
/// server — the editor's saved shape can diverge arbitrarily, so any per-kind
/// translation lives in the frontend loader. `inner` is boxed-erased so a
/// possibly MB-scale deployed payload serializes in ONE pass (no
/// `serde_json::Value` round-trip) while keeping the struct non-generic.
#[derive(Serialize)]
pub struct WithDraftOverlay {
/// Deployed payload, flattened to the top level.
#[serde(flatten)]
pub inner: Box<dyn erased_serde::Serialize + Send>,
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
/// skip its deployed-shape parsing of `inner`. Omitted when false.
#[serde(skip_serializing_if = "std::ops::Not::not")]
pub no_deployed: bool,
/// The user's saved draft payload (whatever shape the editor wrote).
/// Present when `get_draft=true` and a draft exists.
#[serde(skip_serializing_if = "Option::is_none")]
pub draft: Option<serde_json::Value>,
/// Other users with a draft on the same path (excludes the authed user).
/// Empty list is omitted to keep the common-case response lean.
#[serde(skip_serializing_if = "Vec::is_empty")]
pub other_drafts_users: Vec<OtherDraftUser>,
}
/// List every other user (and the legacy NULL-email row, if any) with a
/// draft at `(workspace, kind, path)`. Returns usernames only — emails never
/// leave the server. LEFT JOIN against `usr` so an orphaned draft (user
/// removed from the workspace) still surfaces with `username = None`. The
/// authed user is excluded via `email <> authed_email`; the legacy row
/// matches because `email IS NULL` fails that comparison.
async fn fetch_other_drafts_users(
db: &DB,
w_id: &str,
authed_email: &str,
kind: UserDraftItemKind,
path: &str,
) -> Result<Vec<OtherDraftUser>> {
// A superadmin authoring in a workspace they are not a member of has no `usr`
// row: fall back to their instance-derived username (`password.username`), or
// their email when derivation is disabled. Else a real teammate's draft renders
// as a phantom "Legacy draft". The genuine NULL-email legacy row keeps
// `username = None` (no `usr`/`password` match and `d.email` is NULL), which is
// why an owner that resolves to no name at all — an external JWT's subject has
// neither row — is dropped instead: `None` is taken to mean "legacy" downstream.
let rows = sqlx::query_as!(
OtherDraftUser,
r#"SELECT COALESCE(u.username, p.username, CASE WHEN p.email IS NOT NULL THEN d.email END) as "username?",
d.created_at as "draft_saved_at!"
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 = $3
AND (d.email IS NULL OR d.email <> $4)
AND (d.email IS NULL OR u.username IS NOT NULL OR p.email IS NOT NULL)
ORDER BY d.email NULLS LAST"#,
w_id,
path,
kind as UserDraftItemKind,
authed_email,
)
.fetch_all(db)
.await?;
Ok(rows)
}
/// If `get_draft` is true AND the authed user has a draft for
/// `(workspace, kind, path)`, attach it as `draft`. `deployed` is always
/// serialized into `inner` untouched.
pub async fn maybe_overlay_draft<T>(
db: &DB,
w_id: &str,
email: &str,
kind: UserDraftItemKind,
path: &str,
get_draft: bool,
deployed: T,
) -> Result<WithDraftOverlay>
where
T: serde::Serialize + Send + 'static,
{
// Non-editor callers (worker/CLI reads of possibly MB-scale flows/apps)
// pass `get_draft = false` and render no overlay, so skip the `usr` join.
if !get_draft {
return Ok(WithDraftOverlay {
inner: Box::new(deployed),
is_draft: false,
draft_saved_at: None,
draft_base: None,
no_deployed: false,
draft: None,
other_drafts_users: Vec::new(),
});
}
// Independent of the authed user's OWN draft: reset-to-deployed reloads
// still need to know who else is editing this path. Only the cross-user
// kinds surface it (see `shares_drafts_across_users`).
let other_drafts_users = if kind.shares_drafts_across_users() {
fetch_other_drafts_users(db, w_id, email, kind, path).await?
} else {
Vec::new()
};
// Prefer the user's OWN per-user draft, falling back to the legacy
// NULL-email workspace draft. `NULLS LAST` + `LIMIT 1` drops the legacy
// 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, base
FROM draft
WHERE workspace_id = $1
AND (email = $2 OR email IS NULL)
AND path = $3
AND typ = $4
ORDER BY email NULLS LAST
LIMIT 1"#,
w_id,
email,
path,
kind as UserDraftItemKind,
)
.fetch_optional(db)
.await?;
let Some(row) = row else {
return Ok(WithDraftOverlay {
inner: Box::new(deployed),
is_draft: false,
draft_saved_at: None,
draft_base: None,
no_deployed: false,
draft: None,
other_drafts_users,
});
};
let draft_json: serde_json::Value = serde_json::from_str(row.value.0.get())?;
Ok(WithDraftOverlay {
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,
})
}
/// One row of a "draft-only" list synthesis: a draft at `path` with no
/// deployed counterpart. `value` is the editor's saved JSON (each handler
/// maps it into its own `Listable*` shape).
#[derive(sqlx::FromRow)]
pub struct DraftOnlyListRow {
pub path: String,
pub value: sqlx::types::Json<Box<serde_json::value::RawValue>>,
pub created_at: DateTime<Utc>,
}
/// Fetch the authed user's draft rows at paths with NO deployed counterpart,
/// for synthesizing draft-only entries into a list response. Absence is
/// checked against `kind.deployed_table()` (the shared single source).
/// Returns empty for kinds with no path-keyed table. Callers keep their own
/// gating (`include_draft_only`, page 0, no filters) and row mapping.
pub async fn fetch_draft_only_list_rows(
db: &DB,
w_id: &str,
email: &str,
kind: UserDraftItemKind,
) -> Result<Vec<DraftOnlyListRow>> {
let Some(table) = kind.deployed_table() else {
return Ok(Vec::new());
};
// `table` is from the closed `deployed_table()` enum, never user input.
// `(email = $3 OR email IS NULL)` surfaces the user's own draft-only rows
// AND the legacy NULL-email rows; `DISTINCT ON (path)` with `email IS NULL`
// last collapses a path that has both to the owned row.
let sql = format!(
"SELECT DISTINCT ON (path) path, value, created_at FROM draft \
WHERE workspace_id = $1 AND typ = $2::text::DRAFT_KIND \
AND (email = $3 OR email IS NULL) \
AND NOT EXISTS (SELECT 1 FROM {table} t \
WHERE t.workspace_id = draft.workspace_id AND t.path = draft.path) \
ORDER BY path, (email IS NULL)"
);
let rows = sqlx::query_as::<_, DraftOnlyListRow>(&sql)
.bind(w_id)
.bind(kind.as_str())
.bind(email)
.fetch_all(db)
.await?;
Ok(rows)
}
/// Delete the caller's OWN draft at a path with no deployed row, for the DELETE
/// route of a kind whose list synthesizes such rows via
/// `fetch_draft_only_list_rows`. The `NOT EXISTS` leaves a deployed row's draft
/// alone, so a route may call this on its not-found branch whatever the reason
/// for the miss. `Ok(false)` means nothing matched: the caller reports its own error.
///
/// Takes no permission check and callers must not add one: an email-scoped row
/// belongs to the caller, who can always discard it, as `update_draft`'s
/// own-discard does. Legacy (`email IS NULL`) rows are owned by nobody and keep
/// their write gate, so discarding one stays on the `update_draft` route.
pub async fn delete_draft_only_for_path(
db: &DB,
w_id: &str,
kind: UserDraftItemKind,
path: &str,
email: &str,
) -> Result<bool> {
let Some(table) = kind.deployed_table() else {
return Ok(false);
};
// `table` is from the closed `deployed_table()` enum, never user input.
let sql = format!(
"DELETE FROM draft \
WHERE workspace_id = $1 AND typ = $2::text::DRAFT_KIND AND path = $3 \
AND email = $4 \
AND NOT EXISTS (SELECT 1 FROM {table} t \
WHERE t.workspace_id = draft.workspace_id AND t.path = draft.path)"
);
let deleted = sqlx::query(&sql)
.bind(w_id)
.bind(kind.as_str())
.bind(path)
.bind(email)
.execute(db)
.await?
.rows_affected();
Ok(deleted > 0)
}
/// The get-by-path draft choreography, shared by every entity's "get by path"
/// route. Given the deployed entity as an `Option` (caller maps its own "not
/// found" to `None`):
/// - `Some(deployed)` → overlay the authed user's draft (if `get_draft`).
/// - `None` + `get_draft` → draft-only response (`no_deployed = true`) when
/// a draft exists, else the caller's 404 via `not_found`.
/// - `None` without `get_draft` → the caller's 404.
pub async fn overlay_or_draft_only<T: serde::Serialize + Send + 'static>(
db: &DB,
w_id: &str,
email: &str,
kind: UserDraftItemKind,
path: &str,
get_draft: bool,
deployed: Option<T>,
not_found: impl FnOnce() -> crate::error::Error,
) -> Result<WithDraftOverlay> {
match deployed {
Some(deployed) => {
maybe_overlay_draft(db, w_id, email, kind, path, get_draft, deployed).await
}
None if get_draft => fetch_draft_only(db, w_id, email, kind, path)
.await?
.ok_or_else(not_found),
None => Err(not_found()),
}
}
/// 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
/// account is therefore what has to delete its drafts — a delete path that skips this leaves them
/// behind forever, addressed to someone who no longer exists. Call it in the same transaction as
/// the account removal.
///
/// 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(conn: &mut sqlx::PgConnection, email: &str) -> Result<()> {
sqlx::query!("DELETE FROM draft WHERE email = $1", email)
.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(())
}
/// Move the drafts an address owns onto its new address, for the same reason
/// [`delete_drafts_of_email`] exists: no foreign key follows the rename, so drafts left behind are
/// stranded on an address that no longer authenticates. Same authorization contract, for a rename.
///
/// The two addresses may each already hold a draft of the same item, since the destination can
/// belong to a principal with no account and so is not covered by the caller's "address is free"
/// check. `draft_pkey_with_user` admits only one, so the moving account's wins — which is also why
/// a rename onto the same address returns early: every row would collide with itself and be
/// cleared. Callers need not compare first (an IdP re-sending an unchanged `userName` does not).
pub async fn rename_drafts_of_email(
conn: &mut sqlx::PgConnection,
old_email: &str,
new_email: &str,
) -> Result<()> {
if old_email == new_email {
return Ok(());
}
sqlx::query!(
"DELETE FROM draft dest
WHERE dest.email = $1
AND EXISTS (SELECT 1 FROM draft src
WHERE src.email = $2
AND src.workspace_id = dest.workspace_id
AND src.path = dest.path
AND src.typ = dest.typ)",
new_email,
old_email
)
.execute(&mut *conn)
.await?;
sqlx::query!(
"UPDATE draft SET email = $1 WHERE email = $2",
new_email,
old_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(())
}
/// Delete EVERY user's draft (and the legacy NULL-email row) at a path+kind.
/// Use when the item is DELETED outright: it's gone for everyone, so leaving
/// teammates' drafts behind would orphan them forever. Discarding one's OWN
/// draft while the item lives on goes through `update_draft` with `value: null`.
/// Idempotent on the no-draft case.
pub async fn delete_all_drafts_for_path(
db: &DB,
w_id: &str,
kind: UserDraftItemKind,
path: &str,
) -> Result<()> {
sqlx::query!(
r#"DELETE FROM draft
WHERE workspace_id = $1
AND path = $2
AND typ = $3"#,
w_id,
path,
kind as UserDraftItemKind,
)
.execute(db)
.await?;
Ok(())
}
/// Discard the deploying user's OWN draft (plus the legacy NULL-email row)
/// for a path+kind, leaving teammates' drafts intact. Use on RENAME: the
/// item moved, so the draft at the old path is orphaned (no FK to cascade).
/// Teammates keep theirs and get the StaleDraftModal on their next reload.
/// Idempotent on the no-draft case.
pub async fn delete_own_draft_for_path(
db: &DB,
w_id: &str,
kind: UserDraftItemKind,
path: &str,
email: &str,
) -> Result<()> {
sqlx::query!(
r#"DELETE FROM draft
WHERE workspace_id = $1
AND path = $2
AND typ = $3
AND (email = $4 OR email IS NULL)"#,
w_id,
path,
kind as UserDraftItemKind,
email,
)
.execute(db)
.await?;
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
/// `no_deployed = true`. Callers must have established no deployed row exists;
/// `Ok(None)` when there's also no draft (caller should 404).
///
/// The draft JSON is expected to be an object (so `serde(flatten)` on `inner`
/// works); a non-object draft renders with no fields flattened.
pub async fn fetch_draft_only(
db: &DB,
w_id: &str,
email: &str,
kind: UserDraftItemKind,
path: &str,
) -> Result<Option<WithDraftOverlay>> {
// 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, base
FROM draft
WHERE workspace_id = $1
AND (email = $2 OR email IS NULL)
AND path = $3
AND typ = $4
ORDER BY email NULLS LAST
LIMIT 1"#,
w_id,
email,
path,
kind as UserDraftItemKind,
)
.fetch_optional(db)
.await?;
let Some(row) = row else {
return Ok(None);
};
let draft_json: serde_json::Value = serde_json::from_str(row.value.0.get())?;
let other_drafts_users = if kind.shares_drafts_across_users() {
fetch_other_drafts_users(db, w_id, email, kind, path).await?
} else {
Vec::new()
};
Ok(Some(WithDraftOverlay {
// Best-effort stand-in for the missing deployed — same JSON as `draft`.
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,
}))
}
/// Marker prefix for draft secret values encrypted at rest with the workspace
/// crypt key (`build_crypt`). Written by `update_draft` for secret variables;
/// resolved back to plaintext by the variable deploy endpoints.
pub const ENCRYPTED_DRAFT_PREFIX: &str = "$encrypted:";
fn draft_decrypt_error() -> crate::error::Error {
crate::error::Error::BadRequest(
"An encrypted draft secret could not be decrypted (the workspace encryption key may \
have changed since the draft was saved). Reset the field and re-enter the secret."
.to_string(),
)
}
/// Decrypt a `$encrypted:`-marked draft value back to plaintext with the
/// workspace crypt key. Fails with a user-facing 400 when it doesn't decrypt
/// (e.g. the workspace key was rotated after the draft save).
pub async fn decrypt_draft_secret_value(db: &DB, w_id: &str, value: &str) -> Result<String> {
let encrypted = value.strip_prefix(ENCRYPTED_DRAFT_PREFIX).unwrap_or(value);
let mc = crate::variables::build_crypt(db, w_id).await?;
crate::variables::decrypt(&mc, encrypted.to_string()).map_err(|_| draft_decrypt_error())
}