Compare commits

...

43 Commits

Author SHA1 Message Date
Guilhem
fa22ec69d8 fix: only show email validation error after dropdown closes
Add onClose callback to AutocompleteSelect and use it in AddUser
to defer validation until the user finishes interacting with the
dropdown, avoiding visual noise between the dropdown and error.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-17 09:33:22 +00:00
Guilhem
da460fa6b4 nit 2026-02-17 09:27:57 +00:00
Guilhem
f03bd04cac fix: use AutocompleteSelect loading prop instead of separate branch
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-17 08:59:01 +00:00
Guilhem
e11a333998 fix: address PR review comments
- Add LIMIT 1000 to list_instance_emails SQL query
- Add loading state to prevent UI flash in AddUser
- Show email validation error only after blur
- Guard loadInstanceEmails with workspaceStore check

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-17 08:20:14 +00:00
Guilhem
2ff72e34dd Merge remote-tracking branch 'origin/main' into glm/select-users 2026-02-16 18:38:22 +00:00
Ruben Fiszel
0940d70a2b chore(main): release 1.636.0 (#7962)
* chore(main): release 1.636.0

* Apply automatic changes

---------

Co-authored-by: rubenfiszel <275584+rubenfiszel@users.noreply.github.com>
2026-02-16 17:09:46 +00:00
centdix
e7b0b00f56 fix: mark base_url as unsaved when using browser fallback (#7964)
* fix: mark base_url as unsaved when using browser fallback in instance settings

When base_url is not set in the database, the frontend silently fills in
window.location.origin but also snapshots it as the initial value. This
makes the dirty-check see no change, so the Save button stays disabled
and the user cannot persist the auto-detected value.

Fix by snapshotting initialValues before applying the fallback, and show
a yellow warning indicating the value is auto-detected and unsaved.

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

* fix: render base_url fallback warning inside the setting card

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

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 17:01:18 +00:00
Ruben Fiszel
4587cd5f3e fix sqlx 2026-02-16 17:00:35 +00:00
Ruben Fiszel
7a5719217b fix sqlx 2026-02-16 16:49:42 +00:00
Ruben Fiszel
ec17b29ae2 fix private check issue 2026-02-16 16:39:59 +00:00
Guilhem
732aff1d5c Merge branch 'main' into claude/issue-7902-20260211-1113 2026-02-16 16:32:49 +00:00
Ruben Fiszel
713c39040b fix private check issue 2026-02-16 16:12:02 +00:00
centdix
5db6a405ad fix review comments (#7963) 2026-02-16 15:53:52 +00:00
hugocasa
6f24f1939d feat: google native triggers (#7837)
* feat: google native triggers

* nit skill

* better native trigger abstraction

* use resources for workspace integrations

* better and better

* better tests

* update native trigger skill

* sqlx

* less tx and google update fix

* refactor a bit the external logic

* nits

* fix

* fix google native trigger update

* fix oauth

* review fixes

* sqlx fix

* nit

* chore: update ee-repo-ref to a10eda4251610cceee67fbe05463b8be82ffa9e0

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

Previous ee-repo-ref: bf3696d5f2a39a3cb84dbbee81e092155f2a8c75

New ee-repo-ref: a10eda4251610cceee67fbe05463b8be82ffa9e0

Automated by sync-ee-ref workflow.

---------

Co-authored-by: Ruben Fiszel <ruben@windmill.dev>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: windmill-internal-app[bot] <windmill-internal-app[bot]@users.noreply.github.com>
2026-02-16 15:52:02 +00:00
Guilhem
13ff7f418e Merge branch 'main' into claude/issue-7902-20260211-1113 2026-02-16 15:50:33 +00:00
windmill-internal-app[bot]
d89d36728b chore: update ee-repo-ref to c927593b0b49867284fc64d50c59daba6c70da84
This commit updates the EE repository reference after PR #415 was merged in windmill-ee-private.

Previous ee-repo-ref: 8c214ec5039be5353f5fea920e27b0c6af61e1fd

New ee-repo-ref: c927593b0b49867284fc64d50c59daba6c70da84

Automated by sync-ee-ref workflow.
2026-02-16 15:48:08 +00:00
centdix
5b8ec502fe feat: allow adding workspace scripts and flows as AI chat context (#7882)
* feat: allow adding workspace scripts and flows as AI chat context

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

* cleaning

* cleaning

* cleaning

* better

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-16 15:46:17 +00:00
Guilhem
47c027716b chore: remove test_dev select page
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 15:39:18 +00:00
Guilhem
9347a04ed5 style: format test_dev select page
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 15:38:41 +00:00
Guilhem
360b43caba nit 2026-02-16 15:38:22 +00:00
Guilhem
1dd2b0a1b2 refactor: extract AutocompleteSelect from Select component
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 15:31:22 +00:00
Guilhem
19f30ee6d5 Merge remote-tracking branch 'origin/main' into glm/select-users 2026-02-16 15:01:41 +00:00
Ruben Fiszel
b2128a0109 add dynamic tls support for email 2026-02-16 14:57:28 +00:00
Guilhem
90139c7754 Merge remote-tracking branch 'origin/main' into glm/select-users 2026-02-16 14:51:51 +00:00
Guilhem
4aa1f01210 refactor: replace Select placeholder hack with autocomplete model
Replace the bind:value getter/setter and placeholder-as-display hack with
a standard controlled input pattern (value={inputValue} + oninput handler).

- Remove showPlaceholderOnOpen prop (replaced by default behavior)
- Add allowUserInput prop: typed text syncs to value in real-time
- Separate rawLabel (for filtering) from displayText (for transformed display)
- Hide dropdown when no items match in allowUserInput mode
- Simplify AddUser to use allowUserInput instead of createText/onCreateItem

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 14:51:06 +00:00
Guilhem
bfb65fc8b4 fix: allow editing custom email in Select component
When a custom value (not in the items list) is selected, reopening
the dropdown now pre-fills the filter with the current value so users
can directly edit it. Also hide the "Use custom email" create option
when the filter matches the current value, and show the regular
placeholder instead of the value when editing a custom entry.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 11:52:54 +00:00
Guilhem
b54bea7192 feat: add email validation and use TextInput in AddUser
Add client-side email format validation with error message display
and disable the Add button when email is invalid. Replace plain
<input> elements with TextInput component for consistent styling.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 11:32:31 +00:00
Guilhem
793bb7b405 fix: restrict list_instance_emails to workspace admins
Move the endpoint from global to workspace-scoped route
(/w/{workspace}/users/list_instance_emails) and add require_admin
check so only workspace admins can list instance emails.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 10:54:06 +00:00
Guilhem
fb0f0031d9 fix: filter out existing workspace members from instance emails select
Also fixes "Add new userss" typo.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 10:25:15 +00:00
Ruben Fiszel
ba80570357 chore(main): release 1.635.1 (#7958)
* chore(main): release 1.635.1

* Apply automatic changes

---------

Co-authored-by: rubenfiszel <275584+rubenfiszel@users.noreply.github.com>
2026-02-15 17:14:04 +00:00
Ruben Fiszel
714f713a04 only update license key via IaC when new expiry is posterior (#7959)
When using infrastructure-as-code to update settings, if the desired
license key matches the current one (same client ID and signature) but
differs only in the expiration date, only apply the update if the new
key has a later expiry. This prevents accidental downgrades when an
older license key is present in the IaC configuration.

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 17:13:37 +00:00
Ruben Fiszel
68f766e1ae fix: pin strum_macros to 0.27 to match strum version (#7957)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 16:56:39 +00:00
Ruben Fiszel
d9a5cb64b8 chore(main): release 1.635.0 (#7945)
* chore(main): release 2.0.0

* Apply automatic changes

* chore(main): release 1.635.0

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

---------

Co-authored-by: rubenfiszel <275584+rubenfiszel@users.noreply.github.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 16:48:38 +00:00
Ruben Fiszel
2ca3c8e409 nit 2026-02-15 16:44:43 +00:00
Ruben Fiszel
0cc4e2650c make api for setting instance config more consistent 2026-02-15 16:38:07 +00:00
Ruben Fiszel
bdffba53ed expose 'Set via API' in worker group config drawer 2026-02-15 16:32:38 +00:00
Ruben Fiszel
64532a1d12 list to record for worker config 2026-02-15 16:21:16 +00:00
Ruben Fiszel
8b8e33e2dc feat: IaC hints, YAML editor for worker configs (#7956)
* feat: add CRD configuration hints to instance settings and workers pages

Add subtle text notes with doc links to help K8s users discover that
instance settings and worker group configs can be managed via the
WindmillInstance CRD.

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

* feat: IaC hints, YAML editor for worker configs, and EE badges

- Move IaC hint from form view to YAML mode in Instance Settings
- Add YAML editor drawer for worker group configs with diff review
- Add EE-only badges to gated fields in worker config drawer
- Make init_bash available in CE (frontend + backend gate)
- Promote Init/Periodic scripts to top-level sections
- Add "YAML editor" button to worker config drawer action bar
- Strip cache_clear and empty fields from YAML output
- Sort default/native groups first in YAML editor

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

* fix: address PR review feedback on worker YAML editor

- Extract serializeWorkerGroupsAsYaml() shared helper (dedup)
- Fix name:undefined leak using proper destructuring
- Protect default/native groups from accidental deletion
- Add loading indicator during YAML save
- Add restart warning for periodic script changes
- Use consistent on:click event syntax in YAML drawer actions

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

* refactor: use onClick prop instead of on:click for Button components

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

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 15:53:03 +00:00
Ruben Fiszel
c5d870f480 make K8s operator private and add registry EE warning (#7955)
Move K8s operator source code (crd, db_sync, reconciler, resolve) to
windmill-ee-private and gate behind feature = "private". OSS stubs
provide error messages when the feature is disabled.

Add an info Alert banner in the Registries settings section when no
enterprise license is active.

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 14:32:15 +00:00
Ruben Fiszel
6cf3f5f4a3 feat: open-source worker group configuration UI (#7954)
* feat: gate private registry configuration behind enterprise feature

Add read_ee_registry<T> helper that returns None and emits a job log
in CE builds when a private registry is configured. Applied to all
language executors: pip, npm/bun, deno, cargo, go, maven, nuget,
powershell, and ruby. Maven settings.xml file write in monitor.rs
is also gated.

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

* feat: open-source worker group configuration UI

Allow CE users to create worker group configs and edit worker_tags.
EE-only features (dedicated workers, custom env vars, init/periodic
scripts, autoscaling, priority tags, etc.) remain gated behind the
enterprise license in the UI. Backend now filters CE config updates
to only allow worker_tags and cache_clear fields instead of rejecting
the entire request.

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

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 14:05:23 +00:00
Ruben Fiszel
423e07376b feat: gate private registry configuration behind enterprise feature (#7953)
Add read_ee_registry<T> helper that returns None and emits a job log
in CE builds when a private registry is configured. Applied to all
language executors: pip, npm/bun, deno, cargo, go, maven, nuget,
powershell, and ruby. Maven settings.xml file write in monitor.rs
is also gated.

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 14:02:14 +00:00
Ruben Fiszel
dbe576406e add type annotations to opaque CRD schema fields (#7952)
* fix: add type annotations to opaque CRD schema fields

The WindmillInstance CRD failed K8s structural schema validation because
four `Option<serde_json::Value>` fields (`object_store_cache_config`,
`secret_backend`, `slack`, `teams`) generated schemas without a `type`
property. Kubernetes requires all specified object fields to declare a
type.

Add a `schemars(schema_with)` annotation that emits
`{"type": "object", "nullable": true, "x-kubernetes-preserve-unknown-fields": true}`
for these opaque settings fields.

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

* feat: enable operator in CE builds

The operator feature was only included in `ee_core`, meaning CE images
couldn't run `windmill operator` as a proper k8s controller — the
subcommand silently fell through to standalone mode.

Add `operator` to `ce_core` so CE builds include the k8s controller
that watches and reconciles WindmillInstance CRDs.

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

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 12:26:21 +00:00
claude[bot]
077eb91c0f feat: add list instance emails endpoint and use Select in AddUser
Add a new GET /users/list_instance_emails endpoint that returns all
instance user emails (from the password table). This endpoint is only
available on non-cloud instances.

In the frontend AddUser component, when the instance emails are
available (non-cloud), replace the email text input with a Select
component that allows both selecting from existing instance users and
typing custom emails.

Closes #7902

Co-authored-by: windmill-internal-app[bot] <windmill-internal-app[bot]@users.noreply.github.com>
2026-02-11 11:24:13 +00:00
174 changed files with 7791 additions and 1966 deletions

View File

@@ -0,0 +1,756 @@
# Skill: Adding Native Trigger Services
This skill provides comprehensive guidance for adding new native trigger services to Windmill. Native triggers allow external services (like Nextcloud, Google Drive, etc.) to trigger Windmill scripts/flows via webhooks or push notifications.
## Architecture Overview
The native trigger system consists of:
1. **Database Layer** - PostgreSQL tables and enum types
2. **Backend Rust Implementation** - Core trait, handlers, and service modules in the `windmill-native-triggers` crate
3. **Frontend Svelte Components** - Configuration forms and UI components
### Key Files
| Component | Path |
|-----------|------|
| Core module with `External` trait | `backend/windmill-native-triggers/src/lib.rs` |
| Generic CRUD handlers | `backend/windmill-native-triggers/src/handler.rs` |
| Background sync logic | `backend/windmill-native-triggers/src/sync.rs` |
| OAuth/workspace integration | `backend/windmill-native-triggers/src/workspace_integrations.rs` |
| Re-export shim (windmill-api) | `backend/windmill-api/src/native_triggers/mod.rs` |
| TriggerKind enum | `backend/windmill-common/src/triggers.rs` |
| JobTriggerKind enum | `backend/windmill-common/src/jobs.rs` |
| Frontend service registry | `frontend/src/lib/components/triggers/native/utils.ts` |
| Frontend trigger utilities | `frontend/src/lib/components/triggers/utils.ts` |
| Trigger badges (icons + counts) | `frontend/src/lib/components/graph/renderers/triggers/TriggersBadge.svelte` |
| Workspace integrations UI | `frontend/src/lib/components/workspaceSettings/WorkspaceIntegrations.svelte` |
| OAuth config form component | `frontend/src/lib/components/workspaceSettings/OAuthClientConfig.svelte` |
| OpenAPI spec | `backend/windmill-api/openapi.yaml` |
| Reference: Nextcloud module | `backend/windmill-native-triggers/src/nextcloud/` |
| Reference: Google module | `backend/windmill-native-triggers/src/google/` |
### Crate Structure
The native trigger code lives in the `windmill-native-triggers` crate (`backend/windmill-native-triggers/`). The `windmill-api` crate re-exports everything via a shim:
```rust
// backend/windmill-api/src/native_triggers/mod.rs
pub use windmill_native_triggers::*;
```
All new service modules go in `backend/windmill-native-triggers/src/`.
---
## Core Concepts
### The `External` Trait
Every native trigger service implements the `External` trait defined in `lib.rs`:
```rust
#[async_trait]
pub trait External: Send + Sync + 'static {
// Associated types:
type ServiceConfig: Debug + DeserializeOwned + Serialize + Send + Sync;
type TriggerData: Debug + Serialize + Send + Sync;
type OAuthData: DeserializeOwned + Serialize + Clone + Send + Sync;
type CreateResponse: DeserializeOwned + Send + Sync;
// Constants:
const SUPPORT_WEBHOOK: bool;
const SERVICE_NAME: ServiceName;
const DISPLAY_NAME: &'static str;
const TOKEN_ENDPOINT: &'static str;
const REFRESH_ENDPOINT: &'static str;
const AUTH_ENDPOINT: &'static str;
// Required methods:
async fn create(&self, w_id, oauth_data, webhook_token, data, db, tx) -> Result<Self::CreateResponse>;
async fn update(&self, w_id, oauth_data, external_id, webhook_token, data, db, tx) -> Result<serde_json::Value>;
async fn get(&self, w_id, oauth_data, external_id, db, tx) -> Result<Self::TriggerData>;
async fn delete(&self, w_id, oauth_data, external_id, db, tx) -> Result<()>;
async fn exists(&self, w_id, oauth_data, external_id, db, tx) -> Result<bool>;
async fn maintain_triggers(&self, db, workspace_id, triggers, oauth_data, synced, errors);
fn external_id_and_metadata_from_response(&self, resp) -> (String, Option<serde_json::Value>);
// Methods with defaults:
async fn prepare_webhook(&self, db, w_id, headers, body, script_path, is_flow) -> Result<PushArgsOwned>;
fn service_config_from_create_response(&self, data, resp) -> Option<serde_json::Value>;
fn additional_routes(&self) -> axum::Router;
async fn http_client_request<T, B>(&self, url, method, workspace_id, tx, db, headers, body) -> Result<T>;
}
```
Key design points:
- **`update()` returns `serde_json::Value`** - the resolved service_config to store. Each service is responsible for building the final config.
- **`maintain_triggers()`** - periodic background maintenance. Each service implements its own strategy (Nextcloud: reconcile with external state; Google: renew expiring channels).
- **No `list_all()` in the trait** - services that need it (Nextcloud) implement it privately; services that don't (Google) use different maintenance strategies.
- **No `get_external_id_from_trigger_data()` or `extract_service_config_from_trigger_data()`** - removed in favor of the `maintain_triggers` pattern.
### Create Lifecycle: Two Paths
The `create_native_trigger` handler in `handler.rs` supports two creation flows, controlled by `service_config_from_create_response()`:
**Path A: Short (Google pattern)** - `service_config_from_create_response()` returns `Some(config)`:
1. `create()` registers on external service
2. `external_id_and_metadata_from_response()` extracts the ID
3. `service_config_from_create_response()` builds the config directly from input data + response metadata
4. Stores trigger in DB -- done, no extra round-trip
Use this when the external_id is known before the create call (e.g., Google generates the channel_id as a UUID upfront and includes it in the webhook URL).
**Path B: Long (Nextcloud pattern)** - `service_config_from_create_response()` returns `None` (default):
1. `create()` registers on external service (webhook URL has no external_id yet)
2. `external_id_and_metadata_from_response()` extracts the ID
3. `update()` is called to fix the webhook URL with the now-known external_id
4. `update()` returns the resolved service_config
5. Stores trigger in DB
Use this when the external_id is assigned by the remote service and the webhook URL needs to be corrected after creation.
### OAuth Token Storage (Three-Table Pattern)
OAuth tokens are stored across three tables, NOT in `workspace_integrations.oauth_data` directly:
| Table | What's Stored |
|-------|---------------|
| `workspace_integrations` | `oauth_data` JSON with `base_url`, `client_id`, `client_secret`, `instance_shared` flag; `resource_path` pointing to the variable |
| `variable` | Encrypted `access_token` (at the path stored in `resource_path`), linked to `account` via `account` column |
| `account` | `refresh_token`, keyed by `workspace_id` + `client` (service name) + `is_workspace_integration = true` |
The `decrypt_oauth_data()` function in `lib.rs` assembles these into a unified struct:
```rust
pub struct OAuthConfig {
pub base_url: String,
pub access_token: String, // decrypted from variable
pub refresh_token: Option<String>, // from account table
pub client_id: String, // from oauth_data or instance settings
pub client_secret: String, // from oauth_data or instance settings
}
```
Instance-level sharing: when `oauth_data.instance_shared == true`, `client_id` and `client_secret` are read from global settings instead of workspace_integrations.
### URL Resolution
The `resolve_endpoint()` helper handles both absolute and relative OAuth URLs:
```rust
pub fn resolve_endpoint(base_url: &str, endpoint: &str) -> String {
if endpoint.starts_with("http://") || endpoint.starts_with("https://") {
endpoint.to_string() // Google: absolute URLs
} else {
format!("{}{}", base_url, endpoint) // Nextcloud: relative paths
}
}
```
### ServiceName Methods
`ServiceName` is the central registry enum. Each variant must implement these match arms:
| Method | Purpose |
|--------|---------|
| `as_str()` | Lowercase identifier (e.g., `"google"`) |
| `as_trigger_kind()` | Maps to `TriggerKind` enum |
| `as_job_trigger_kind()` | Maps to `JobTriggerKind` enum |
| `token_endpoint()` | OAuth token endpoint (relative or absolute) |
| `auth_endpoint()` | OAuth authorization endpoint |
| `oauth_scopes()` | Space-separated OAuth scopes |
| `resource_type()` | Resource type for token storage (e.g., `"gworkspace"`) |
| `extra_auth_params()` | Extra OAuth params (e.g., Google needs `access_type=offline`, `prompt=consent`) |
| `integration_service()` | Maps to the workspace integration service (usually `*self`) |
| `TryFrom<String>` | Parse from string |
| `Display` | Delegates to `as_str()` |
---
## Step-by-Step Implementation Guide
### Step 1: Database Migration
Create a new migration file: `backend/migrations/YYYYMMDDHHMMSS_newservice_trigger.up.sql`
```sql
-- Add the service to the native_trigger_service enum
ALTER TYPE native_trigger_service ADD VALUE IF NOT EXISTS 'newservice';
-- Add to TRIGGER_KIND enum (used for trigger tracking)
ALTER TYPE TRIGGER_KIND ADD VALUE IF NOT EXISTS 'newservice';
-- Add to job_trigger_kind enum (used for job tracking)
ALTER TYPE job_trigger_kind ADD VALUE IF NOT EXISTS 'newservice';
```
Also create the corresponding down migration.
### Step 2: Update windmill-common Enums
#### `backend/windmill-common/src/triggers.rs`
Add variant to `TriggerKind` enum, and update `to_key()` and `fmt()` implementations.
#### `backend/windmill-common/src/jobs.rs`
Add variant to `JobTriggerKind` enum and update the `Display` implementation.
### Step 3: Backend Service Module
Create a new directory: `backend/windmill-native-triggers/src/newservice/`
#### `mod.rs` - Type Definitions
```rust
use serde::{Deserialize, Serialize};
pub mod external;
// pub mod routes; // Only if you need additional service-specific routes
/// OAuth data deserialized from the three-table pattern.
/// The actual structure is built by decrypt_oauth_data() from variable + account + workspace_integrations.
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct NewServiceOAuthData {
pub base_url: String, // from workspace_integrations.oauth_data
pub access_token: String, // decrypted from variable table
pub refresh_token: Option<String>, // from account table
// Note: client_id and client_secret are in OAuthConfig, not here
// unless the service needs them at runtime for API calls
}
/// Configuration provided by user when creating/updating a trigger.
/// Stored as JSON in native_trigger.service_config.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct NewServiceConfig {
// Service-specific configuration fields
pub folder_path: String,
pub file_filter: Option<String>,
}
/// Data retrieved from the external service about a trigger.
/// Returned by the get() method and shown in the UI.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct NewServiceTriggerData {
pub folder_path: String,
pub file_filter: Option<String>,
// Fields that shouldn't affect service_config comparison should use #[serde(skip_serializing)]
}
/// Response from external service when creating a trigger/webhook.
#[derive(Debug, Deserialize)]
pub struct CreateTriggerResponse {
pub id: String,
}
/// Handler struct (stateless, used for routing)
#[derive(Copy, Clone)]
pub struct NewService;
```
#### `external.rs` - External Trait Implementation
```rust
use async_trait::async_trait;
use reqwest::Method;
use sqlx::PgConnection;
use std::collections::HashMap;
use windmill_common::{
error::{Error, Result},
BASE_URL, DB,
};
use crate::{
generate_webhook_service_url, External, NativeTrigger, NativeTriggerData, ServiceName,
sync::{SyncError, TriggerSyncInfo},
};
use super::{NewService, NewServiceConfig, NewServiceOAuthData, NewServiceTriggerData, CreateTriggerResponse};
#[async_trait]
impl External for NewService {
type ServiceConfig = NewServiceConfig;
type TriggerData = NewServiceTriggerData;
type OAuthData = NewServiceOAuthData;
type CreateResponse = CreateTriggerResponse;
const SERVICE_NAME: ServiceName = ServiceName::NewService;
const DISPLAY_NAME: &'static str = "New Service";
const SUPPORT_WEBHOOK: bool = true;
const TOKEN_ENDPOINT: &'static str = "/oauth/token";
const REFRESH_ENDPOINT: &'static str = "/oauth/token";
const AUTH_ENDPOINT: &'static str = "/oauth/authorize";
async fn create(
&self,
w_id: &str,
oauth_data: &Self::OAuthData,
webhook_token: &str,
data: &NativeTriggerData<Self::ServiceConfig>,
db: &DB,
tx: &mut PgConnection,
) -> Result<Self::CreateResponse> {
let base_url = &*BASE_URL.read().await;
// external_id is None during create (we get it from the response)
let webhook_url = generate_webhook_service_url(
base_url, w_id, &data.script_path, data.is_flow,
None, Self::SERVICE_NAME, webhook_token,
);
let url = format!("{}/api/webhooks/create", oauth_data.base_url);
let payload = serde_json::json!({
"callback_url": webhook_url,
"folder_path": data.service_config.folder_path,
});
let response: CreateTriggerResponse = self
.http_client_request(&url, Method::POST, w_id, tx, db, None, Some(&payload))
.await?;
Ok(response)
}
/// Update returns the resolved service_config as JSON.
/// For services using the update+get pattern, call self.get() and serialize.
async fn update(
&self,
w_id: &str,
oauth_data: &Self::OAuthData,
external_id: &str,
webhook_token: &str,
data: &NativeTriggerData<Self::ServiceConfig>,
db: &DB,
tx: &mut PgConnection,
) -> Result<serde_json::Value> {
let base_url = &*BASE_URL.read().await;
let webhook_url = generate_webhook_service_url(
base_url, w_id, &data.script_path, data.is_flow,
Some(external_id), Self::SERVICE_NAME, webhook_token,
);
let url = format!("{}/api/webhooks/{}", oauth_data.base_url, external_id);
let payload = serde_json::json!({
"callback_url": webhook_url,
"folder_path": data.service_config.folder_path,
});
let _: serde_json::Value = self
.http_client_request(&url, Method::PUT, w_id, tx, db, None, Some(&payload))
.await?;
// Fetch back the updated state to get the resolved config
let trigger_data = self.get(w_id, oauth_data, external_id, db, tx).await?;
serde_json::to_value(&trigger_data)
.map_err(|e| Error::InternalErr(format!("Failed to serialize trigger data: {}", e)))
}
async fn get(
&self,
w_id: &str,
oauth_data: &Self::OAuthData,
external_id: &str,
db: &DB,
tx: &mut PgConnection,
) -> Result<Self::TriggerData> {
let url = format!("{}/api/webhooks/{}", oauth_data.base_url, external_id);
self.http_client_request::<_, ()>(&url, Method::GET, w_id, tx, db, None, None).await
}
async fn delete(
&self,
w_id: &str,
oauth_data: &Self::OAuthData,
external_id: &str,
db: &DB,
tx: &mut PgConnection,
) -> Result<()> {
let url = format!("{}/api/webhooks/{}", oauth_data.base_url, external_id);
let _: serde_json::Value = self
.http_client_request::<_, ()>(&url, Method::DELETE, w_id, tx, db, None, None)
.await
.or_else(|e| match &e {
Error::InternalErr(msg) if msg.contains("404") => Ok(serde_json::Value::Null),
_ => Err(e),
})?;
Ok(())
}
async fn exists(
&self,
w_id: &str,
oauth_data: &Self::OAuthData,
external_id: &str,
db: &DB,
tx: &mut PgConnection,
) -> Result<bool> {
match self.get(w_id, oauth_data, external_id, db, tx).await {
Ok(_) => Ok(true),
Err(Error::NotFound(_)) => Ok(false),
Err(e) => Err(e),
}
}
/// Background maintenance. Choose the right pattern for your service:
/// - For services with queryable external state: use reconcile_with_external_state()
/// - For channel-based services with expiration: implement renewal logic
async fn maintain_triggers(
&self,
db: &DB,
workspace_id: &str,
triggers: &[NativeTrigger],
oauth_data: &Self::OAuthData,
synced: &mut Vec<TriggerSyncInfo>,
errors: &mut Vec<SyncError>,
) {
// Option A: Reconcile with external state (Nextcloud pattern)
// Fetch all triggers from external service and compare with DB
let external_triggers = match self.list_all(workspace_id, oauth_data, db).await {
Ok(triggers) => triggers,
Err(e) => {
errors.push(SyncError {
resource_path: format!("workspace:{}", workspace_id),
error_message: format!("Failed to list triggers: {}", e),
error_type: "api_error".to_string(),
});
return;
}
};
// Convert to (external_id, config_json) pairs
let external_pairs: Vec<(String, serde_json::Value)> = external_triggers
.into_iter()
.map(|t| (t.id.clone(), serde_json::to_value(&t).unwrap_or_default()))
.collect();
crate::sync::reconcile_with_external_state(
db, workspace_id, Self::SERVICE_NAME, triggers, &external_pairs, synced, errors,
).await;
}
fn external_id_and_metadata_from_response(
&self,
resp: &Self::CreateResponse,
) -> (String, Option<serde_json::Value>) {
(resp.id.clone(), None)
}
// service_config_from_create_response: NOT overridden (returns None).
// This means the handler uses the update+get pattern after create.
// Override and return Some(...) to skip the update+get cycle (Google pattern).
}
impl NewService {
/// Private helper to list all triggers from the external service.
async fn list_all(
&self,
w_id: &str,
oauth_data: &<Self as External>::OAuthData,
db: &DB,
) -> Result<Vec<<Self as External>::TriggerData>> {
// Implementation depends on the external service's API
todo!()
}
}
```
### Step 4: Update lib.rs Registry
In `backend/windmill-native-triggers/src/lib.rs`:
```rust
// Service modules - add new services here:
#[cfg(feature = "native_trigger")]
pub mod newservice; // <-- Add this
// ServiceName enum - add variant:
pub enum ServiceName {
Nextcloud,
Google,
NewService, // <-- Add this
}
// Then add match arms in ALL ServiceName methods:
// as_str(), as_trigger_kind(), as_job_trigger_kind(), token_endpoint(),
// auth_endpoint(), oauth_scopes(), resource_type(), extra_auth_params(),
// integration_service(), TryFrom<String>, Display
```
### Step 5: Update handler.rs Routes
In `backend/windmill-native-triggers/src/handler.rs`:
```rust
pub fn generate_native_trigger_routers() -> Router {
// ...
#[cfg(feature = "native_trigger")]
{
use crate::newservice::NewService;
return router
.nest("/nextcloud", service_routes(NextCloud))
.nest("/google", service_routes(Google))
.nest("/newservice", service_routes(NewService)); // <-- Add this
}
// ...
}
```
### Step 6: Update sync.rs
In `backend/windmill-native-triggers/src/sync.rs`:
```rust
pub async fn sync_all_triggers(db: &DB) -> Result<BackgroundSyncResult> {
// ...
#[cfg(feature = "native_trigger")]
{
use crate::newservice::NewService;
// ... existing service syncs ...
// New service sync
let (service_name, result) = sync_service_triggers(db, NewService).await;
total_synced += result.synced_triggers.len();
total_errors += result.errors.len();
service_results.insert(service_name, result);
}
// ...
}
```
### Step 7: Frontend Service Registry
In `frontend/src/lib/components/triggers/native/utils.ts`:
Add to `NATIVE_TRIGGER_SERVICES`, `getTriggerIconName()`, and `getServiceIcon()`.
### Step 8: Frontend Trigger Form Component
Create: `frontend/src/lib/components/triggers/native/services/newservice/NewServiceTriggerForm.svelte`
### Step 9: Frontend Icon Component
Create: `frontend/src/lib/components/icons/NewServiceIcon.svelte`
### Step 10: Update NativeTriggerEditor
Check `frontend/src/lib/components/triggers/native/NativeTriggerEditor.svelte` to ensure it dynamically loads form components based on service name.
### Step 11: Workspace Integration UI
Add your service to the `supportedServices` map in `frontend/src/lib/components/workspaceSettings/WorkspaceIntegrations.svelte`:
```typescript
const supportedServices: Record<string, ServiceConfig> = {
// ... existing services ...
newservice: {
name: 'newservice',
displayName: 'New Service',
description: 'Connect to New Service for triggers',
icon: NewServiceIcon,
docsUrl: 'https://www.windmill.dev/docs/integrations/newservice',
requiresBaseUrl: false, // false for cloud services, true for self-hosted
setupInstructions: [
'Step 1: Create an OAuth app on the service',
'Step 2: Configure the redirect URI shown below',
'Step 3: Enter the client credentials below'
]
}
}
```
### Step 12: Update `frontend/src/lib/components/triggers/utils.ts`
Update ALL of these maps/functions:
1. `triggerIconMap` - import and add icon
2. `triggerDisplayNamesMap` - add display name
3. `triggerTypeOrder` in `sortTriggers()` - add type
4. `getLightConfig()` - add case for your service
5. `getTriggerLabel()` - add case for your service
6. `jobTriggerKinds` - add to array
7. `countPropertyMap` - add count property
8. `triggerSaveFunctions` - add save function
### Step 13: Update TriggersBadge Component
In `frontend/src/lib/components/graph/renderers/triggers/TriggersBadge.svelte`:
1. Import the icon
2. Add to `baseConfig` with `countKey` (the dynamic `availableNativeServices` loop does NOT set `countKey`)
3. Add to the `allTypes` array
### Step 14: Update OpenAPI Spec and Regenerate Types
Add to `JobTriggerKind` enum in `backend/windmill-api/openapi.yaml`, then:
```bash
cd frontend && npm run generate-backend-client
```
---
## Special Patterns
### Unified Service with `trigger_type` (Google Pattern)
When a single service handles multiple trigger types (e.g., Google Drive + Calendar share OAuth and API patterns), use a single `ServiceName` variant with a discriminator field:
```rust
pub enum GoogleTriggerType { Drive, Calendar }
pub struct GoogleServiceConfig {
pub trigger_type: GoogleTriggerType,
// Drive-specific fields (only used when trigger_type = Drive)
pub resource_id: Option<String>,
pub resource_name: Option<String>,
// Calendar-specific fields (only used when trigger_type = Calendar)
pub calendar_id: Option<String>,
pub calendar_name: Option<String>,
// Metadata set after creation
pub google_resource_id: Option<String>,
pub expiration: Option<String>,
}
```
Branch in trait methods based on `trigger_type`. Frontend uses a `ToggleButtonGroup` to switch between types. This keeps the codebase simpler (one service, one OAuth flow, one set of routes).
See `backend/windmill-native-triggers/src/google/` for the reference implementation.
### Skipping update+get After Create (Google Pattern)
Override `service_config_from_create_response()` to return `Some(config)` when the external_id is known before the create call:
```rust
fn service_config_from_create_response(
&self,
data: &NativeTriggerData<Self::ServiceConfig>,
resp: &Self::CreateResponse,
) -> Option<serde_json::Value> {
// Clone input config, add metadata from response
let mut config = data.service_config.clone();
config.google_resource_id = Some(resp.resource_id.clone());
config.expiration = Some(resp.expiration.clone());
Some(serde_json::to_value(&config).unwrap())
}
```
### Services with Absolute OAuth Endpoints (Google)
Unlike self-hosted services where OAuth endpoints are relative paths appended to `base_url`, services like Google have absolute URLs:
```rust
// Nextcloud: relative paths
ServiceName::Nextcloud => "/apps/oauth2/api/v1/token",
// Google: absolute URLs
ServiceName::Google => "https://oauth2.googleapis.com/token",
```
The `resolve_endpoint()` function handles both. For services with absolute endpoints:
- `base_url` can be empty
- `requiresBaseUrl: false` in the frontend workspace integration config
- Add `extra_auth_params()` if needed (Google requires `access_type=offline` and `prompt=consent`)
### Channel-Based Push Notifications with Renewal (Google Pattern)
For services using expiring watch channels instead of persistent webhooks:
1. Store expiration in `service_config` (as part of `ServiceConfig`)
2. In `maintain_triggers()`, implement renewal logic instead of using `reconcile_with_external_state()`:
```rust
async fn maintain_triggers(&self, db, workspace_id, triggers, oauth_data, synced, errors) {
for trigger in triggers {
if should_renew_channel(trigger) {
self.renew_channel(db, trigger, oauth_data).await;
}
}
}
```
3. Renewal: best-effort stop old channel, create new one with same external_id, update service_config with new expiration
4. Google example: Drive channels expire in 24h (renew when <1h left), Calendar channels expire in 7 days (renew when <1 day left)
### reconcile_with_external_state (Nextcloud Pattern)
The reusable function in `sync.rs` compares external triggers with DB state:
- Triggers missing externally: sets error "Trigger no longer exists on external service"
- Triggers present externally: clears errors, updates service_config if it differs
Usage in `maintain_triggers()`:
```rust
let external_pairs: Vec<(String, serde_json::Value)> = /* fetch from external */;
crate::sync::reconcile_with_external_state(
db, workspace_id, Self::SERVICE_NAME, triggers, &external_pairs, synced, errors,
).await;
```
### Webhook Payload Processing
Override `prepare_webhook()` to parse service-specific payloads into script/flow args:
```rust
async fn prepare_webhook(&self, db, w_id, headers, body, script_path, is_flow) -> Result<PushArgsOwned> {
let mut args = HashMap::new();
args.insert("event_type".to_string(), Box::new(headers.get("x-event-type").cloned()) as _);
args.insert("payload".to_string(), Box::new(serde_json::from_str::<serde_json::Value>(&body)?) as _);
Ok(PushArgsOwned { extra: None, args })
}
```
Then register in `prepare_native_trigger_args()` in `lib.rs`:
```rust
pub async fn prepare_native_trigger_args(service_name, db, w_id, headers, body) -> Result<Option<PushArgsOwned>> {
match service_name {
ServiceName::Google => { /* ... */ Ok(Some(args)) }
ServiceName::NewService => { /* ... */ Ok(Some(args)) }
ServiceName::Nextcloud => Ok(None), // Uses default body parsing
}
}
```
### Instance-Level OAuth Credentials
When `workspace_integrations.oauth_data.instance_shared == true`, `decrypt_oauth_data()` reads `client_id` and `client_secret` from instance-level global settings instead of workspace-level. This allows admins to share OAuth app credentials across workspaces.
The frontend handles this via the `generate_instance_connect_url` endpoint in `workspace_integrations.rs`.
---
## Testing Checklist
- [ ] Database migration runs successfully
- [ ] `cargo check -p windmill-native-triggers --features native_trigger` passes
- [ ] `npx svelte-check --threshold error` passes (in frontend/)
- [ ] Service appears in workspace integrations list
- [ ] OAuth flow completes successfully
- [ ] Can create a new trigger
- [ ] Can view trigger details
- [ ] Can update trigger configuration
- [ ] Can delete trigger
- [ ] Webhook receives and processes payloads
- [ ] Background sync works correctly (reconciliation or channel renewal)
- [ ] Error handling works (expired tokens, service unavailable)
---
## Reference Implementations
### Nextcloud (Self-Hosted, Update+Get Pattern)
| File | Purpose |
|------|---------|
| `nextcloud/mod.rs` | Types: NextCloudOAuthData, NextcloudServiceConfig, NextCloudTriggerData |
| `nextcloud/external.rs` | External trait: uses update+get pattern, reconcile_with_external_state for sync |
| `nextcloud/routes.rs` | Additional route: `GET /events` |
Key patterns: relative OAuth endpoints, base_url required, list_all + reconcile for sync, update returns JSON from get().
### Google (Cloud, Unified Service, Short Create)
| File | Purpose |
|------|---------|
| `google/mod.rs` | Types: GoogleServiceConfig with trigger_type discriminator, GoogleTriggerType enum |
| `google/external.rs` | External trait: overrides service_config_from_create_response, channel renewal for sync |
| `google/routes.rs` | Additional routes: `GET /calendars`, `GET /drive/files`, `GET /drive/shared_drives` |
Key patterns: absolute OAuth endpoints, empty base_url, trigger_type for Drive/Calendar, expiring watch channels with renewal, service_config_from_create_response skips update+get, get() reconstructs data from stored service_config (no external "get channel" API).

View File

@@ -1,5 +1,45 @@
# Changelog
## [1.636.0](https://github.com/windmill-labs/windmill/compare/v1.635.1...v1.636.0) (2026-02-16)
### Features
* allow adding workspace scripts and flows as AI chat context ([#7882](https://github.com/windmill-labs/windmill/issues/7882)) ([5b8ec50](https://github.com/windmill-labs/windmill/commit/5b8ec502fef8fb439200e18b8c610d0f5998b6df))
* google native triggers ([#7837](https://github.com/windmill-labs/windmill/issues/7837)) ([6f24f19](https://github.com/windmill-labs/windmill/commit/6f24f1939d75a597acc74c1589794d511e041baa))
### Bug Fixes
* mark base_url as unsaved when using browser fallback ([#7964](https://github.com/windmill-labs/windmill/issues/7964)) ([e7b0b00](https://github.com/windmill-labs/windmill/commit/e7b0b00f5696828dec094155298d0c9dc033b355))
## [1.635.1](https://github.com/windmill-labs/windmill/compare/v1.635.0...v1.635.1) (2026-02-15)
### Bug Fixes
* pin strum_macros to 0.27 to match strum version ([#7957](https://github.com/windmill-labs/windmill/issues/7957)) ([68f766e](https://github.com/windmill-labs/windmill/commit/68f766e1ae54dbe2fe42769559d81d4d76a409ef))
## [1.635.0](https://github.com/windmill-labs/windmill/compare/v1.634.6...v1.635.0) (2026-02-15)
### Features
* add Kubernetes operator and instance settings YAML editor ([#7836](https://github.com/windmill-labs/windmill/issues/7836)) ([82e5f6d](https://github.com/windmill-labs/windmill/commit/82e5f6de48e246a49b25e7d4ea7be65122e8772c))
* add maven settings.xml support for java private registries ([#7940](https://github.com/windmill-labs/windmill/issues/7940)) ([581dde8](https://github.com/windmill-labs/windmill/commit/581dde8d0bc4428a5e95fcb5341239231ab36ef6))
* **cli:** add `lint` command ([#7917](https://github.com/windmill-labs/windmill/issues/7917)) ([37d1277](https://github.com/windmill-labs/windmill/commit/37d1277b91d1b8a03e327b0585f547037482498d))
* handle $var: and $res: in arrays for transform_json_value ([#7949](https://github.com/windmill-labs/windmill/issues/7949)) ([e4a34d0](https://github.com/windmill-labs/windmill/commit/e4a34d031b2bdb1b73a2a7ca68544fa34f83ed0f))
* IaC hints, YAML editor for worker configs ([#7956](https://github.com/windmill-labs/windmill/issues/7956)) ([8b8e33e](https://github.com/windmill-labs/windmill/commit/8b8e33e2dc1a2b4c0effab70463f6d4b402a0f7f))
* open-source worker group configuration UI ([#7954](https://github.com/windmill-labs/windmill/issues/7954)) ([6cf3f5f](https://github.com/windmill-labs/windmill/commit/6cf3f5f4a35a6139b5cdf9f44af29c3941f19645))
### Bug Fixes
* allow renaming of backend runnables in the UI ([6215760](https://github.com/windmill-labs/windmill/commit/6215760b1294d55245909a1c1de6c4cc8cef320a))
* **go:** preserve proxy envs for go mod tidy/download ([#7946](https://github.com/windmill-labs/windmill/issues/7946)) ([8410b59](https://github.com/windmill-labs/windmill/commit/8410b59a8f23d62c57e497d170449643b46595a0))
* Missing app policy for datatable ([#7944](https://github.com/windmill-labs/windmill/issues/7944)) ([a9dbd1f](https://github.com/windmill-labs/windmill/commit/a9dbd1f73fca9100b64106281802c43881181e78))
* strip slack_oauth_client_secret from get_settings for non-admins ([#7950](https://github.com/windmill-labs/windmill/issues/7950)) ([43218c6](https://github.com/windmill-labs/windmill/commit/43218c62852490d0efafa8f94385bfe0e8f2ad82))
## [1.634.6](https://github.com/windmill-labs/windmill/compare/v1.634.5...v1.634.6) (2026-02-13)

View File

@@ -1,11 +1,11 @@
{
"db_name": "PostgreSQL",
"query": "\n SELECT\n oauth_data as \"oauth_data!: sqlx::types::Json<WorkspaceOAuthConfig>\",\n service_name as \"service_name!: ServiceName\"\n FROM\n workspace_integrations\n WHERE\n workspace_id = $1\n ",
"query": "\n SELECT\n oauth_data as \"oauth_data: sqlx::types::Json<WorkspaceOAuthConfig>\",\n service_name as \"service_name!: ServiceName\",\n resource_path\n FROM\n workspace_integrations\n WHERE\n workspace_id = $1\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "oauth_data!: sqlx::types::Json<WorkspaceOAuthConfig>",
"name": "oauth_data: sqlx::types::Json<WorkspaceOAuthConfig>",
"type_info": "Jsonb"
},
{
@@ -22,6 +22,11 @@
}
}
}
},
{
"ordinal": 2,
"name": "resource_path",
"type_info": "Text"
}
],
"parameters": {
@@ -30,9 +35,10 @@
]
},
"nullable": [
true,
false,
false
true
]
},
"hash": "5368683c19f8d6744d5dbc53e5b2ab0f2348646d79f5306c6868e2c3a8f389ee"
"hash": "0010ef26da16facd1c2c832601ac687c4c27de46a90f45496b8446af1a9d0578"
}

View File

@@ -0,0 +1,15 @@
{
"db_name": "PostgreSQL",
"query": "DELETE FROM resource WHERE workspace_id = $1 AND path = $2",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Text",
"Text"
]
},
"nullable": []
},
"hash": "05e05a9b979941c7a11cd881da652f459e4a0444d63a96deba4a879fbe1124ff"
}

View File

@@ -46,11 +46,11 @@
]
},
"nullable": [
false,
false,
false,
false,
false,
true,
true,
true,
true,
true,
true,
true
]

View File

@@ -30,7 +30,8 @@
"sqs",
"gcp",
"mqtt",
"nextcloud"
"nextcloud",
"google"
]
}
}

View File

@@ -24,7 +24,8 @@
"mqtt",
"gcp",
"default_email",
"nextcloud"
"nextcloud",
"google"
]
}
}

View File

@@ -122,7 +122,8 @@
"sqs",
"gcp",
"mqtt",
"nextcloud"
"nextcloud",
"google"
]
}
}

View File

@@ -24,7 +24,8 @@
"mqtt",
"gcp",
"default_email",
"nextcloud"
"nextcloud",
"google"
]
}
}

View File

@@ -40,7 +40,8 @@
"sqs",
"gcp",
"mqtt",
"nextcloud"
"nextcloud",
"google"
]
}
}

View File

@@ -1,11 +1,10 @@
{
"db_name": "PostgreSQL",
"query": "\n UPDATE workspace_integrations\n SET oauth_data = $1, updated_at = now()\n WHERE workspace_id = $2 AND service_name = $3\n ",
"query": "DELETE FROM native_trigger WHERE workspace_id = $1 AND service_name = $2",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Jsonb",
"Text",
{
"Custom": {
@@ -22,5 +21,5 @@
},
"nullable": []
},
"hash": "3b3f60623126626b52ca0a4a188655ddf728cd3f21ee308db7393694ccc5c7b3"
"hash": "1af48c42255f1c973b4a9c9a58050bf5ec1ee6f93f0a90c1c7d0c0fcd816702d"
}

View File

@@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
"query": "SELECT client, refresh_token, grant_type, cc_client_id, cc_client_secret, cc_token_url, mcp_server_url FROM account WHERE workspace_id = $1 AND id = $2",
"query": "SELECT client, refresh_token, grant_type, cc_client_id, cc_client_secret, cc_token_url, mcp_server_url, is_workspace_integration FROM account WHERE workspace_id = $1 AND id = $2",
"describe": {
"columns": [
{
@@ -37,6 +37,11 @@
"ordinal": 6,
"name": "mcp_server_url",
"type_info": "Text"
},
{
"ordinal": 7,
"name": "is_workspace_integration",
"type_info": "Bool"
}
],
"parameters": {
@@ -52,8 +57,9 @@
true,
true,
true,
true
true,
false
]
},
"hash": "e26ccc6607a9c78c1a8c1fd7b3bec931cf0ed27f79f852ae7f63a0ed6e12042f"
"hash": "1ba2e23d4ba816048ec1e88af9e342867fc0443cabea16d111afa2b91d3fe03b"
}

View File

@@ -34,7 +34,8 @@
"sqs",
"gcp",
"mqtt",
"nextcloud"
"nextcloud",
"google"
]
}
}
@@ -67,7 +68,8 @@
"sqs",
"gcp",
"mqtt",
"nextcloud"
"nextcloud",
"google"
]
}
}

View File

@@ -24,7 +24,8 @@
"mqtt",
"gcp",
"default_email",
"nextcloud"
"nextcloud",
"google"
]
}
}

View File

@@ -40,7 +40,8 @@
"mqtt",
"gcp",
"default_email",
"nextcloud"
"nextcloud",
"google"
]
}
}

View File

@@ -0,0 +1,25 @@
{
"db_name": "PostgreSQL",
"query": "INSERT INTO account (workspace_id, client, expires_at, refresh_token, is_workspace_integration)\n VALUES ($1, $2, now() + ($3 || ' seconds')::interval, $4, true)\n RETURNING id",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "id",
"type_info": "Int4"
}
],
"parameters": {
"Left": [
"Varchar",
"Varchar",
"Text",
"Varchar"
]
},
"nullable": [
false
]
},
"hash": "26beff5e94b68703ad81ef9dd2d08869eb3bb7659efd9bac04cdf98ae963063d"
}

View File

@@ -0,0 +1,23 @@
{
"db_name": "PostgreSQL",
"query": "DELETE FROM account WHERE workspace_id = $1 AND client = $2 AND is_workspace_integration = true RETURNING id",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "id",
"type_info": "Int4"
}
],
"parameters": {
"Left": [
"Text",
"Text"
]
},
"nullable": [
false
]
},
"hash": "27065225c6affd26f1533dacffe1c38321511b5a7dd2a7e9435c04868188fd44"
}

View File

@@ -0,0 +1,20 @@
{
"db_name": "PostgreSQL",
"query": "SELECT count(*) FROM native_trigger WHERE workspace_id = 'test-workspace' AND service_name = 'google'",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "count",
"type_info": "Int8"
}
],
"parameters": {
"Left": []
},
"nullable": [
null
]
},
"hash": "2e5dd992b0bfd7550d6f4cb5424a1c14352527b98249bce286790641bf56491e"
}

View File

@@ -24,7 +24,8 @@
"mqtt",
"gcp",
"default_email",
"nextcloud"
"nextcloud",
"google"
]
}
}

View File

@@ -30,7 +30,8 @@
"mqtt",
"gcp",
"default_email",
"nextcloud"
"nextcloud",
"google"
]
}
}

View File

@@ -0,0 +1,14 @@
{
"db_name": "PostgreSQL",
"query": "INSERT INTO global_settings (name, value) VALUES ('oauths', $1)\n ON CONFLICT (name) DO UPDATE SET value = $1",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Jsonb"
]
},
"nullable": []
},
"hash": "4be53f0b801ebc1a33a184556fd138fdec8082f31f56d7023cf8c6311964f3b0"
}

View File

@@ -37,7 +37,8 @@
"mqtt",
"gcp",
"default_email",
"nextcloud"
"nextcloud",
"google"
]
}
}

View File

@@ -32,7 +32,8 @@
"mqtt",
"gcp",
"default_email",
"nextcloud"
"nextcloud",
"google"
]
}
}
@@ -70,7 +71,8 @@
"mqtt",
"gcp",
"default_email",
"nextcloud"
"nextcloud",
"google"
]
}
}

View File

@@ -245,7 +245,8 @@
"sqs",
"gcp",
"mqtt",
"nextcloud"
"nextcloud",
"google"
]
}
}

View File

@@ -0,0 +1,22 @@
{
"db_name": "PostgreSQL",
"query": "SELECT count(*) FROM variable WHERE workspace_id = 'test-workspace' AND path = $1",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "count",
"type_info": "Int8"
}
],
"parameters": {
"Left": [
"Text"
]
},
"nullable": [
null
]
},
"hash": "607c13333627e79557d3b6f6f68eee0a5dbe7cd4643e4bf99a592eb1bb82580c"
}

View File

@@ -35,7 +35,8 @@
"mqtt",
"gcp",
"default_email",
"nextcloud"
"nextcloud",
"google"
]
}
}

View File

@@ -0,0 +1,19 @@
{
"db_name": "PostgreSQL",
"query": "INSERT INTO resource (workspace_id, path, value, resource_type, description, created_by)\n VALUES ($1, $2, $3, $4, $5, $6)\n ON CONFLICT (workspace_id, path) DO UPDATE\n SET value = EXCLUDED.value, resource_type = EXCLUDED.resource_type",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Varchar",
"Varchar",
"Jsonb",
"Varchar",
"Text",
"Varchar"
]
},
"nullable": []
},
"hash": "6868520d496afe306bbd93293076ea4bb155097d1e8d3ffe5b75dd80ced735de"
}

View File

@@ -0,0 +1,23 @@
{
"db_name": "PostgreSQL",
"query": "SELECT refresh_token FROM account WHERE workspace_id = $1 AND id = $2",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "refresh_token",
"type_info": "Varchar"
}
],
"parameters": {
"Left": [
"Text",
"Int4"
]
},
"nullable": [
false
]
},
"hash": "6b4d48527af6f1411dc5e03f9144fb127488a79ac53a154d71253628320b1084"
}

View File

@@ -0,0 +1,16 @@
{
"db_name": "PostgreSQL",
"query": "UPDATE account SET\n refresh_token = $1,\n expires_at = now() + interval '1 hour',\n refresh_error = NULL\n WHERE workspace_id = $2 AND client = $3 AND is_workspace_integration = true",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Varchar",
"Text",
"Text"
]
},
"nullable": []
},
"hash": "6e83478011a8f65bf294ad7886139369e386f6a552c6626be48ecaa0e5ab78a7"
}

View File

@@ -29,7 +29,8 @@
"mqtt",
"gcp",
"default_email",
"nextcloud"
"nextcloud",
"google"
]
}
}

View File

@@ -24,7 +24,8 @@
"mqtt",
"gcp",
"default_email",
"nextcloud"
"nextcloud",
"google"
]
}
}

View File

@@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
"query": "\n SELECT\n workspace_id,\n service_name AS \"service_name!: ServiceName\",\n oauth_data,\n created_at,\n updated_at,\n created_by\n FROM\n workspace_integrations\n WHERE\n workspace_id = $1\n AND service_name = $2\n ",
"query": "\n SELECT\n workspace_id,\n service_name AS \"service_name!: ServiceName\",\n oauth_data,\n resource_path,\n created_at,\n updated_at,\n created_by\n FROM\n workspace_integrations\n WHERE\n workspace_id = $1\n AND service_name = $2\n ",
"describe": {
"columns": [
{
@@ -30,16 +30,21 @@
},
{
"ordinal": 3,
"name": "resource_path",
"type_info": "Text"
},
{
"ordinal": 4,
"name": "created_at",
"type_info": "Timestamptz"
},
{
"ordinal": 4,
"ordinal": 5,
"name": "updated_at",
"type_info": "Timestamptz"
},
{
"ordinal": 5,
"ordinal": 6,
"name": "created_by",
"type_info": "Varchar"
}
@@ -63,11 +68,12 @@
"nullable": [
false,
false,
false,
true,
true,
false,
false,
false
]
},
"hash": "3e5bdc2e071fc2f1e3c7971736272f20bb5a0aa921a614bd02898d3f162660c2"
"hash": "7443ba1f922e190bb9a0ca313847f8d27c35a6ee3aff20157d6285e73aa923ef"
}

View File

@@ -40,7 +40,8 @@
"sqs",
"gcp",
"mqtt",
"nextcloud"
"nextcloud",
"google"
]
}
}

View File

@@ -0,0 +1,23 @@
{
"db_name": "PostgreSQL",
"query": "INSERT INTO account (workspace_id, client, expires_at, refresh_token, is_workspace_integration)\n VALUES ('test-workspace', $1, now() + interval '1 hour', $2, true)\n RETURNING id",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "id",
"type_info": "Int4"
}
],
"parameters": {
"Left": [
"Varchar",
"Varchar"
]
},
"nullable": [
false
]
},
"hash": "7a57f58e809e482a599722d3887fb7e115506ff1e5ec9cf6dd2af84ed9a78632"
}

View File

@@ -0,0 +1,15 @@
{
"db_name": "PostgreSQL",
"query": "UPDATE account SET\n expires_at = now() + interval '1 hour',\n refresh_error = NULL\n WHERE workspace_id = $1 AND client = $2 AND is_workspace_integration = true",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Text",
"Text"
]
},
"nullable": []
},
"hash": "7b1d29170c9c4ad4e3a15c4e8acbeb6769dc6fc97269beee607a5625a495a121"
}

View File

@@ -27,7 +27,8 @@
"mqtt",
"gcp",
"default_email",
"nextcloud"
"nextcloud",
"google"
]
}
}

View File

@@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
"query": "\n SELECT EXISTS (\n SELECT 1\n FROM workspace_integrations\n WHERE workspace_id = $1\n AND service_name = $2\n AND oauth_data IS NOT NULL\n )\n ",
"query": "\n SELECT EXISTS (\n SELECT 1\n FROM workspace_integrations wi\n WHERE wi.workspace_id = $1\n AND wi.service_name = $2\n AND wi.oauth_data IS NOT NULL\n )\n ",
"describe": {
"columns": [
{
@@ -29,5 +29,5 @@
null
]
},
"hash": "95c57fb921a2e3725b92cbafac6e3dc360b88429f03dd1e2b1b55cfabe208cb7"
"hash": "823fc5f998fe747ec8537752d9eb7ef548b2fd9ee1f5380084f27796c2bcc8ad"
}

View File

@@ -0,0 +1,15 @@
{
"db_name": "PostgreSQL",
"query": "DELETE FROM variable WHERE workspace_id = $1 AND account = ANY($2)",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Text",
"Int4Array"
]
},
"nullable": []
},
"hash": "826a4216830f6a930c382209a20bc7f8b460064480e080b989e31df3d6a30e31"
}

View File

@@ -24,7 +24,8 @@
"mqtt",
"gcp",
"default_email",
"nextcloud"
"nextcloud",
"google"
]
}
}

View File

@@ -0,0 +1,39 @@
{
"db_name": "PostgreSQL",
"query": "SELECT external_id, webhook_token_prefix FROM native_trigger WHERE workspace_id = $1 AND service_name = $2",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "external_id",
"type_info": "Varchar"
},
{
"ordinal": 1,
"name": "webhook_token_prefix",
"type_info": "Varchar"
}
],
"parameters": {
"Left": [
"Text",
{
"Custom": {
"name": "native_trigger_service",
"kind": {
"Enum": [
"nextcloud",
"google"
]
}
}
}
]
},
"nullable": [
false,
false
]
},
"hash": "83d6e371ca84903e9f487afc065353a9f7be86ff752612909587ec3cb770cb75"
}

View File

@@ -35,7 +35,8 @@
"mqtt",
"gcp",
"default_email",
"nextcloud"
"nextcloud",
"google"
]
}
}

View File

@@ -0,0 +1,16 @@
{
"db_name": "PostgreSQL",
"query": "UPDATE workspace_integrations SET resource_path = REGEXP_REPLACE(resource_path, 'u/' || $2 || '/(.*)', 'u/' || $1 || '/\\1') WHERE resource_path LIKE ('u/' || $2 || '/%') AND workspace_id = $3",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Text",
"Text",
"Text"
]
},
"nullable": []
},
"hash": "89791ad1a0862b6475ebfdeb54b0101e124fcf9d12e93d84b44457c72c7604a5"
}

View File

@@ -1,12 +1,12 @@
{
"db_name": "PostgreSQL",
"query": "\n SELECT EXISTS(\n SELECT 1\n FROM native_trigger\n WHERE\n workspace_id = $1 AND\n service_name = $2 AND\n external_id = $3\n )\n ",
"query": "\n SELECT service_config\n FROM native_trigger\n WHERE external_id = $1 AND service_name = $2 AND workspace_id = $3\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "exists",
"type_info": "Bool"
"name": "service_config",
"type_info": "Jsonb"
}
],
"parameters": {
@@ -27,8 +27,8 @@
]
},
"nullable": [
null
true
]
},
"hash": "06072cfe26abe58629623a8b38382b33947c5a5c702ce586e6e6ea51430380bf"
"hash": "8a4d42e373043bc509985ab320894bf3e6afa7b2019cc4739baac9165f7ead9e"
}

View File

@@ -0,0 +1,15 @@
{
"db_name": "PostgreSQL",
"query": "UPDATE account SET refresh_token = $1 WHERE workspace_id = 'test-workspace' AND id = $2",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Varchar",
"Int4"
]
},
"nullable": []
},
"hash": "8e5881225f4bf7243bd40397ac8b8708fb6ce6c0ba0d263bb7eeb404f5dd62ff"
}

View File

@@ -0,0 +1,15 @@
{
"db_name": "PostgreSQL",
"query": "DELETE FROM resource WHERE workspace_id = $1 AND resource_type = $2 AND path LIKE 'u/%/native_%'",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Text",
"Text"
]
},
"nullable": []
},
"hash": "8f33846f6c25a78267a5c8143b414f033280ebf57b7b9568dc2fe31bc625020d"
}

View File

@@ -26,7 +26,7 @@
]
},
"nullable": [
false
true
]
},
"hash": "9052b7cd438ff029a37bd489190d98d365acec09f2f102b7de71dcc9d356900e"

View File

@@ -0,0 +1,22 @@
{
"db_name": "PostgreSQL",
"query": "SELECT count(*) FROM resource WHERE workspace_id = 'test-workspace' AND path = $1",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "count",
"type_info": "Int8"
}
],
"parameters": {
"Left": [
"Text"
]
},
"nullable": [
null
]
},
"hash": "9242b5a866d0dd489bebe4284413d37202be70068affca92c45d112e0210538a"
}

View File

@@ -24,7 +24,8 @@
"mqtt",
"gcp",
"default_email",
"nextcloud"
"nextcloud",
"google"
]
}
}

View File

@@ -0,0 +1,33 @@
{
"db_name": "PostgreSQL",
"query": "SELECT resource_path FROM workspace_integrations WHERE workspace_id = $1 AND service_name = $2",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "resource_path",
"type_info": "Text"
}
],
"parameters": {
"Left": [
"Text",
{
"Custom": {
"name": "native_trigger_service",
"kind": {
"Enum": [
"nextcloud",
"google"
]
}
}
}
]
},
"nullable": [
true
]
},
"hash": "993e514229a1d508a44ef07b4399f9697cdefaa1f45ca665f72bc6fcf2797c7e"
}

View File

@@ -32,7 +32,8 @@
"mqtt",
"gcp",
"default_email",
"nextcloud"
"nextcloud",
"google"
]
}
}

View File

@@ -30,7 +30,8 @@
"sqs",
"gcp",
"mqtt",
"nextcloud"
"nextcloud",
"google"
]
}
}

View File

@@ -155,7 +155,8 @@
"sqs",
"gcp",
"mqtt",
"nextcloud"
"nextcloud",
"google"
]
}
}

View File

@@ -185,7 +185,8 @@
"sqs",
"gcp",
"mqtt",
"nextcloud"
"nextcloud",
"google"
]
}
}

View File

@@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
"query": "\n INSERT INTO workspace_integrations (\n workspace_id,\n service_name,\n oauth_data,\n created_by,\n created_at,\n updated_at\n ) VALUES (\n $1, $2, $3, $4, now(), now()\n )\n ON CONFLICT (workspace_id, service_name)\n DO UPDATE SET\n oauth_data = $3,\n updated_at = now()\n ",
"query": "\n INSERT INTO workspace_integrations (\n workspace_id,\n service_name,\n oauth_data,\n resource_path,\n created_by,\n created_at,\n updated_at\n ) VALUES (\n $1, $2, $3, $4, $5, now(), now()\n )\n ON CONFLICT (workspace_id, service_name)\n DO UPDATE SET\n oauth_data = $3,\n resource_path = $4,\n updated_at = now()\n ",
"describe": {
"columns": [],
"parameters": {
@@ -18,10 +18,11 @@
}
},
"Jsonb",
"Text",
"Varchar"
]
},
"nullable": []
},
"hash": "2602402bedcdbc45cdc0d64a76ce075c6ae51404037b0a7d4d33faf6a7d6a6d8"
"hash": "a588f4caa014008b50eccd09122b09f8a098e58791893bacaaf2ff67a30c031c"
}

View File

@@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
"query": "\n SELECT\n EXISTS(SELECT 1 FROM websocket_trigger WHERE workspace_id = $1) AS \"websocket_used!\",\n EXISTS(SELECT 1 FROM http_trigger WHERE workspace_id = $1) AS \"http_routes_used!\",\n EXISTS(SELECT 1 FROM kafka_trigger WHERE workspace_id = $1) as \"kafka_used!\",\n EXISTS(SELECT 1 FROM nats_trigger WHERE workspace_id = $1) as \"nats_used!\",\n EXISTS(SELECT 1 FROM postgres_trigger WHERE workspace_id = $1) AS \"postgres_used!\",\n EXISTS(SELECT 1 FROM mqtt_trigger WHERE workspace_id = $1) AS \"mqtt_used!\",\n EXISTS(SELECT 1 FROM sqs_trigger WHERE workspace_id = $1) AS \"sqs_used!\",\n EXISTS(SELECT 1 FROM gcp_trigger WHERE workspace_id = $1) AS \"gcp_used!\",\n EXISTS(SELECT 1 FROM email_trigger WHERE workspace_id = $1) AS \"email_used!\",\n EXISTS(SELECT 1 FROM native_trigger WHERE workspace_id = $1 AND service_name = 'nextcloud'::native_trigger_service) AS \"nextcloud_used!\"\n ",
"query": "\n SELECT\n EXISTS(SELECT 1 FROM websocket_trigger WHERE workspace_id = $1) AS \"websocket_used!\",\n EXISTS(SELECT 1 FROM http_trigger WHERE workspace_id = $1) AS \"http_routes_used!\",\n EXISTS(SELECT 1 FROM kafka_trigger WHERE workspace_id = $1) as \"kafka_used!\",\n EXISTS(SELECT 1 FROM nats_trigger WHERE workspace_id = $1) as \"nats_used!\",\n EXISTS(SELECT 1 FROM postgres_trigger WHERE workspace_id = $1) AS \"postgres_used!\",\n EXISTS(SELECT 1 FROM mqtt_trigger WHERE workspace_id = $1) AS \"mqtt_used!\",\n EXISTS(SELECT 1 FROM sqs_trigger WHERE workspace_id = $1) AS \"sqs_used!\",\n EXISTS(SELECT 1 FROM gcp_trigger WHERE workspace_id = $1) AS \"gcp_used!\",\n EXISTS(SELECT 1 FROM email_trigger WHERE workspace_id = $1) AS \"email_used!\",\n EXISTS(SELECT 1 FROM native_trigger WHERE workspace_id = $1 AND service_name = 'nextcloud'::native_trigger_service) AS \"nextcloud_used!\",\n EXISTS(SELECT 1 FROM native_trigger WHERE workspace_id = $1 AND service_name = 'google'::native_trigger_service) AS \"google_used!\"\n ",
"describe": {
"columns": [
{
@@ -52,6 +52,11 @@
"ordinal": 9,
"name": "nextcloud_used!",
"type_info": "Bool"
},
{
"ordinal": 10,
"name": "google_used!",
"type_info": "Bool"
}
],
"parameters": {
@@ -69,8 +74,9 @@
null,
null,
null,
null,
null
]
},
"hash": "d14e982c3d74499ec4bc62118e0edf065799eec5cf16f439b5f7568f392e60c3"
"hash": "a6a25545af16db9f03552ce2ac178cfda3f2ced1b8f1e60bdfc7d84c642903d2"
}

View File

@@ -0,0 +1,18 @@
{
"db_name": "PostgreSQL",
"query": "INSERT INTO variable (workspace_id, path, value, is_secret, description, account, is_oauth)\n VALUES ($1, $2, $3, true, $4, $5, true)\n ON CONFLICT (workspace_id, path) DO UPDATE\n SET value = EXCLUDED.value, account = EXCLUDED.account",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Varchar",
"Varchar",
"Varchar",
"Varchar",
"Int4"
]
},
"nullable": []
},
"hash": "ab5720c0af66aba9fd7d6b842f098878c431d52bdd7b71355fc7192144b91300"
}

View File

@@ -160,7 +160,8 @@
"sqs",
"gcp",
"mqtt",
"nextcloud"
"nextcloud",
"google"
]
}
}

View File

@@ -24,7 +24,8 @@
"mqtt",
"gcp",
"default_email",
"nextcloud"
"nextcloud",
"google"
]
}
}

View File

@@ -0,0 +1,24 @@
{
"db_name": "PostgreSQL",
"query": "SELECT COUNT(*) FROM native_trigger WHERE workspace_id = $1 AND script_path = $2 AND is_flow = $3 AND service_name = 'google'",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "count",
"type_info": "Int8"
}
],
"parameters": {
"Left": [
"Text",
"Text",
"Bool"
]
},
"nullable": [
null
]
},
"hash": "bc1298e492d3008386d9b1b449a98156fe186832494d16a434921727e5d3314d"
}

View File

@@ -105,7 +105,8 @@
"sqs",
"gcp",
"mqtt",
"nextcloud"
"nextcloud",
"google"
]
}
}

View File

@@ -0,0 +1,29 @@
{
"db_name": "PostgreSQL",
"query": "SELECT value, account FROM variable WHERE workspace_id = $1 AND path = $2",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "value",
"type_info": "Varchar"
},
{
"ordinal": 1,
"name": "account",
"type_info": "Int4"
}
],
"parameters": {
"Left": [
"Text",
"Text"
]
},
"nullable": [
false,
true
]
},
"hash": "c0aff25f0cc3b71842b0ba9ae55b6bc5eca203bf02f46164db08580d128b860a"
}

View File

@@ -0,0 +1,22 @@
{
"db_name": "PostgreSQL",
"query": "SELECT count(*) FROM account WHERE workspace_id = 'test-workspace' AND id = $1",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "count",
"type_info": "Int8"
}
],
"parameters": {
"Left": [
"Int4"
]
},
"nullable": [
null
]
},
"hash": "c1757ea525295ac9a0681be83a1f9d1e70944f65562e38c078b683e09cd9fb09"
}

View File

@@ -0,0 +1,16 @@
{
"db_name": "PostgreSQL",
"query": "INSERT INTO resource (workspace_id, path, value, resource_type, extra_perms, created_by)\n VALUES ('test-workspace', $1, $2, $3, '{}'::jsonb, 'test-user')",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Varchar",
"Jsonb",
"Varchar"
]
},
"nullable": []
},
"hash": "c2bf1109d208d3aa989b2e12c0380f54638edc40788d7417a08d08a267426b5e"
}

View File

@@ -31,7 +31,8 @@
"mqtt",
"gcp",
"default_email",
"nextcloud"
"nextcloud",
"google"
]
}
}

View File

@@ -24,7 +24,8 @@
"mqtt",
"gcp",
"default_email",
"nextcloud"
"nextcloud",
"google"
]
}
}

View File

@@ -105,7 +105,8 @@
"sqs",
"gcp",
"mqtt",
"nextcloud"
"nextcloud",
"google"
]
}
}

View File

@@ -25,7 +25,8 @@
"mqtt",
"gcp",
"default_email",
"nextcloud"
"nextcloud",
"google"
]
}
}

View File

@@ -0,0 +1,33 @@
{
"db_name": "PostgreSQL",
"query": "SELECT oauth_data FROM workspace_integrations\n WHERE workspace_id = $1 AND service_name = $2",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "oauth_data",
"type_info": "Jsonb"
}
],
"parameters": {
"Left": [
"Text",
{
"Custom": {
"name": "native_trigger_service",
"kind": {
"Enum": [
"nextcloud",
"google"
]
}
}
}
]
},
"nullable": [
true
]
},
"hash": "e241023a0d7b24adf7940ae764f14136b6d19fefbd8389e5ecd3bfc9bd652632"
}

View File

@@ -0,0 +1,15 @@
{
"db_name": "PostgreSQL",
"query": "UPDATE variable SET value = $1 WHERE workspace_id = 'test-workspace' AND path = $2",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Varchar",
"Text"
]
},
"nullable": []
},
"hash": "e4c508a9bc69ccb4b32cf50caa97f9a2ff7c5990df953296d7227c1c81bc5130"
}

View File

@@ -185,7 +185,8 @@
"sqs",
"gcp",
"mqtt",
"nextcloud"
"nextcloud",
"google"
]
}
}

View File

@@ -31,7 +31,8 @@
"mqtt",
"gcp",
"default_email",
"nextcloud"
"nextcloud",
"google"
]
}
}

View File

@@ -0,0 +1,16 @@
{
"db_name": "PostgreSQL",
"query": "INSERT INTO variable (workspace_id, path, value, is_secret, description, account, is_oauth)\n VALUES ('test-workspace', $1, $2, true, 'test oauth token', $3, true)",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Varchar",
"Varchar",
"Int4"
]
},
"nullable": []
},
"hash": "e80dc984cd1d2388cbf17206ad059137cf7f92d0222382af1a66de807f3138e8"
}

View File

@@ -24,7 +24,8 @@
"mqtt",
"gcp",
"default_email",
"nextcloud"
"nextcloud",
"google"
]
}
}

View File

@@ -24,7 +24,8 @@
"mqtt",
"gcp",
"default_email",
"nextcloud"
"nextcloud",
"google"
]
}
}

View File

@@ -0,0 +1,20 @@
{
"db_name": "PostgreSQL",
"query": "SELECT email FROM password ORDER BY email LIMIT 1000",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "email",
"type_info": "Varchar"
}
],
"parameters": {
"Left": []
},
"nullable": [
false
]
},
"hash": "f11d3a43f804a33e412a69be54824d7666d4dac0b824095c4c4fefb1f802c6f7"
}

View File

@@ -0,0 +1,16 @@
{
"db_name": "PostgreSQL",
"query": "UPDATE workspace_integrations SET resource_path = $1 WHERE workspace_id = $2 AND resource_path = $3",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Text",
"Text",
"Text"
]
},
"nullable": []
},
"hash": "f5460eb13ea4f0e9896928a3266e419090f72e54f7347b35254a661116ba822d"
}

445
backend/Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
[package]
name = "windmill"
version = "1.634.6"
version = "1.636.0"
authors.workspace = true
edition.workspace = true
@@ -75,7 +75,7 @@ members = [
exclude = ["./windmill-duckdb-ffi-internal"]
[workspace.package]
version = "1.634.6"
version = "1.636.0"
authors = ["Ruben Fiszel <ruben@windmill.dev>"]
edition = "2021"
@@ -96,7 +96,7 @@ lto = "thin"
[features]
default = []
private = ["windmill-api/private", "windmill-api-agent-workers?/private", "windmill-autoscaling/private", "windmill-common/private", "windmill-git-sync/private", "windmill-indexer/private", "windmill-queue/private", "windmill-worker/private", "windmill-test-utils/private"]
private = ["windmill-api/private", "windmill-api-agent-workers?/private", "windmill-autoscaling/private", "windmill-common/private", "windmill-git-sync/private", "windmill-indexer/private", "windmill-operator?/private", "windmill-queue/private", "windmill-worker/private", "windmill-test-utils/private"]
agent_worker_server = ["windmill-api/agent_worker_server", "dep:windmill-api-agent-workers", "windmill-test-utils/agent_worker_server"]
enterprise = ["windmill-worker/enterprise", "windmill-queue/enterprise", "windmill-api/enterprise", "windmill-api-agent-workers?/enterprise", "dep:windmill-autoscaling", "windmill-autoscaling/enterprise", "windmill-git-sync/enterprise", "windmill-common/prometheus", "windmill-common/enterprise"]
local_reports = ["windmill-common/local_reports"]
@@ -166,7 +166,7 @@ oss_core = [
"static_frontend", "mcp", "bedrock", "inline_preview",
"quickjs"
]
ce_core = ["oss_core", "private"]
ce_core = ["oss_core", "private", "operator"]
ee_core = [
"enterprise", "stripe", "prometheus", "cloud",
"kafka", "sqs_trigger", "nats", "gcp_trigger",
@@ -584,7 +584,7 @@ tree-sitter-ruby = "0.23.0"
oracle = { version = "0.6.3", features = ["chrono"] }
rumqttc = { version = "0.24.0", features = ["use-native-tls"]}
strum = { version = "0.27", features = ["derive"] }
strum_macros = "^0"
strum_macros = "0.27"
hudsucker = { version = "0.22", features = ["rcgen-ca", "native-tls-client"] }
hyper-http-proxy = { version = "1", default-features = false, features = ["native-tls"] }
rcgen = "0.13"

View File

@@ -1 +1 @@
e7f80bca9320580e1cb96b4f4ca9942649abce7f
9f6e1e533df7711600ec2b8d5f0c958448db1a20

View File

@@ -0,0 +1,11 @@
-- Note: PostgreSQL does not support removing enum values directly.
-- This down migration is a placeholder. To fully reverse, you would need to:
-- 1. Create a new enum type without the values
-- 2. Update all columns to use the new type
-- 3. Drop the old type
-- 4. Rename the new type
-- For now, we just document what was added:
-- Removed from native_trigger_service: 'google'
-- Removed from TRIGGER_KIND: 'google'
-- Removed from job_trigger_kind: 'google'

View File

@@ -0,0 +1,10 @@
-- Add Google to native_trigger_service enum
-- 'google' is a unified service that handles both Drive and Calendar triggers
-- The trigger_type field in service_config determines which Google service is used
ALTER TYPE native_trigger_service ADD VALUE IF NOT EXISTS 'google';
-- Add to TRIGGER_KIND enum (used for trigger tracking)
ALTER TYPE TRIGGER_KIND ADD VALUE IF NOT EXISTS 'google';
-- Add to job_trigger_kind enum (used for job tracking)
ALTER TYPE job_trigger_kind ADD VALUE IF NOT EXISTS 'google';

View File

@@ -0,0 +1,3 @@
ALTER TABLE account DROP COLUMN IF EXISTS is_workspace_integration;
ALTER TABLE workspace_integrations ALTER COLUMN oauth_data SET NOT NULL;
ALTER TABLE workspace_integrations DROP COLUMN IF EXISTS resource_path;

View File

@@ -0,0 +1,10 @@
-- Migrate native trigger OAuth tokens from workspace_integrations to account+variable+resource pattern
-- Add flag to distinguish workspace integration accounts from regular user OAuth accounts
ALTER TABLE account ADD COLUMN is_workspace_integration BOOLEAN NOT NULL DEFAULT false;
-- Make oauth_data nullable since it will only store client config (no tokens) going forward
ALTER TABLE workspace_integrations ALTER COLUMN oauth_data DROP NOT NULL;
-- Add resource_path column to workspace_integrations
ALTER TABLE workspace_integrations ADD COLUMN IF NOT EXISTS resource_path TEXT;

View File

@@ -1346,6 +1346,10 @@ pub async fn reload_maven_settings_xml_setting(conn: &Connection) {
)
.await;
if !cfg!(feature = "enterprise") {
return;
}
let settings_xml = MAVEN_SETTINGS_XML.read().await.clone();
match settings_xml {
Some(ref content) if !content.trim().is_empty() => {

View File

@@ -23,7 +23,7 @@ use windmill_common::{
DB,
};
use windmill_api_auth::{ApiAuthed, require_devops_role};
use windmill_api_auth::{require_devops_role, ApiAuthed};
pub fn global_service() -> Router {
Router::new()
@@ -128,12 +128,16 @@ async fn update_config(
require_devops_role(&db, &authed.email).await?;
#[cfg(not(feature = "enterprise"))]
if name.starts_with("worker__") {
return Err(error::Error::BadRequest(
"Worker groups configurable from UI available only in the enterprise version"
.to_string(),
));
}
let config = if name.starts_with("worker__") {
// In CE, only allow setting worker_tags, cache_clear, and init_bash
serde_json::json!({
"worker_tags": config.get("worker_tags"),
"cache_clear": config.get("cache_clear"),
"init_bash": config.get("init_bash")
})
} else {
config
};
if name.starts_with("worker__") {
let periodic_script_bash = config

View File

@@ -19,6 +19,8 @@ mcp = []
windmill-test-utils.workspace = true
windmill-api-client.workspace = true
windmill-common = { workspace = true, default-features = false }
windmill-native-triggers = { workspace = true, features = ["native_trigger"] }
windmill-api-auth.workspace = true
sqlx.workspace = true
serde_json.workspace = true
serde.workspace = true

View File

@@ -0,0 +1,595 @@
/*!
* Integration tests for the native trigger system (Google).
*
* Tests cover 4 business-logic areas:
* 1. Resource path change — cleanup old path, recreate at new path
* 2. Config loading — workspace-level, instance-level, token update
* 3. Channel expiration renewal — should_renew_channel pure logic
* 4. Delete workspace integration — full cascade, cleanup preserves triggers, parse_stop_channel_params
*/
use serde_json::json;
use sqlx::{Pool, Postgres};
use windmill_api_auth::ApiAuthed;
use windmill_common::variables::{build_crypt, encrypt};
use windmill_native_triggers::{
decrypt_oauth_data, delete_native_trigger, delete_workspace_integration,
get_workspace_integration,
google::{parse_stop_channel_params, should_renew_channel},
store_native_trigger, store_workspace_integration, NativeTriggerConfig, OAuthConfig,
ServiceName,
};
// ============================================================================
// Helpers
// ============================================================================
async fn insert_test_script(db: &Pool<Postgres>, path: &str) -> anyhow::Result<i64> {
let hash: i64 = rand::random::<i64>().unsigned_abs() as i64;
sqlx::query(
"INSERT INTO script (workspace_id, hash, path, summary, description, content,
created_by, language, kind, lock)
VALUES ('test-workspace', $1, $2, '', '', 'def main(): pass',
'test-user', 'python3', 'script', '')",
)
.bind(hash)
.bind(path)
.execute(db)
.await?;
Ok(hash)
}
fn test_authed() -> ApiAuthed {
ApiAuthed {
email: "test@windmill.dev".to_string(),
username: "test-user".to_string(),
is_admin: true,
is_operator: false,
groups: vec!["all".to_string()],
folders: vec![],
scopes: None,
username_override: None,
token_prefix: None,
}
}
/// Set up a complete workspace integration with account+variable+resource.
/// Returns (resource_path, account_id).
async fn setup_oauth_integration(
db: &Pool<Postgres>,
service_name: ServiceName,
resource_path: &str,
access_token: &str,
refresh_token: &str,
oauth_data_override: Option<serde_json::Value>,
) -> anyhow::Result<i32> {
// 1. Create account with is_workspace_integration=true
let account_id: i32 = sqlx::query_scalar!(
"INSERT INTO account (workspace_id, client, expires_at, refresh_token, is_workspace_integration)
VALUES ('test-workspace', $1, now() + interval '1 hour', $2, true)
RETURNING id",
service_name.as_str(),
refresh_token,
)
.fetch_one(db)
.await?;
// 2. Encrypt and create variable
let mc = build_crypt(db, "test-workspace").await?;
let encrypted = encrypt(&mc, access_token);
sqlx::query!(
"INSERT INTO variable (workspace_id, path, value, is_secret, description, account, is_oauth)
VALUES ('test-workspace', $1, $2, true, 'test oauth token', $3, true)",
resource_path,
encrypted,
account_id,
)
.execute(db)
.await?;
// 3. Create resource
let resource_value = json!({ "token": format!("$var:{}", resource_path) });
sqlx::query!(
"INSERT INTO resource (workspace_id, path, value, resource_type, extra_perms, created_by)
VALUES ('test-workspace', $1, $2, $3, '{}'::jsonb, 'test-user')",
resource_path,
resource_value,
service_name.resource_type(),
)
.execute(db)
.await?;
// 4. Store workspace integration with resource_path
let oauth_data = oauth_data_override.unwrap_or_else(|| {
json!({
"client_id": "test-client-id",
"client_secret": "test-client-secret",
"base_url": "https://example.com",
"resource_path": resource_path,
})
});
let authed = test_authed();
let mut tx = db.begin().await?;
store_workspace_integration(
&mut *tx,
&authed,
"test-workspace",
service_name,
oauth_data,
Some(resource_path),
)
.await?;
tx.commit().await?;
Ok(account_id)
}
fn now_ms() -> i64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_millis() as i64
}
// ============================================================================
// 1. Resource Path Change
// ============================================================================
#[sqlx::test(migrations = "../migrations", fixtures("base"))]
async fn test_resource_path_change(db: Pool<Postgres>) -> anyhow::Result<()> {
let path_a = "u/test-user/native_gworkspace";
setup_oauth_integration(
&db,
ServiceName::Google,
path_a,
"token-a",
"refresh-a",
None,
)
.await?;
// Verify decrypt works at path A
let config: OAuthConfig =
decrypt_oauth_data(&db, "test-workspace", ServiceName::Google).await?;
assert_eq!(config.access_token, "token-a");
// Cleanup old path
let mut tx = db.begin().await?;
windmill_native_triggers::workspace_integrations::cleanup_oauth_resource(
&mut *tx,
"test-workspace",
ServiceName::Google,
)
.await;
tx.commit().await?;
// Recreate at path B
let path_b = "u/test-user/native_gworkspace_v2";
setup_oauth_integration(
&db,
ServiceName::Google,
path_b,
"token-b",
"refresh-b",
None,
)
.await?;
// Path A resources should be gone
let var_count: i64 = sqlx::query_scalar!(
"SELECT count(*) FROM variable WHERE workspace_id = 'test-workspace' AND path = $1",
path_a,
)
.fetch_one(&db)
.await?
.unwrap_or(0);
assert_eq!(var_count, 0, "variable at old path should be deleted");
let res_count: i64 = sqlx::query_scalar!(
"SELECT count(*) FROM resource WHERE workspace_id = 'test-workspace' AND path = $1",
path_a,
)
.fetch_one(&db)
.await?
.unwrap_or(0);
assert_eq!(res_count, 0, "resource at old path should be deleted");
// Path B should work
let config: OAuthConfig =
decrypt_oauth_data(&db, "test-workspace", ServiceName::Google).await?;
assert_eq!(config.access_token, "token-b");
assert_eq!(config.refresh_token.as_deref(), Some("refresh-b"));
Ok(())
}
// ============================================================================
// 2. Config Loading — workspace vs instance + token update
// ============================================================================
#[sqlx::test(migrations = "../migrations", fixtures("base"))]
async fn test_decrypt_workspace_level(db: Pool<Postgres>) -> anyhow::Result<()> {
let resource_path = "u/test-user/native_gworkspace";
setup_oauth_integration(
&db,
ServiceName::Google,
resource_path,
"ws-access-token",
"ws-refresh-token",
None,
)
.await?;
let config: OAuthConfig =
decrypt_oauth_data(&db, "test-workspace", ServiceName::Google).await?;
assert_eq!(config.access_token, "ws-access-token");
assert_eq!(config.refresh_token.as_deref(), Some("ws-refresh-token"));
assert_eq!(config.client_id, "test-client-id");
assert_eq!(config.client_secret, "test-client-secret");
assert_eq!(config.base_url, "https://example.com");
Ok(())
}
#[sqlx::test(migrations = "../migrations", fixtures("base"))]
async fn test_decrypt_instance_level(db: Pool<Postgres>) -> anyhow::Result<()> {
// Insert instance-level credentials into global_settings
sqlx::query!(
"INSERT INTO global_settings (name, value) VALUES ('oauths', $1)
ON CONFLICT (name) DO UPDATE SET value = $1",
json!({
"gworkspace": {
"id": "instance-client-id",
"secret": "instance-client-secret"
}
}),
)
.execute(&db)
.await?;
let resource_path = "u/test-user/native_gworkspace";
let oauth_data = json!({
"instance_shared": true,
"base_url": "https://accounts.google.com",
"resource_path": resource_path,
});
setup_oauth_integration(
&db,
ServiceName::Google,
resource_path,
"inst-access-token",
"inst-refresh-token",
Some(oauth_data),
)
.await?;
let config: OAuthConfig =
decrypt_oauth_data(&db, "test-workspace", ServiceName::Google).await?;
assert_eq!(config.client_id, "instance-client-id");
assert_eq!(config.client_secret, "instance-client-secret");
assert_eq!(config.access_token, "inst-access-token");
Ok(())
}
#[sqlx::test(migrations = "../migrations", fixtures("base"))]
async fn test_token_update_persists(db: Pool<Postgres>) -> anyhow::Result<()> {
let resource_path = "u/test-user/native_gworkspace";
let account_id = setup_oauth_integration(
&db,
ServiceName::Google,
resource_path,
"old-access-token",
"old-refresh-token",
None,
)
.await?;
// Verify old tokens
let config: OAuthConfig =
decrypt_oauth_data(&db, "test-workspace", ServiceName::Google).await?;
assert_eq!(config.access_token, "old-access-token");
// Simulate token refresh: update variable + account
let mc = build_crypt(&db, "test-workspace").await?;
let new_encrypted = encrypt(&mc, "new-access-token");
sqlx::query!(
"UPDATE variable SET value = $1 WHERE workspace_id = 'test-workspace' AND path = $2",
new_encrypted,
resource_path,
)
.execute(&db)
.await?;
sqlx::query!(
"UPDATE account SET refresh_token = $1 WHERE workspace_id = 'test-workspace' AND id = $2",
"new-refresh-token",
account_id,
)
.execute(&db)
.await?;
// Verify new tokens
let config: OAuthConfig =
decrypt_oauth_data(&db, "test-workspace", ServiceName::Google).await?;
assert_eq!(config.access_token, "new-access-token");
assert_eq!(config.refresh_token.as_deref(), Some("new-refresh-token"));
Ok(())
}
// ============================================================================
// 3. Channel Expiration Renewal — should_renew_channel
// ============================================================================
#[test]
fn test_should_renew_drive_channel_expired() {
let config = json!({
"triggerType": "drive",
"expiration": (now_ms() - 1000).to_string(),
});
assert!(should_renew_channel(&config));
}
#[test]
fn test_should_renew_drive_channel_within_window() {
// 30 minutes remaining — within the 1-hour Drive renewal window
let config = json!({
"triggerType": "drive",
"expiration": (now_ms() + 30 * 60 * 1000).to_string(),
});
assert!(should_renew_channel(&config));
}
#[test]
fn test_should_renew_drive_channel_not_yet() {
// 2 hours remaining — outside the 1-hour Drive renewal window
let config = json!({
"triggerType": "drive",
"expiration": (now_ms() + 2 * 60 * 60 * 1000).to_string(),
});
assert!(!should_renew_channel(&config));
}
#[test]
fn test_should_renew_calendar_channel_within_window() {
// 12 hours remaining — within the 1-day Calendar renewal window
let config = json!({
"triggerType": "calendar",
"expiration": (now_ms() + 12 * 60 * 60 * 1000).to_string(),
});
assert!(should_renew_channel(&config));
}
#[test]
fn test_should_renew_calendar_channel_not_yet() {
// 2 days remaining — outside the 1-day Calendar renewal window
let config = json!({
"triggerType": "calendar",
"expiration": (now_ms() + 2 * 24 * 60 * 60 * 1000).to_string(),
});
assert!(!should_renew_channel(&config));
}
#[test]
fn test_should_renew_channel_zero_expiration() {
let config = json!({
"triggerType": "drive",
"expiration": "0",
});
assert!(!should_renew_channel(&config));
}
#[test]
fn test_should_renew_channel_missing_fields() {
assert!(!should_renew_channel(&json!({})));
}
// ============================================================================
// 4. Delete Workspace Integration
// ============================================================================
#[sqlx::test(migrations = "../migrations", fixtures("base"))]
async fn test_delete_integration_full_cascade(db: Pool<Postgres>) -> anyhow::Result<()> {
let resource_path = "u/test-user/native_gworkspace";
let account_id = setup_oauth_integration(
&db,
ServiceName::Google,
resource_path,
"token",
"refresh",
None,
)
.await?;
// Add a native trigger linked to this integration
insert_test_script(&db, "f/test/handler").await?;
let trigger_config = NativeTriggerConfig {
script_path: "f/test/handler".to_string(),
is_flow: false,
webhook_token: "abcdefghij1234567890".to_string(),
};
store_native_trigger(
&db,
"test-workspace",
ServiceName::Google,
"ext-1",
&trigger_config,
json!({"triggerType": "drive"}),
)
.await?;
// Step 1: Delete triggers
let deleted =
delete_native_trigger(&db, "test-workspace", ServiceName::Google, "ext-1").await?;
assert!(deleted);
// Step 2: Cleanup OAuth resources
let mut tx = db.begin().await?;
windmill_native_triggers::workspace_integrations::cleanup_oauth_resource(
&mut *tx,
"test-workspace",
ServiceName::Google,
)
.await;
tx.commit().await?;
// Step 3: Delete workspace integration
let mut tx = db.begin().await?;
let deleted =
delete_workspace_integration(&mut *tx, "test-workspace", ServiceName::Google).await?;
tx.commit().await?;
assert!(deleted);
// Verify everything is gone
let var_count: i64 = sqlx::query_scalar!(
"SELECT count(*) FROM variable WHERE workspace_id = 'test-workspace' AND path = $1",
resource_path,
)
.fetch_one(&db)
.await?
.unwrap_or(0);
assert_eq!(var_count, 0);
let acc_count: i64 = sqlx::query_scalar!(
"SELECT count(*) FROM account WHERE workspace_id = 'test-workspace' AND id = $1",
account_id,
)
.fetch_one(&db)
.await?
.unwrap_or(0);
assert_eq!(acc_count, 0);
let res_count: i64 = sqlx::query_scalar!(
"SELECT count(*) FROM resource WHERE workspace_id = 'test-workspace' AND path = $1",
resource_path,
)
.fetch_one(&db)
.await?
.unwrap_or(0);
assert_eq!(res_count, 0);
assert!(
get_workspace_integration(&db, "test-workspace", ServiceName::Google)
.await
.is_err()
);
Ok(())
}
#[sqlx::test(migrations = "../migrations", fixtures("base"))]
async fn test_cleanup_preserves_triggers(db: Pool<Postgres>) -> anyhow::Result<()> {
let resource_path = "u/test-user/native_gworkspace";
setup_oauth_integration(
&db,
ServiceName::Google,
resource_path,
"token",
"refresh",
None,
)
.await?;
// Create a trigger
insert_test_script(&db, "f/test/handler").await?;
let trigger_config = NativeTriggerConfig {
script_path: "f/test/handler".to_string(),
is_flow: false,
webhook_token: "abcdefghij1234567890".to_string(),
};
store_native_trigger(
&db,
"test-workspace",
ServiceName::Google,
"ext-1",
&trigger_config,
json!({"triggerType": "drive"}),
)
.await?;
// Cleanup OAuth only — should NOT remove the trigger
let mut tx = db.begin().await?;
windmill_native_triggers::workspace_integrations::cleanup_oauth_resource(
&mut *tx,
"test-workspace",
ServiceName::Google,
)
.await;
tx.commit().await?;
// OAuth resources gone
let var_count: i64 = sqlx::query_scalar!(
"SELECT count(*) FROM variable WHERE workspace_id = 'test-workspace' AND path = $1",
resource_path,
)
.fetch_one(&db)
.await?
.unwrap_or(0);
assert_eq!(var_count, 0);
// Trigger still exists
let trigger_count: i64 = sqlx::query_scalar!(
"SELECT count(*) FROM native_trigger WHERE workspace_id = 'test-workspace' AND service_name = 'google'"
)
.fetch_one(&db)
.await?
.unwrap_or(0);
assert_eq!(trigger_count, 1, "trigger should survive OAuth cleanup");
Ok(())
}
// --- parse_stop_channel_params ---
#[test]
fn test_parse_stop_channel_params_drive() {
let config = json!({
"triggerType": "drive",
"googleResourceId": "res-123",
});
let (resource_id, url) = parse_stop_channel_params(&config);
assert_eq!(resource_id, "res-123");
assert!(
url.contains("googleapis.com/drive/v3/channels/stop"),
"url={}",
url
);
}
#[test]
fn test_parse_stop_channel_params_calendar() {
let config = json!({
"triggerType": "calendar",
"googleResourceId": "res-456",
});
let (resource_id, url) = parse_stop_channel_params(&config);
assert_eq!(resource_id, "res-456");
assert!(
url.contains("googleapis.com/calendar/v3/channels/stop"),
"url={}",
url
);
}
#[test]
fn test_parse_stop_channel_params_default() {
// Missing triggerType defaults to Drive
let config = json!({ "googleResourceId": "res-789" });
let (resource_id, url) = parse_stop_channel_params(&config);
assert_eq!(resource_id, "res-789");
assert!(url.contains("drive/v3/channels/stop"), "url={}", url);
}
#[test]
fn test_parse_stop_channel_params_missing_resource_id() {
let config = json!({ "triggerType": "drive" });
let (resource_id, _url) = parse_stop_channel_params(&config);
assert_eq!(resource_id, "");
}

View File

@@ -22,10 +22,12 @@ use ee_oss::validate_license_key;
use windmill_common::usernames::generate_instance_username_for_all_users;
use axum::{
extract::{Extension, Path, Query},
extract::{Extension, Path},
routing::{get, post},
Json, Router,
};
#[cfg(feature = "enterprise")]
use axum::extract::Query;
use serde_json::json;
use serde::{Deserialize, Serialize};
@@ -407,16 +409,9 @@ async fn get_instance_config(
Ok(Json(config))
}
#[derive(Deserialize)]
struct SetInstanceConfigQuery {
#[serde(default)]
skip_worker_configs: Option<bool>,
}
async fn set_instance_config(
Extension(db): Extension<DB>,
authed: ApiAuthed,
Query(query): Query<SetInstanceConfigQuery>,
Json(desired): Json<InstanceConfig>,
) -> error::Result<()> {
require_super_admin(&db, &authed.email).await?;
@@ -425,20 +420,22 @@ async fn set_instance_config(
.await
.map_err(|e| error::Error::internal_err(e.to_string()))?;
let current_map = current.global_settings.to_settings_map();
let desired_map = desired.global_settings.to_settings_map();
let settings_diff =
instance_config::diff_global_settings(&current_map, &desired_map, ApplyMode::Merge);
if !desired_map.is_empty() {
let current_map = current.global_settings.to_settings_map();
let settings_diff =
instance_config::diff_global_settings(&current_map, &desired_map, ApplyMode::Merge);
for (key, value) in &settings_diff.upserts {
run_setting_pre_write_hook(&db, key, value).await?;
for (key, value) in &settings_diff.upserts {
run_setting_pre_write_hook(&db, key, value).await?;
}
instance_config::apply_settings_diff(&db, &settings_diff)
.await
.map_err(|e| error::Error::internal_err(e.to_string()))?;
}
instance_config::apply_settings_diff(&db, &settings_diff)
.await
.map_err(|e| error::Error::internal_err(e.to_string()))?;
if !query.skip_worker_configs.unwrap_or(false) {
if !desired.worker_configs.is_empty() {
let current_wc: std::collections::BTreeMap<String, serde_json::Value> = current
.worker_configs
.iter()

View File

@@ -75,6 +75,7 @@ pub fn workspaced_service() -> Router {
.route("/whoami", get(whoami))
.route("/leave", post(leave_workspace))
.route("/username_to_email/:username", get(username_to_email))
.route("/list_instance_emails", get(list_instance_emails))
}
pub fn global_service() -> Router {
@@ -420,6 +421,22 @@ async fn list_users_as_super_admin(
Ok(Json(rows))
}
async fn list_instance_emails(
authed: ApiAuthed,
Extension(db): Extension<DB>,
) -> JsonResult<Vec<String>> {
if *CLOUD_HOSTED {
return Err(Error::BadRequest(
"This endpoint is not available on cloud hosted instances".to_string(),
));
}
require_admin(authed.is_admin, &authed.username)?;
let rows = sqlx::query_scalar!("SELECT email FROM password ORDER BY email LIMIT 1000")
.fetch_all(&db)
.await?;
Ok(Json(rows))
}
#[derive(Serialize, Deserialize)]
struct Progress {
progress: u64,

View File

@@ -2499,6 +2499,7 @@ struct UsedTriggers {
pub gcp_used: bool,
pub email_used: bool,
pub nextcloud_used: bool,
pub google_used: bool,
}
async fn get_used_triggers(
@@ -2520,7 +2521,8 @@ async fn get_used_triggers(
EXISTS(SELECT 1 FROM sqs_trigger WHERE workspace_id = $1) AS "sqs_used!",
EXISTS(SELECT 1 FROM gcp_trigger WHERE workspace_id = $1) AS "gcp_used!",
EXISTS(SELECT 1 FROM email_trigger WHERE workspace_id = $1) AS "email_used!",
EXISTS(SELECT 1 FROM native_trigger WHERE workspace_id = $1 AND service_name = 'nextcloud'::native_trigger_service) AS "nextcloud_used!"
EXISTS(SELECT 1 FROM native_trigger WHERE workspace_id = $1 AND service_name = 'nextcloud'::native_trigger_service) AS "nextcloud_used!",
EXISTS(SELECT 1 FROM native_trigger WHERE workspace_id = $1 AND service_name = 'google'::native_trigger_service) AS "google_used!"
"#,
w_id
)

View File

@@ -1,7 +1,7 @@
openapi: "3.0.3"
info:
version: 1.634.6
version: 1.636.0
title: Windmill API
contact:
@@ -1497,16 +1497,10 @@ paths:
schema:
$ref: "#/components/schemas/InstanceConfig"
put:
summary: update instance config (bulk upsert, no deletes)
summary: update instance config (bulk upsert, no deletes). Empty or missing global_settings/worker_configs are skipped.
operationId: setInstanceConfig
tags:
- setting
parameters:
- name: skip_worker_configs
in: query
description: if true, ignore worker_configs in the request body
schema:
type: boolean
requestBody:
description: full instance configuration to apply
required: true
@@ -2403,6 +2397,24 @@ paths:
items:
$ref: "#/components/schemas/GlobalUserInfo"
/w/{workspace}/users/list_instance_emails:
get:
summary: list all instance user emails (only on non-cloud instances, requires workspace admin)
operationId: listInstanceEmails
tags:
- user
parameters:
- $ref: "#/components/parameters/WorkspaceId"
responses:
"200":
description: list of instance user emails
content:
application/json:
schema:
type: array
items:
type: string
/w/{workspace}/workspaces/list_pending_invites:
get:
summary: list pending invites for a workspace
@@ -3664,6 +3676,8 @@ paths:
type: boolean
nextcloud_used:
type: boolean
google_used:
type: boolean
required:
- http_routes_used
- websocket_used
@@ -3675,6 +3689,7 @@ paths:
- sqs_used
- email_used
- nextcloud_used
- google_used
/w/{workspace}/users/list:
get:
summary: list users
@@ -12326,6 +12341,55 @@ paths:
schema:
type: string
/w/{workspace}/native_triggers/integrations/{service_name}/instance_sharing_available:
get:
summary: check if instance-level credential sharing is available for a service
operationId: checkInstanceSharingAvailable
tags:
- workspace_integration
parameters:
- $ref: "#/components/parameters/WorkspaceId"
- name: service_name
in: path
required: true
schema:
$ref: "#/components/schemas/NativeServiceName"
responses:
"200":
description: whether instance sharing is available
content:
application/json:
schema:
type: boolean
/w/{workspace}/native_triggers/integrations/{service_name}/generate_instance_connect_url:
post:
summary: generate connect url using instance-level credentials
operationId: generateInstanceConnectUrl
tags:
- workspace_integration
parameters:
- $ref: "#/components/parameters/WorkspaceId"
- name: service_name
in: path
required: true
schema:
$ref: "#/components/schemas/NativeServiceName"
requestBody:
description: redirect_uri
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/RedirectUri"
responses:
"200":
description: authorization URL using instance credentials
content:
application/json:
schema:
type: string
/w/{workspace}/native_triggers/integrations/{service_name}/delete:
delete:
summary: delete native trigger service
@@ -12347,7 +12411,7 @@ paths:
schema:
type: string
/w/{workspace}/native_triggers/integrations/{service_name}/callback/{code}/{state}:
/w/{workspace}/native_triggers/integrations/{service_name}/callback:
post:
summary: native trigger service oauth callback
operationId: nativeTriggerServiceCallback
@@ -12360,23 +12424,26 @@ paths:
required: true
schema:
$ref: "#/components/schemas/NativeServiceName"
- name: code
in: path
required: true
schema:
type: string
- name: state
in: path
required: true
schema:
type: string
requestBody:
description: redirect_uri
description: OAuth callback data
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/RedirectUri"
type: object
properties:
code:
type: string
state:
type: string
redirect_uri:
type: string
resource_path:
type: string
required:
- code
- state
- redirect_uri
responses:
"200":
description: native trigger service oauth completed
@@ -12616,6 +12683,91 @@ paths:
items:
$ref: "#/components/schemas/NextCloudEventType"
/w/{workspace}/native_triggers/google/calendars:
get:
summary: list Google Calendars for the authenticated user
operationId: listGoogleCalendars
tags:
- native_trigger
parameters:
- name: workspace
in: path
required: true
schema:
type: string
responses:
"200":
description: list of Google Calendars
content:
application/json:
schema:
type: array
items:
$ref: "#/components/schemas/GoogleCalendarEntry"
/w/{workspace}/native_triggers/google/drive/files:
get:
summary: list or search Google Drive files
operationId: listGoogleDriveFiles
tags:
- native_trigger
parameters:
- name: workspace
in: path
required: true
schema:
type: string
- name: q
in: query
description: search query to filter files by name
schema:
type: string
- name: parent_id
in: query
description: folder ID to list children of
schema:
type: string
- name: page_token
in: query
description: token for next page of results
schema:
type: string
- name: shared_with_me
in: query
description: if true, list files shared with the user
schema:
type: boolean
default: false
responses:
"200":
description: list of Google Drive files
content:
application/json:
schema:
$ref: "#/components/schemas/GoogleDriveFilesResponse"
/w/{workspace}/native_triggers/google/drive/shared_drives:
get:
summary: list shared drives accessible to the user
operationId: listGoogleSharedDrives
tags:
- native_trigger
parameters:
- name: workspace
in: path
required: true
schema:
type: string
responses:
"200":
description: list of shared drives
content:
application/json:
schema:
type: array
items:
$ref: "#/components/schemas/SharedDriveEntry"
/native_triggers/{service_name}/w/{workspace_id}/webhook/{internal_id}:
post:
summary: receive webhook from external native trigger service
@@ -20056,6 +20208,7 @@ components:
- mqtt
- sqs
- gcp
- google
TriggerMode:
description: job trigger mode
@@ -20519,6 +20672,8 @@ components:
type: number
nextcloud_count:
type: number
google_count:
type: number
WebsocketTrigger:
allOf:
@@ -23519,6 +23674,7 @@ components:
type: string
enum:
- nextcloud
- google
NativeTrigger:
type: object
@@ -23601,6 +23757,10 @@ components:
oauth_data:
nullable: true
$ref: "#/components/schemas/WorkspaceOAuthConfig"
resource_path:
type: string
nullable: true
description: Path to the resource storing the OAuth token
required:
- service_name
@@ -23722,3 +23882,57 @@ components:
- id
- name
- path
GoogleCalendarEntry:
type: object
properties:
id:
type: string
summary:
type: string
primary:
type: boolean
default: false
required:
- id
- summary
GoogleDriveFile:
type: object
properties:
id:
type: string
name:
type: string
mime_type:
type: string
is_folder:
type: boolean
default: false
required:
- id
- name
- mime_type
GoogleDriveFilesResponse:
type: object
properties:
files:
type: array
items:
$ref: "#/components/schemas/GoogleDriveFile"
next_page_token:
type: string
required:
- files
SharedDriveEntry:
type: object
properties:
id:
type: string
name:
type: string
required:
- id
- name

View File

@@ -3413,29 +3413,43 @@ pub async fn get_args_and_trigger_metadata(
// Build trigger metadata if this is a native trigger request
#[cfg(feature = "native_trigger")]
let trigger_metadata = if let Some(service_name_str) = &run_query.service_name {
use crate::native_triggers::ServiceName;
let (trigger_metadata, native_args) = if let Some(service_name_str) = &run_query.service_name {
use crate::native_triggers::{prepare_native_trigger_args, ServiceName};
let service_name = ServiceName::try_from(service_name_str.to_owned())?;
Some(TriggerMetadata::new(
let metadata = Some(TriggerMetadata::new(
run_query.trigger_external_id.clone(),
service_name.as_job_trigger_kind(),
))
));
let body = match &args.body {
crate::args::RawBody::Json(s) => s.clone(),
crate::args::RawBody::Text(s) => s.clone(),
_ => String::new(),
};
let native =
prepare_native_trigger_args(service_name, db, w_id, &args.metadata.headers, body)
.await?;
(metadata, native)
} else {
None
(None, None)
};
#[cfg(not(feature = "native_trigger"))]
let trigger_metadata: Option<TriggerMetadata> = None;
#[cfg(not(feature = "native_trigger"))]
let native_args: Option<windmill_queue::PushArgsOwned> = None;
let args = args
.to_args_from_runnable(
let args = if let Some(prepared) = native_args {
prepared
} else {
args.to_args_from_runnable(
&authed,
&db,
&w_id,
runnable_id,
run_query.skip_preprocessor,
)
.await?;
.await?
};
Ok((args, trigger_metadata))
}

View File

@@ -136,6 +136,7 @@ pub struct TriggersCount {
sqs_count: i64,
gcp_count: i64,
nextcloud_count: i64,
google_count: i64,
}
pub async fn get_triggers_count_internal(
@@ -305,6 +306,16 @@ pub async fn get_triggers_count_internal(
.await?
.unwrap_or(0);
let google_count = sqlx::query_scalar!(
"SELECT COUNT(*) FROM native_trigger WHERE workspace_id = $1 AND script_path = $2 AND is_flow = $3 AND service_name = 'google'",
w_id,
path,
is_flow,
)
.fetch_one(db)
.await?
.unwrap_or(0);
Ok(axum::Json(TriggersCount {
primary_schedule: primary_schedule
.map(|s| windmill_trigger::handler::TriggerPrimarySchedule { schedule: s }),
@@ -321,5 +332,6 @@ pub async fn get_triggers_count_internal(
gcp_count,
sqs_count,
nextcloud_count,
google_count,
}))
}

View File

@@ -335,6 +335,15 @@ async fn update_username_in_workpsace<'c>(
).execute(&mut **tx)
.await?;
sqlx::query!(
r#"UPDATE workspace_integrations SET resource_path = REGEXP_REPLACE(resource_path, 'u/' || $2 || '/(.*)', 'u/' || $1 || '/\1') WHERE resource_path LIKE ('u/' || $2 || '/%') AND workspace_id = $3"#,
new_username,
old_username,
w_id
)
.execute(&mut **tx)
.await?;
sqlx::query!(
"UPDATE variable SET extra_perms = extra_perms - ('u/' || $2) || jsonb_build_object(('u/' || $1), extra_perms->('u/' || $2)) WHERE extra_perms ? ('u/' || $2) AND workspace_id = $3",
new_username,

View File

@@ -145,6 +145,68 @@ pub async fn load_value_from_global_settings(
Ok(r)
}
/// Read OAuth client_id and client_secret from instance-level global settings.
/// `oauth_key` is the key under `oauths` (e.g., "gworkspace", "nextcloud").
pub async fn get_instance_oauth_credentials(
db: &Pool<Postgres>,
oauth_key: &str,
) -> error::Result<(String, String)> {
let oauths_value = load_value_from_global_settings(db, OAUTH_SETTING)
.await?
.ok_or_else(|| {
error::Error::InternalErr("Instance OAuth settings not found".to_string())
})?;
let entry = oauths_value.get(oauth_key).ok_or_else(|| {
error::Error::InternalErr(format!("No {} entry in instance OAuth settings", oauth_key))
})?;
let id = entry
.get("id")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
let secret = entry
.get("secret")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
if id.is_empty() || secret.is_empty() {
return Err(error::Error::InternalErr(format!(
"Instance OAuth credentials for {} are incomplete",
oauth_key
)));
}
Ok((id, secret))
}
/// Map service client name to the OAuth settings key in global_settings.
/// e.g. "google" -> "gworkspace", "nextcloud" -> "nextcloud"
pub fn workspace_integration_oauth_key(client_name: &str) -> &str {
match client_name {
"google" => "gworkspace",
other => other,
}
}
/// Resolve the token endpoint URL for a workspace integration service.
pub fn workspace_integration_token_endpoint(client_name: &str, base_url: &str) -> String {
match client_name {
"google" => "https://oauth2.googleapis.com/token".to_string(),
_ => format!("{}/apps/oauth2/api/v1/token", base_url),
}
}
/// Resolve the auth endpoint URL for a workspace integration service.
pub fn workspace_integration_auth_endpoint(client_name: &str, base_url: &str) -> String {
match client_name {
"google" => "https://accounts.google.com/o/oauth2/v2/auth".to_string(),
_ => format!("{}/apps/oauth2/authorize", base_url),
}
}
pub async fn set_value_in_global_settings(
db: &Pool<Postgres>,
setting_name: &str,

View File

@@ -3,6 +3,8 @@ use std::fmt;
use serde::{Deserialize, Serialize};
use crate::global_settings::LICENSE_KEY_SETTING;
// ---------------------------------------------------------------------------
// Kubernetes Secret reference support
// ---------------------------------------------------------------------------
@@ -164,6 +166,28 @@ pub struct InstanceConfig {
// Global settings
// ---------------------------------------------------------------------------
/// Generate a schema for opaque JSON objects (used for EE-private settings).
/// Produces `{"type": "object", "nullable": true}` so the CRD passes K8s
/// structural schema validation while still accepting any JSON object.
#[cfg(feature = "instance_config_schema")]
fn opaque_json_schema(_: &mut schemars::gen::SchemaGenerator) -> schemars::schema::Schema {
schemars::schema::SchemaObject {
instance_type: Some(schemars::schema::InstanceType::Object.into()),
metadata: Some(Box::default()),
extensions: {
let mut m = schemars::Map::new();
m.insert("nullable".to_string(), serde_json::Value::Bool(true));
m.insert(
"x-kubernetes-preserve-unknown-fields".to_string(),
serde_json::Value::Bool(true),
);
m
},
..Default::default()
}
.into()
}
/// Typed global settings with schema validation.
/// Known settings have explicit fields; unknown settings pass through via `extra`.
#[derive(Deserialize, Serialize, Clone, Debug, Default)]
@@ -265,6 +289,10 @@ pub struct GlobalSettings {
#[serde(skip_serializing_if = "Option::is_none")]
pub otel_tracing_proxy: Option<OtelTracingProxySettings>,
#[serde(skip_serializing_if = "Option::is_none")]
#[cfg_attr(
feature = "instance_config_schema",
schemars(schema_with = "opaque_json_schema")
)]
pub object_store_cache_config: Option<serde_json::Value>,
#[serde(skip_serializing_if = "Option::is_none")]
pub critical_error_channels: Option<Vec<CriticalErrorChannel>>,
@@ -277,10 +305,22 @@ pub struct GlobalSettings {
// Opaque settings (EE-private structs or no clear schema)
#[serde(skip_serializing_if = "Option::is_none")]
#[cfg_attr(
feature = "instance_config_schema",
schemars(schema_with = "opaque_json_schema")
)]
pub secret_backend: Option<serde_json::Value>,
#[serde(skip_serializing_if = "Option::is_none")]
#[cfg_attr(
feature = "instance_config_schema",
schemars(schema_with = "opaque_json_schema")
)]
pub slack: Option<serde_json::Value>,
#[serde(skip_serializing_if = "Option::is_none")]
#[cfg_attr(
feature = "instance_config_schema",
schemars(schema_with = "opaque_json_schema")
)]
pub teams: Option<serde_json::Value>,
/// Catch-all for settings not yet covered by typed fields.
@@ -368,6 +408,8 @@ pub struct OAuthClient {
pub connect_config: Option<OAuthConfig>,
#[serde(skip_serializing_if = "Option::is_none")]
pub login_config: Option<OAuthConfig>,
#[serde(skip_serializing_if = "Option::is_none")]
pub share_with_workspaces: Option<bool>,
}
/// OAuth provider endpoint configuration.
@@ -717,6 +759,33 @@ pub const PROTECTED_SETTINGS: &[&str] = &[
/// Internal settings that are never exposed via the API or included in config exports.
pub const HIDDEN_SETTINGS: &[&str] = &["uid", "rsa_keys", "jwt_secret", "min_keep_alive_version"];
/// Extract the expiry timestamp from a license key JSON value.
///
/// License keys have the format `<client_id>.<expiry>.<signature>`.
/// Returns `None` if the value is not a string or doesn't match the format.
fn license_key_expiry(value: &serde_json::Value) -> Option<u64> {
let s = value.as_str()?;
let parts: Vec<&str> = s.split('.').collect();
if parts.len() != 3 {
return None;
}
parts[1].parse::<u64>().ok()
}
/// Returns true if two license key values share the same client ID and signature
/// (i.e. they differ only in the expiry field).
fn license_keys_same_except_expiry(a: &serde_json::Value, b: &serde_json::Value) -> bool {
let (Some(a_str), Some(b_str)) = (a.as_str(), b.as_str()) else {
return false;
};
let a_parts: Vec<&str> = a_str.split('.').collect();
let b_parts: Vec<&str> = b_str.split('.').collect();
if a_parts.len() != 3 || b_parts.len() != 3 {
return false;
}
a_parts[0] == b_parts[0] && a_parts[2] == b_parts[2]
}
/// Compute the diff between current and desired global settings.
pub fn diff_global_settings(
current: &BTreeMap<String, serde_json::Value>,
@@ -727,6 +796,23 @@ pub fn diff_global_settings(
for (key, value) in desired {
match current.get(key) {
Some(existing) if existing == value => {} // no change
Some(existing) if key == LICENSE_KEY_SETTING => {
if license_keys_same_except_expiry(existing, value) {
let current_expiry = license_key_expiry(existing).unwrap_or(0);
let desired_expiry = license_key_expiry(value).unwrap_or(0);
if desired_expiry > current_expiry {
upserts.insert(key.clone(), value.clone());
} else {
tracing::info!(
"Skipping license_key update: desired expiry ({}) is not posterior to current expiry ({})",
desired_expiry,
current_expiry
);
}
} else {
upserts.insert(key.clone(), value.clone());
}
}
_ => {
upserts.insert(key.clone(), value.clone());
}
@@ -1866,6 +1952,7 @@ mod tests {
allowed_domains: None,
connect_config: None,
login_config: None,
share_with_workspaces: None,
},
);
m
@@ -1910,4 +1997,117 @@ mod tests {
Some("plain-token")
);
}
// -----------------------------------------------------------------------
// License key expiry diff tests
// -----------------------------------------------------------------------
#[test]
fn diff_license_key_skips_older_expiry() {
let mut current = BTreeMap::new();
current.insert(
"license_key".to_string(),
serde_json::json!("client1.2000000000.sig123"),
);
let mut desired = BTreeMap::new();
desired.insert(
"license_key".to_string(),
serde_json::json!("client1.1000000000.sig123"),
);
let diff = diff_global_settings(&current, &desired, ApplyMode::Merge);
assert!(
diff.upserts.is_empty(),
"Should not update license_key when desired expiry is older"
);
}
#[test]
fn diff_license_key_skips_equal_expiry() {
let mut current = BTreeMap::new();
current.insert(
"license_key".to_string(),
serde_json::json!("client1.2000000000.sig_a"),
);
let mut desired = BTreeMap::new();
desired.insert(
"license_key".to_string(),
serde_json::json!("client1.2000000000.sig_b"),
);
let diff = diff_global_settings(&current, &desired, ApplyMode::Merge);
assert_eq!(
diff.upserts.len(),
1,
"Different signature means different key, should update"
);
}
#[test]
fn diff_license_key_updates_newer_expiry() {
let mut current = BTreeMap::new();
current.insert(
"license_key".to_string(),
serde_json::json!("client1.1000000000.sig123"),
);
let mut desired = BTreeMap::new();
desired.insert(
"license_key".to_string(),
serde_json::json!("client1.2000000000.sig123"),
);
let diff = diff_global_settings(&current, &desired, ApplyMode::Merge);
assert_eq!(diff.upserts.len(), 1);
assert_eq!(
diff.upserts["license_key"],
serde_json::json!("client1.2000000000.sig123")
);
}
#[test]
fn diff_license_key_updates_different_client() {
let mut current = BTreeMap::new();
current.insert(
"license_key".to_string(),
serde_json::json!("client1.2000000000.sig123"),
);
let mut desired = BTreeMap::new();
desired.insert(
"license_key".to_string(),
serde_json::json!("client2.1000000000.sig456"),
);
let diff = diff_global_settings(&current, &desired, ApplyMode::Merge);
assert_eq!(
diff.upserts.len(),
1,
"Different client ID means different key, should always update"
);
}
#[test]
fn diff_license_key_non_string_always_updates() {
let mut current = BTreeMap::new();
current.insert(
"license_key".to_string(),
serde_json::json!({"envRef": "LIC_KEY"}),
);
let mut desired = BTreeMap::new();
desired.insert(
"license_key".to_string(),
serde_json::json!({"envRef": "NEW_LIC_KEY"}),
);
let diff = diff_global_settings(&current, &desired, ApplyMode::Merge);
assert_eq!(
diff.upserts.len(),
1,
"Non-string license keys should always be updated when different"
);
}
}

View File

@@ -0,0 +1,531 @@
use async_trait::async_trait;
use reqwest::Method;
use serde_json::value::RawValue;
use sqlx::PgConnection;
use std::collections::HashMap;
use windmill_common::{
error::{Error, Result},
worker::to_raw_value,
BASE_URL, DB,
};
use windmill_queue::PushArgsOwned;
use crate::{
generate_webhook_service_url, get_token_by_prefix,
sync::{SyncAction, SyncError, TriggerSyncInfo},
update_native_trigger_error, update_native_trigger_service_config, External, NativeTrigger,
NativeTriggerData, ServiceName,
};
use super::{
endpoints, routes, CreateWatchResponse, Google, GoogleOAuthData, GoogleServiceConfig,
GoogleTriggerType, StopChannelRequest, WatchRequest,
};
#[async_trait]
impl External for Google {
type ServiceConfig = GoogleServiceConfig;
// Google has no "get channel" API, so TriggerData is never constructed.
// The trait default for get() returns Ok(None).
type TriggerData = ();
type OAuthData = GoogleOAuthData;
type CreateResponse = CreateWatchResponse;
const SERVICE_NAME: ServiceName = ServiceName::Google;
const DISPLAY_NAME: &'static str = "Google";
const SUPPORT_WEBHOOK: bool = true;
const TOKEN_ENDPOINT: &'static str = "https://oauth2.googleapis.com/token";
const REFRESH_ENDPOINT: &'static str = "https://oauth2.googleapis.com/token";
const AUTH_ENDPOINT: &'static str = "https://accounts.google.com/o/oauth2/v2/auth";
async fn create(
&self,
w_id: &str,
_oauth_data: &Self::OAuthData,
webhook_token: &str,
data: &NativeTriggerData<Self::ServiceConfig>,
db: &DB,
_tx: &mut PgConnection,
) -> Result<Self::CreateResponse> {
let channel_id = uuid::Uuid::new_v4().to_string();
self.create_watch_channel(w_id, &channel_id, webhook_token, data, db)
.await
}
async fn update(
&self,
w_id: &str,
oauth_data: &Self::OAuthData,
external_id: &str,
webhook_token: &str,
data: &NativeTriggerData<Self::ServiceConfig>,
db: &DB,
tx: &mut PgConnection,
) -> Result<serde_json::Value> {
// Google doesn't support updating watch channels — delete old, create new.
let _ = self.delete(w_id, oauth_data, external_id, db, tx).await;
// Reuse the same channel ID so external_id stays permanent
let resp = self
.create_watch_channel(w_id, external_id, webhook_token, data, db)
.await?;
self.service_config_from_create_response(data, &resp)
.ok_or_else(|| {
Error::InternalErr(
"Failed to build service_config from create response".to_string(),
)
})
}
async fn delete(
&self,
w_id: &str,
_oauth_data: &Self::OAuthData,
external_id: &str,
db: &DB,
tx: &mut PgConnection,
) -> Result<()> {
// Get the stored trigger to find the google_resource_id and trigger_type
let trigger = sqlx::query_scalar!(
r#"
SELECT service_config
FROM native_trigger
WHERE external_id = $1 AND service_name = $2 AND workspace_id = $3
"#,
external_id,
ServiceName::Google as ServiceName,
w_id
)
.fetch_optional(&mut *tx)
.await?;
let config = trigger.flatten();
if config.is_none() {
return Ok(());
}
let config = config.unwrap();
let (google_resource_id, url) = super::parse_stop_channel_params(&config);
if !google_resource_id.is_empty() {
let stop_request =
StopChannelRequest { id: external_id.to_string(), resource_id: google_resource_id };
// Stop the channel (ignore errors - channel may have already expired)
let result: std::result::Result<serde_json::Value, _> = self
.http_client_request(&url, Method::POST, w_id, db, None, Some(&stop_request))
.await;
if let Err(e) = result {
tracing::warn!("Failed to stop Google channel {}: {}", external_id, e);
}
}
Ok(())
}
async fn maintain_triggers(
&self,
db: &DB,
workspace_id: &str,
triggers: &[NativeTrigger],
_oauth_data: &Self::OAuthData,
synced: &mut Vec<TriggerSyncInfo>,
errors: &mut Vec<SyncError>,
) {
renew_expiring_channels(self, db, workspace_id, triggers, synced, errors).await;
}
async fn prepare_webhook(
&self,
_db: &DB,
_w_id: &str,
headers: HashMap<String, String>,
_body: String,
_script_path: &str,
_is_flow: bool,
) -> Result<PushArgsOwned> {
// Google sends notification info in headers (same format for Drive and Calendar)
let payload = serde_json::json!({
"channel_id": headers.get("x-goog-channel-id").cloned().unwrap_or_default(),
"resource_id": headers.get("x-goog-resource-id").cloned().unwrap_or_default(),
"resource_state": headers.get("x-goog-resource-state").cloned().unwrap_or_default(),
"resource_uri": headers.get("x-goog-resource-uri").cloned().unwrap_or_default(),
"message_number": headers.get("x-goog-message-number").cloned().unwrap_or_default(),
"channel_expiration": headers.get("x-goog-channel-expiration").cloned().unwrap_or_default(),
"changed": headers.get("x-goog-changed").cloned().unwrap_or_default(),
"channel_token": headers.get("x-goog-channel-token").cloned().unwrap_or_default(),
});
let mut args: HashMap<String, Box<RawValue>> = HashMap::new();
args.insert("payload".to_string(), to_raw_value(&payload));
Ok(PushArgsOwned { extra: None, args })
}
fn external_id_and_metadata_from_response(
&self,
resp: &Self::CreateResponse,
) -> (String, Option<serde_json::Value>) {
let metadata = serde_json::json!({
"googleResourceId": resp.resource_id,
"expiration": resp.expiration,
});
(resp.id.clone(), Some(metadata))
}
fn service_config_from_create_response(
&self,
data: &NativeTriggerData<Self::ServiceConfig>,
resp: &Self::CreateResponse,
) -> Option<serde_json::Value> {
let mut config = data.service_config.clone();
config.google_resource_id = Some(resp.resource_id.clone());
config.expiration = Some(resp.expiration.clone());
serde_json::to_value(&config).ok()
}
fn additional_routes(&self) -> axum::Router {
routes::google_routes(self.clone())
}
}
// Helper methods for creating trigger type-specific watches
impl Google {
/// Build a webhook URL and watch request, then register the channel with Google.
/// Used by both `create()` (new UUID) and `update()` (reuse existing external_id).
async fn create_watch_channel(
&self,
w_id: &str,
channel_id: &str,
webhook_token: &str,
data: &NativeTriggerData<GoogleServiceConfig>,
db: &DB,
) -> Result<CreateWatchResponse> {
let base_url = &*BASE_URL.read().await;
let webhook_url = generate_webhook_service_url(
base_url,
w_id,
&data.script_path,
data.is_flow,
Some(channel_id),
ServiceName::Google,
webhook_token,
);
tracing::info!(
"Creating Google {} watch channel '{}' with webhook URL: {}",
data.service_config.trigger_type,
channel_id,
webhook_url
);
let expiration_ms = chrono::Utc::now().timestamp_millis()
+ (data.service_config.max_expiration_hours() as i64 * 3600 * 1000);
let mut watch_request = WatchRequest::new(channel_id.to_string(), webhook_url);
watch_request.expiration = Some(expiration_ms);
match data.service_config.trigger_type {
GoogleTriggerType::Drive => {
self.create_drive_watch(w_id, &data.service_config, &watch_request, db)
.await
}
GoogleTriggerType::Calendar => {
self.create_calendar_watch(w_id, &data.service_config, &watch_request, db)
.await
}
}
}
async fn create_drive_watch(
&self,
w_id: &str,
config: &GoogleServiceConfig,
watch_request: &WatchRequest,
db: &DB,
) -> Result<CreateWatchResponse> {
match config.resource_id.as_deref().filter(|s| !s.is_empty()) {
Some(resource_id) => {
// Specific file: use files.watch
let url = format!("{}/files/{}/watch", endpoints::DRIVE_API_BASE, resource_id);
self.http_client_request(&url, Method::POST, w_id, db, None, Some(watch_request))
.await
}
None => {
// All changes: use changes.watch
let token_url = format!("{}/changes/startPageToken", endpoints::DRIVE_API_BASE);
let token_response: serde_json::Value = self
.http_client_request::<_, ()>(&token_url, Method::GET, w_id, db, None, None)
.await?;
let start_page_token = token_response
.get("startPageToken")
.and_then(|v| v.as_str())
.ok_or_else(|| {
Error::InternalErr("Failed to get startPageToken".to_string())
})?;
let watch_body = serde_json::to_value(watch_request)?;
let watch_url = format!(
"{}/changes/watch?pageToken={}",
endpoints::DRIVE_API_BASE,
start_page_token
);
self.http_client_request(
&watch_url,
Method::POST,
w_id,
db,
None,
Some(&watch_body),
)
.await
}
}
}
async fn create_calendar_watch(
&self,
w_id: &str,
config: &GoogleServiceConfig,
watch_request: &WatchRequest,
db: &DB,
) -> Result<CreateWatchResponse> {
let calendar_id = config.calendar_id.as_ref().ok_or_else(|| {
Error::BadRequest("calendar_id is required for Calendar triggers".into())
})?;
let url = format!(
"{}/calendars/{}/events/watch",
endpoints::CALENDAR_API_BASE,
urlencoding::encode(calendar_id)
);
self.http_client_request(&url, Method::POST, w_id, db, None, Some(watch_request))
.await
}
/// Renew an expiring Google watch channel.
/// Stops the old channel and creates a new one with the same channel ID.
/// Returns the updated service_config with new expiration.
pub async fn renew_channel(
&self,
w_id: &str,
trigger: &NativeTrigger,
db: &DB,
) -> Result<serde_json::Value> {
let config: GoogleServiceConfig = trigger
.service_config
.as_ref()
.map(|v| serde_json::from_value(v.clone()))
.transpose()?
.ok_or_else(|| Error::InternalErr("Missing service config".to_string()))?;
let webhook_token = get_token_by_prefix(db, &trigger.webhook_token_prefix)
.await?
.ok_or_else(|| Error::InternalErr("Webhook token not found".to_string()))?;
let base_url = &*BASE_URL.read().await;
// Reuse the same channel ID so external_id stays permanent
let channel_id = trigger.external_id.clone();
let webhook_url = generate_webhook_service_url(
base_url,
w_id,
&trigger.script_path,
trigger.is_flow,
Some(&channel_id),
ServiceName::Google,
&webhook_token,
);
tracing::info!(
"Renewing Google {} watch channel '{}' with webhook URL: {}",
config.trigger_type,
channel_id,
webhook_url
);
let expiration_ms = chrono::Utc::now().timestamp_millis()
+ (config.max_expiration_hours() as i64 * 3600 * 1000);
let mut watch_request = WatchRequest::new(channel_id.clone(), webhook_url);
watch_request.expiration = Some(expiration_ms);
// Best-effort stop old channel before creating a new one
let old_google_resource_id = trigger
.service_config
.as_ref()
.and_then(|c| c.get("googleResourceId"))
.and_then(|r| r.as_str())
.unwrap_or_default();
if !old_google_resource_id.is_empty() {
let stop_request = StopChannelRequest {
id: channel_id.clone(),
resource_id: old_google_resource_id.to_string(),
};
let url = match config.trigger_type {
GoogleTriggerType::Calendar => {
format!("{}/channels/stop", endpoints::CALENDAR_API_BASE)
}
GoogleTriggerType::Drive => {
format!("{}/channels/stop", endpoints::DRIVE_API_BASE)
}
};
let result: std::result::Result<serde_json::Value, _> = self
.http_client_request(&url, Method::POST, w_id, db, None, Some(&stop_request))
.await;
if let Err(e) = result {
tracing::warn!(
"Failed to stop old Google channel {} during renewal: {}",
channel_id,
e
);
}
}
// Create new watch channel with the same channel ID
let resp = match config.trigger_type {
GoogleTriggerType::Drive => {
self.create_drive_watch(w_id, &config, &watch_request, db)
.await?
}
GoogleTriggerType::Calendar => {
self.create_calendar_watch(w_id, &config, &watch_request, db)
.await?
}
};
// Build the updated service_config with new expiration
let mut new_config = config;
new_config.google_resource_id = Some(resp.resource_id);
new_config.expiration = Some(resp.expiration);
serde_json::to_value(&new_config)
.map_err(|e| Error::internal_err(format!("Failed to serialize config: {}", e)))
}
}
/// Renewal window: renew Drive channels with <1 hour remaining, Calendar with <1 day remaining.
pub fn should_renew_channel(service_config: &serde_json::Value) -> bool {
let expiration_ms = service_config
.get("expiration")
.and_then(|v| v.as_str())
.and_then(|s| s.parse::<i64>().ok())
.unwrap_or(0);
if expiration_ms == 0 {
return false;
}
let now_ms = chrono::Utc::now().timestamp_millis();
let remaining_ms = expiration_ms - now_ms;
let trigger_type = service_config
.get("triggerType")
.and_then(|t| t.as_str())
.unwrap_or("drive");
let renewal_window_ms: i64 = match trigger_type {
"calendar" => 24 * 60 * 60 * 1000, // 1 day for Calendar (7 day expiry)
_ => 60 * 60 * 1000, // 1 hour for Drive (24h expiry)
};
remaining_ms < renewal_window_ms
}
async fn renew_expiring_channels(
handler: &Google,
db: &DB,
workspace_id: &str,
triggers: &[NativeTrigger],
synced: &mut Vec<TriggerSyncInfo>,
errors: &mut Vec<SyncError>,
) {
for trigger in triggers {
let Some(config) = &trigger.service_config else {
continue;
};
if !should_renew_channel(config) {
continue;
}
tracing::info!(
"Renewing expiring Google channel {} for script_path '{}' in workspace '{}'",
trigger.external_id,
trigger.script_path,
workspace_id
);
match handler.renew_channel(workspace_id, trigger, db).await {
Ok(new_config) => {
match update_native_trigger_service_config(
db,
workspace_id,
ServiceName::Google,
&trigger.external_id,
&new_config,
)
.await
{
Ok(()) => {
tracing::info!(
"Renewed Google channel {} for '{}'",
trigger.external_id,
trigger.script_path
);
synced.push(TriggerSyncInfo {
external_id: trigger.external_id.clone(),
script_path: trigger.script_path.clone(),
action: SyncAction::ConfigUpdated,
});
}
Err(e) => {
tracing::error!(
"Failed to update DB after renewing Google channel {}: {}",
trigger.external_id,
e
);
errors.push(SyncError {
resource_path: format!("workspace:{}", workspace_id),
error_message: format!(
"Failed to update DB after channel renewal for {}: {}",
trigger.external_id, e
),
error_type: "channel_renewal_error".to_string(),
});
}
}
}
Err(e) => {
tracing::error!(
"Failed to renew Google channel {} for '{}': {}",
trigger.external_id,
trigger.script_path,
e
);
let _ = update_native_trigger_error(
db,
workspace_id,
ServiceName::Google,
&trigger.external_id,
Some(&format!("Channel renewal failed: {}", e)),
)
.await;
errors.push(SyncError {
resource_path: format!("workspace:{}", workspace_id),
error_message: format!(
"Channel renewal failed for {}: {}",
trigger.external_id, e
),
error_type: "channel_renewal_error".to_string(),
});
}
}
}
}

View File

@@ -0,0 +1,241 @@
//! Google Native Trigger Module
//!
//! This module provides integration with Google services (Drive, Calendar)
//! push notification system to trigger Windmill scripts/flows when changes occur.
//!
//! ## Unified Architecture
//! A single "google" native trigger service handles both Drive and Calendar triggers.
//! The `trigger_type` field in `GoogleServiceConfig` determines which service to use.
//!
//! ## How it works:
//! 1. User configures a trigger with trigger_type (drive/calendar) and service-specific settings
//! 2. Windmill creates a "watch channel" via the appropriate Google API
//! 3. Google sends push notifications to Windmill's webhook when changes occur
//! 4. The webhook triggers the configured script/flow
//!
//! ## Important notes:
//! - Drive watch channels expire after max 24 hours
//! - Calendar watch channels expire after max 7 days
//! - Background sync job renews channels before expiration
use serde::{Deserialize, Serialize};
pub mod external;
pub mod routes;
pub use external::should_renew_channel;
/// Extracts `(google_resource_id, stop_url)` from a native trigger's service_config JSON.
/// Used by the `delete` method and tested independently.
pub fn parse_stop_channel_params(config: &serde_json::Value) -> (String, String) {
let google_resource_id = config
.get("googleResourceId")
.and_then(|r| r.as_str())
.map(String::from)
.unwrap_or_default();
let trigger_type = config
.get("triggerType")
.and_then(|t| t.as_str())
.unwrap_or("drive");
let stop_url = match trigger_type {
"calendar" => format!("{}/channels/stop", endpoints::CALENDAR_API_BASE),
_ => format!("{}/channels/stop", endpoints::DRIVE_API_BASE),
};
(google_resource_id, stop_url)
}
/// Handler struct for Google triggers (stateless, used for routing)
#[derive(Copy, Clone)]
pub struct Google;
/// Type of Google trigger
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum GoogleTriggerType {
Drive,
Calendar,
}
impl std::fmt::Display for GoogleTriggerType {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
GoogleTriggerType::Drive => write!(f, "drive"),
GoogleTriggerType::Calendar => write!(f, "calendar"),
}
}
}
/// User-provided configuration for a Google trigger.
/// The trigger_type determines which service-specific config is used.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct GoogleServiceConfig {
/// The type of trigger (drive or calendar)
pub trigger_type: GoogleTriggerType,
// Drive-specific fields (only used when trigger_type = drive)
/// The file ID to watch, or None for all changes (Drive only)
#[serde(skip_serializing_if = "Option::is_none")]
pub resource_id: Option<String>,
/// Human-readable name/path for display purposes (Drive only)
#[serde(skip_serializing_if = "Option::is_none")]
pub resource_name: Option<String>,
// Calendar-specific fields (only used when trigger_type = calendar)
/// The calendar ID to watch (Calendar only, e.g., "primary")
#[serde(skip_serializing_if = "Option::is_none")]
pub calendar_id: Option<String>,
/// Human-readable calendar name (Calendar only)
#[serde(skip_serializing_if = "Option::is_none")]
pub calendar_name: Option<String>,
// Metadata from Google watch channel (set after creation, used for renewal/deletion)
/// The resource ID assigned by Google for the watch channel
#[serde(skip_serializing_if = "Option::is_none")]
pub google_resource_id: Option<String>,
/// Channel expiration time (Unix timestamp in milliseconds, as string)
#[serde(skip_serializing_if = "Option::is_none")]
pub expiration: Option<String>,
}
impl GoogleServiceConfig {
/// Returns the expiration duration for this trigger type in hours
pub fn max_expiration_hours(&self) -> u64 {
match self.trigger_type {
GoogleTriggerType::Drive => 24, // Google Drive: max 24 hours
GoogleTriggerType::Calendar => 168, // Google Calendar: max 7 days
}
}
}
/// OAuth data structure shared by all Google services.
/// Stored encrypted in workspace_integrations table with service_name = 'google'.
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct GoogleOAuthData {
/// The OAuth access token for API requests
pub access_token: String,
/// The OAuth refresh token for obtaining new access tokens
pub refresh_token: Option<String>,
/// When the access token expires
pub token_expires_at: Option<chrono::DateTime<chrono::Utc>>,
}
/// Google API endpoints
pub mod endpoints {
/// Google Drive API v3 base URL
pub const DRIVE_API_BASE: &str = "https://www.googleapis.com/drive/v3";
/// Google Calendar API v3 base URL
pub const CALENDAR_API_BASE: &str = "https://www.googleapis.com/calendar/v3";
/// Google OAuth2 token endpoint
pub const TOKEN_ENDPOINT: &str = "https://oauth2.googleapis.com/token";
/// Google OAuth2 authorization endpoint
pub const AUTH_ENDPOINT: &str = "https://accounts.google.com/o/oauth2/v2/auth";
}
/// OAuth scopes for Google services
pub mod scopes {
/// Read-only access to Google Drive files
pub const DRIVE_READONLY: &str = "https://www.googleapis.com/auth/drive.readonly";
/// Read-only access to Google Calendar
pub const CALENDAR_READONLY: &str = "https://www.googleapis.com/auth/calendar.readonly";
/// Events access to Google Calendar
pub const CALENDAR_EVENTS: &str = "https://www.googleapis.com/auth/calendar.events";
/// Returns all scopes needed for Google triggers (both Drive and Calendar)
pub fn all_scopes() -> Vec<&'static str> {
vec![DRIVE_READONLY, CALENDAR_READONLY, CALENDAR_EVENTS]
}
}
/// Common response wrapper for Google API errors
#[derive(Debug, Deserialize)]
pub struct GoogleApiError {
pub error: GoogleErrorDetails,
}
#[derive(Debug, Deserialize)]
pub struct GoogleErrorDetails {
pub code: i32,
pub message: String,
#[serde(default)]
pub errors: Vec<GoogleErrorItem>,
}
#[derive(Debug, Deserialize)]
pub struct GoogleErrorItem {
pub domain: Option<String>,
pub reason: Option<String>,
pub message: Option<String>,
}
/// Google Watch Channel response (used by Drive and Calendar push notifications)
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct WatchChannel {
/// Unique channel ID (we generate this as UUID)
pub id: String,
/// Resource ID assigned by Google
pub resource_id: String,
/// Resource URI being watched
pub resource_uri: Option<String>,
/// Channel expiration time (Unix timestamp in milliseconds)
pub expiration: i64,
/// Token for validation (optional)
pub token: Option<String>,
}
/// Response from Google API when creating a watch channel
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct CreateWatchResponse {
/// The channel ID we provided
pub id: String,
/// Resource ID assigned by Google
pub resource_id: String,
/// Resource URI being watched
pub resource_uri: Option<String>,
/// Channel expiration (Unix timestamp in milliseconds)
pub expiration: String,
}
/// Request body for creating a watch channel
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct WatchRequest {
/// Unique channel ID (UUID)
pub id: String,
/// Type of delivery mechanism (always "web_hook")
#[serde(rename = "type")]
pub channel_type: String,
/// The URL to receive notifications
pub address: String,
/// Optional token for validation
#[serde(skip_serializing_if = "Option::is_none")]
pub token: Option<String>,
/// Optional expiration time in milliseconds (Google may adjust this)
#[serde(skip_serializing_if = "Option::is_none")]
pub expiration: Option<i64>,
}
impl WatchRequest {
pub fn new(channel_id: String, webhook_url: String) -> Self {
Self {
id: channel_id,
channel_type: "web_hook".to_string(),
address: webhook_url,
token: None,
expiration: None,
}
}
}
/// Request body for stopping a watch channel
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct StopChannelRequest {
pub id: String,
pub resource_id: String,
}

View File

@@ -0,0 +1,220 @@
use std::sync::Arc;
use axum::{
extract::{Path, Query},
routing::get,
Extension, Json, Router,
};
use http::Method;
use serde::{Deserialize, Serialize};
use windmill_common::{error::JsonResult, DB};
use crate::{get_workspace_integration, External, ServiceName};
use super::Google;
fn escape_drive_query(s: &str) -> String {
s.replace('\\', "\\\\").replace('\'', "\\'")
}
#[derive(Debug, Serialize, Deserialize)]
pub struct GoogleCalendarEntry {
pub id: String,
pub summary: String,
#[serde(default)]
pub primary: bool,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct GoogleCalendarListResponse {
#[serde(default)]
items: Vec<GoogleCalendarListItem>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct GoogleCalendarListItem {
id: String,
#[serde(default)]
summary: String,
#[serde(default)]
primary: bool,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct GoogleDriveFile {
pub id: String,
pub name: String,
pub mime_type: String,
#[serde(default)]
pub is_folder: bool,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct GoogleDriveFilesResponse {
pub files: Vec<GoogleDriveFile>,
#[serde(skip_serializing_if = "Option::is_none")]
pub next_page_token: Option<String>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct DriveApiResponse {
#[serde(default)]
files: Vec<DriveApiFile>,
next_page_token: Option<String>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct DriveApiFile {
id: String,
name: String,
mime_type: String,
}
#[derive(Debug, Deserialize)]
pub struct DriveFilesQuery {
pub q: Option<String>,
pub parent_id: Option<String>,
pub page_token: Option<String>,
#[serde(default)]
pub shared_with_me: bool,
}
async fn list_calendars(
Extension(handler): Extension<Arc<Google>>,
Extension(db): Extension<DB>,
Path(workspace_id): Path<String>,
) -> JsonResult<Vec<GoogleCalendarEntry>> {
get_workspace_integration(&db, &workspace_id, ServiceName::Google).await?;
let url = format!(
"{}/users/me/calendarList",
super::endpoints::CALENDAR_API_BASE
);
let response: GoogleCalendarListResponse = handler
.http_client_request::<_, ()>(&url, Method::GET, &workspace_id, &db, None, None)
.await?;
let calendars = response
.items
.into_iter()
.map(|item| GoogleCalendarEntry {
id: item.id,
summary: item.summary,
primary: item.primary,
})
.collect();
Ok(Json(calendars))
}
async fn list_drive_files(
Extension(handler): Extension<Arc<Google>>,
Extension(db): Extension<DB>,
Path(workspace_id): Path<String>,
Query(query): Query<DriveFilesQuery>,
) -> JsonResult<GoogleDriveFilesResponse> {
get_workspace_integration(&db, &workspace_id, ServiceName::Google).await?;
let drive_query = if query.shared_with_me {
"sharedWithMe = true and trashed = false".to_string()
} else if let Some(ref parent_id) = query.parent_id {
format!(
"'{}' in parents and trashed = false",
escape_drive_query(parent_id)
)
} else if let Some(ref search) = query.q {
format!(
"name contains '{}' and trashed = false",
escape_drive_query(search)
)
} else {
"'root' in parents and trashed = false".to_string()
};
let mut url = format!(
"{}/files?q={}&fields=files(id,name,mimeType),nextPageToken&pageSize=50&orderBy=folder,name&supportsAllDrives=true&includeItemsFromAllDrives=true",
super::endpoints::DRIVE_API_BASE,
urlencoding::encode(&drive_query)
);
if let Some(ref page_token) = query.page_token {
url.push_str(&format!("&pageToken={}", urlencoding::encode(page_token)));
}
let response: DriveApiResponse = handler
.http_client_request::<_, ()>(&url, Method::GET, &workspace_id, &db, None, None)
.await?;
let files = response
.files
.into_iter()
.map(|f| {
let is_folder = f.mime_type == "application/vnd.google-apps.folder";
GoogleDriveFile { id: f.id, name: f.name, mime_type: f.mime_type, is_folder }
})
.collect();
Ok(Json(GoogleDriveFilesResponse {
files,
next_page_token: response.next_page_token,
}))
}
#[derive(Debug, Serialize)]
pub struct SharedDriveEntry {
pub id: String,
pub name: String,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct SharedDrivesApiResponse {
#[serde(default)]
drives: Vec<SharedDriveApiEntry>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct SharedDriveApiEntry {
id: String,
name: String,
}
async fn list_shared_drives(
Extension(handler): Extension<Arc<Google>>,
Extension(db): Extension<DB>,
Path(workspace_id): Path<String>,
) -> JsonResult<Vec<SharedDriveEntry>> {
get_workspace_integration(&db, &workspace_id, ServiceName::Google).await?;
let url = format!(
"{}/drives?pageSize=100&fields=drives(id,name)",
super::endpoints::DRIVE_API_BASE
);
let response: SharedDrivesApiResponse = handler
.http_client_request::<_, ()>(&url, Method::GET, &workspace_id, &db, None, None)
.await?;
let drives = response
.drives
.into_iter()
.map(|d| SharedDriveEntry { id: d.id, name: d.name })
.collect();
Ok(Json(drives))
}
pub fn google_routes(service: Google) -> Router {
let service = Arc::new(service);
Router::new()
.route("/calendars", get(list_calendars))
.route("/drive/files", get(list_drive_files))
.route("/drive/shared_drives", get(list_shared_drives))
.layer(Extension(service))
}

View File

@@ -1,8 +1,7 @@
use crate::{
delete_native_trigger, delete_token_by_prefix, get_native_trigger, get_token_by_prefix,
get_workspace_integration, list_native_triggers, store_native_trigger,
update_native_trigger_error, External, NativeTrigger, NativeTriggerConfig, NativeTriggerData,
ServiceName,
decrypt_oauth_data, delete_native_trigger, delete_token_by_prefix, get_native_trigger,
get_token_by_prefix, list_native_triggers, store_native_trigger, update_native_trigger_error,
External, NativeTrigger, NativeTriggerConfig, NativeTriggerData, ServiceName,
};
use axum::{
extract::{Path, Query},
@@ -65,7 +64,7 @@ pub struct ListQuery {
pub struct FullTriggerResponse<T: Serialize> {
#[serde(flatten)]
pub windmill_data: NativeTrigger,
pub external_data: T,
pub external_data: Option<T>,
}
#[derive(Debug, Serialize)]
@@ -132,15 +131,9 @@ async fn create_native_trigger<T: External>(
)
.await?;
let integration = get_workspace_integration(&mut *tx, &workspace_id, service_name).await?;
let oauth_data: T::OAuthData = serde_json::from_value(integration.oauth_data).map_err(|e| {
Error::InternalErr(format!(
"Failed to parse {} OAuth data: {}",
T::DISPLAY_NAME,
e
))
})?;
let integration_service = service_name.integration_service();
let oauth_data: T::OAuthData =
decrypt_oauth_data(&db, &workspace_id, integration_service).await?;
let resp = handler
.create(
@@ -155,24 +148,25 @@ async fn create_native_trigger<T: External>(
let (external_id, _) = handler.external_id_and_metadata_from_response(&resp);
// update the created external trigger with a new uri containing the external_id
handler
.update(
&workspace_id,
&oauth_data,
&external_id,
&webhook_token,
&data,
&db,
&mut tx,
)
.await?;
// Fetch the updated trigger data from the external service and extract service_config
let trigger_data = handler
.get(&workspace_id, &oauth_data, &external_id, &db, &mut tx)
.await?;
let service_config = handler.extract_service_config_from_trigger_data(&trigger_data)?;
// Some services (e.g. Google) can build service_config directly from the create response,
// while others (e.g. Nextcloud) need an update+get cycle to correct the webhook URL
// with the external_id assigned by the remote service.
let service_config =
if let Some(config) = handler.service_config_from_create_response(&data, &resp) {
config
} else {
handler
.update(
&workspace_id,
&oauth_data,
&external_id,
&webhook_token,
&data,
&db,
&mut tx,
)
.await?
};
let config = NativeTriggerConfig {
script_path: data.script_path.clone(),
@@ -227,22 +221,32 @@ async fn update_native_trigger_handler<T: External>(
)
.await?;
let mut tx = user_db.begin(&authed).await?;
let integration_service = service_name.integration_service();
let oauth_data: T::OAuthData =
decrypt_oauth_data(&db, &workspace_id, integration_service).await?;
let mut tx = user_db.clone().begin(&authed).await?;
let existing = get_native_trigger(&mut *tx, &workspace_id, service_name, &external_id)
.await?
.ok_or_else(|| Error::NotFound(format!("Native trigger not found: {}", external_id)))?;
// Look up the full token using the stored prefix (use db, not tx, for token table)
let runnable_changed =
existing.script_path != data.script_path || existing.is_flow != data.is_flow;
let webhook_token = match get_token_by_prefix(&db, &existing.webhook_token_prefix).await? {
Some(token) => token,
None => {
tracing::warn!(
"Webhook token not found for trigger {} (prefix: {}), recreating token",
external_id,
existing.webhook_token_prefix
);
new_webhook_token(
Some(token) if !runnable_changed => token,
existing_token => {
if let Some(_) = existing_token {
delete_token_by_prefix(&db, &existing.webhook_token_prefix).await?;
} else {
tracing::warn!(
"Webhook token not found for trigger {} (prefix: {}), recreating token",
external_id,
existing.webhook_token_prefix
);
}
let token = new_webhook_token(
&mut *tx,
&db,
&authed,
@@ -251,21 +255,14 @@ async fn update_native_trigger_handler<T: External>(
&workspace_id,
service_name,
)
.await?
.await?;
tx.commit().await?;
tx = user_db.begin(&authed).await?;
token
}
};
let integration = get_workspace_integration(&mut *tx, &workspace_id, service_name).await?;
let oauth_data: T::OAuthData = serde_json::from_value(integration.oauth_data).map_err(|e| {
Error::InternalErr(format!(
"Failed to parse {} OAuth data: {}",
T::DISPLAY_NAME,
e
))
})?;
handler
let service_config = handler
.update(
&workspace_id,
&oauth_data,
@@ -277,12 +274,6 @@ async fn update_native_trigger_handler<T: External>(
)
.await?;
// Fetch the updated trigger data from the external service and extract service_config
let trigger_data = handler
.get(&workspace_id, &oauth_data, &external_id, &db, &mut tx)
.await?;
let service_config = handler.extract_service_config_from_trigger_data(&trigger_data)?;
let config = NativeTriggerConfig {
script_path: data.script_path.clone(),
is_flow: data.is_flow,
@@ -341,22 +332,16 @@ async fn get_native_trigger_handler<T: External>(
)
.await?;
let integration = get_workspace_integration(&mut *tx, &workspace_id, service_name).await?;
let oauth_data: T::OAuthData = serde_json::from_value(integration.oauth_data).map_err(|e| {
Error::InternalErr(format!(
"Failed to parse {} OAuth data: {}",
T::DISPLAY_NAME,
e
))
})?;
let integration_service = service_name.integration_service();
let oauth_data: T::OAuthData =
decrypt_oauth_data(&db, &workspace_id, integration_service).await?;
let native_trigger = handler
.get(&workspace_id, &oauth_data, &external_id, &db, &mut tx)
.await;
let native_trigger_config = match native_trigger {
Ok(native_cfg) => {
let external_data = match native_trigger {
Ok(Some(native_cfg)) => {
// Clear error if it was set
if windmill_trigger.error.is_some() {
update_native_trigger_error(
@@ -368,8 +353,9 @@ async fn get_native_trigger_handler<T: External>(
)
.await?;
}
native_cfg
Some(native_cfg)
}
Ok(None) => None,
Err(Error::NotFound(_)) => {
let error_msg = "Trigger no longer exists on external service".to_string();
tracing::warn!(
@@ -396,10 +382,7 @@ async fn get_native_trigger_handler<T: External>(
Err(e) => return Err(e),
};
let full_resp = Json(FullTriggerResponse {
windmill_data: windmill_trigger,
external_data: native_trigger_config,
});
let full_resp = Json(FullTriggerResponse { windmill_data: windmill_trigger, external_data });
Ok(full_resp)
}
@@ -430,15 +413,9 @@ async fn delete_native_trigger_handler<T: External>(
)
.await?;
let integration = get_workspace_integration(&mut *tx, &workspace_id, service_name).await?;
let oauth_data: T::OAuthData = serde_json::from_value(integration.oauth_data).map_err(|e| {
Error::InternalErr(format!(
"Failed to parse {} OAuth data: {}",
T::DISPLAY_NAME,
e
))
})?;
let integration_service = service_name.integration_service();
let oauth_data: T::OAuthData =
decrypt_oauth_data(&db, &workspace_id, integration_service).await?;
handler
.delete(&workspace_id, &oauth_data, &external_id, &db, &mut tx)
@@ -476,34 +453,6 @@ async fn delete_native_trigger_handler<T: External>(
Ok(format!("Native trigger deleted"))
}
async fn exists_native_trigger_handler<T: External>(
Extension(service_name): Extension<ServiceName>,
_authed: ApiAuthed,
Extension(db): Extension<DB>,
Path((workspace_id, external_id)): Path<(String, String)>,
) -> JsonResult<bool> {
let exists = sqlx::query_scalar!(
r#"
SELECT EXISTS(
SELECT 1
FROM native_trigger
WHERE
workspace_id = $1 AND
service_name = $2 AND
external_id = $3
)
"#,
workspace_id,
service_name as ServiceName,
external_id
)
.fetch_one(&db)
.await?
.unwrap_or(false);
Ok(Json(exists))
}
async fn list_native_triggers_handler<T: External>(
Extension(service_name): Extension<ServiceName>,
authed: ApiAuthed,
@@ -543,10 +492,6 @@ pub fn service_routes<T: External + 'static>(handler: T) -> Router {
.route(
"/delete/:external_id",
delete(delete_native_trigger_handler::<T>),
)
.route(
"/exists/:external_id",
get(exists_native_trigger_handler::<T>),
);
standard_routes
@@ -562,15 +507,12 @@ pub fn generate_native_trigger_routers() -> Router {
#[cfg(feature = "native_trigger")]
{
use crate::google::Google;
use crate::nextcloud::NextCloud;
// Register all service routes here
// When adding a new service:
// 1. Import the handler: use crate::newservice::NewServiceHandler;
// 2. Add the route: .nest("/newservice", service_routes(NewServiceHandler))
return router.nest("/nextcloud", service_routes(NextCloud));
// Add new services here:
// .nest("/newservice", service_routes(NewServiceHandler))
return router
.nest("/nextcloud", service_routes(NextCloud))
.nest("/google", service_routes(Google));
}
#[cfg(not(feature = "native_trigger"))]

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