feat: public app rate limiting + fork hub raw apps + raw apps publish to hub button

This commit is contained in:
HugoCasa
2026-02-04 17:28:20 +01:00
parent 2e470816ed
commit ea965a06f2
15 changed files with 439 additions and 16 deletions
+1
View File
@@ -15587,6 +15587,7 @@ dependencies = [
"constant_time_eq 0.3.1",
"cookie 0.17.0",
"cron",
"dashmap 6.1.0",
"datafusion",
"deno_core",
"deno_error",
@@ -0,0 +1,2 @@
ALTER TABLE workspace_settings
DROP COLUMN IF EXISTS public_app_execution_limit_per_minute;
@@ -0,0 +1,2 @@
ALTER TABLE workspace_settings
ADD COLUMN IF NOT EXISTS public_app_execution_limit_per_minute INTEGER DEFAULT NULL;
+1
View File
@@ -169,6 +169,7 @@ tar.workspace = true
flate2.workspace = true
backon = {workspace = true, optional = true}
strum = { workspace = true, optional = true }
dashmap.workspace = true
[build-dependencies]
deno_core = { workspace = true, optional = true }
+60
View File
@@ -2383,6 +2383,9 @@ paths:
type: string
operator_settings:
$ref: "#/components/schemas/OperatorSettings"
public_app_execution_limit_per_minute:
type: integer
description: Rate limit for public app executions per minute per server. NULL or 0 means disabled.
/w/{workspace}/workspaces/get_deploy_to:
get:
@@ -4145,6 +4148,35 @@ paths:
type: string
example: "Updated mute critical alert UI settings for workspace: workspace_id"
/w/{workspace}/workspaces/public_app_rate_limit:
post:
summary: Set public app rate limit for this workspace
operationId: setPublicAppRateLimit
tags:
- setting
parameters:
- $ref: "#/components/parameters/WorkspaceId"
requestBody:
description: Public app rate limit configuration
required: true
content:
application/json:
schema:
type: object
properties:
public_app_execution_limit_per_minute:
type: integer
description: Rate limit for public app executions per minute per server. NULL or 0 to disable.
example: 100
responses:
"200":
description: Successfully updated public app rate limit settings.
content:
application/json:
schema:
type: string
example: "Updated public app rate limit for workspace: workspace_id"
/oauth/login_callback/{client_name}:
post:
security: []
@@ -5396,6 +5428,34 @@ paths:
required:
- app
/apps/hub/get_raw/{id}:
get:
summary: get hub raw app by id
operationId: getHubRawAppById
tags:
- app
parameters:
- $ref: "#/components/parameters/PathId"
responses:
"200":
description: raw app
content:
application/json:
schema:
type: object
properties:
app:
type: object
properties:
summary:
type: string
value: {}
required:
- summary
- value
required:
- app
/apps_u/public_app_by_custom_path/{custom_path}:
get:
summary: get public app by custom path
+28
View File
@@ -126,6 +126,7 @@ pub fn global_service() -> Router {
Router::new()
.route("/hub/list", get(list_hub_apps))
.route("/hub/get/:id", get(get_hub_app_by_id))
.route("/hub/get_raw/:id", get(get_hub_raw_app_by_id))
}
#[derive(FromRow, Deserialize, Serialize)]
@@ -1312,6 +1313,24 @@ pub async fn get_hub_app_by_id(
Ok(Json(value))
}
pub async fn get_hub_raw_app_by_id(
Path(id): Path<i32>,
Extension(db): Extension<DB>,
) -> JsonResult<Box<serde_json::value::RawValue>> {
let value = http_get_from_hub(
&HTTP_CLIENT,
&format!("{}/raw_apps/{}/json", *HUB_BASE_URL.read().await, id),
false,
None,
Some(&db),
)
.await?
.json()
.await
.map_err(to_anyhow)?;
Ok(Json(value))
}
async fn delete_app(
authed: ApiAuthed,
Extension(db): Extension<DB>,
@@ -1910,6 +1929,15 @@ async fn execute_component(
}
};
// Check rate limit for anonymous (public) executions
if matches!(policy.execution_mode, ExecutionMode::Anonymous) && opt_authed.is_none() {
if let Some(limit) = crate::workspaces::get_public_app_rate_limit(&db, &w_id).await? {
if limit > 0 {
crate::public_app_rate_limit::check_and_increment(&w_id, limit)?;
}
}
}
// Execution is publisher and an user is authenticated: check if the user is authorized to
// execute the app.
if let (ExecutionMode::Publisher, Some(authed)) = (policy.execution_mode, opt_authed.as_ref()) {
+1
View File
@@ -167,6 +167,7 @@ mod teams_approvals_oss;
#[cfg(feature = "native_trigger")]
pub mod native_triggers;
mod public_app_layer;
mod public_app_rate_limit;
mod static_assets;
#[cfg(all(feature = "stripe", feature = "enterprise", feature = "private"))]
pub mod stripe_ee;
@@ -0,0 +1,48 @@
/*
* Author: Windmill Labs, Inc
* Copyright: Windmill Labs, Inc 2024
* This file and its contents are licensed under the AGPLv3 License.
* Please see the included NOTICE for copyright information and
* LICENSE-AGPL for a copy of the license.
*/
use chrono::Utc;
use dashmap::DashMap;
use hyper::StatusCode;
use std::sync::LazyLock;
use windmill_common::error::{Error, Result};
struct RateLimitEntry {
count: i32,
minute_bucket: i64,
}
static RATE_LIMIT_COUNTER: LazyLock<DashMap<String, RateLimitEntry>> =
LazyLock::new(DashMap::new);
pub fn check_and_increment(workspace_id: &str, limit: i32) -> Result<()> {
let current_minute = Utc::now().timestamp() / 60;
let mut entry = RATE_LIMIT_COUNTER
.entry(workspace_id.to_string())
.or_insert(RateLimitEntry { count: 0, minute_bucket: current_minute });
if entry.minute_bucket != current_minute {
entry.count = 0;
entry.minute_bucket = current_minute;
}
if entry.count >= limit {
return Err(Error::Generic(
StatusCode::TOO_MANY_REQUESTS,
format!(
"Rate limit exceeded for public app executions in workspace '{}'. \
Limit: {} per minute per server.",
workspace_id, limit
),
));
}
entry.count += 1;
Ok(())
}
+57 -1
View File
@@ -176,6 +176,10 @@ pub fn workspaced_service() -> Router {
post(acknowledge_all_critical_alerts),
)
.route("/critical_alerts/mute", post(mute_critical_alerts))
.route(
"/public_app_rate_limit",
post(edit_public_app_rate_limit),
)
.route("/operator_settings", post(update_operator_settings))
.route(
"/create_workspace_fork_branch",
@@ -287,6 +291,8 @@ pub struct WorkspaceSettings {
pub error_handler: Option<serde_json::Value>,
#[serde(skip_serializing_if = "Option::is_none")]
pub success_handler: Option<serde_json::Value>,
#[serde(skip_serializing_if = "Option::is_none")]
pub public_app_execution_limit_per_minute: Option<i32>,
}
/// #[derive(sqlx::Type, Serialize, Deserialize, Debug)]
@@ -625,7 +631,8 @@ async fn get_settings(
git_app_installations,
auto_invite,
error_handler,
success_handler
success_handler,
public_app_execution_limit_per_minute
FROM
workspace_settings
WHERE
@@ -4376,6 +4383,55 @@ pub async fn mute_critical_alerts() -> Error {
Error::NotFound("Critical Alerts require EE".to_string())
}
#[derive(Deserialize)]
pub struct EditPublicAppRateLimitRequest {
pub public_app_execution_limit_per_minute: Option<i32>,
}
async fn edit_public_app_rate_limit(
Extension(db): Extension<DB>,
Path(w_id): Path<String>,
authed: ApiAuthed,
Json(req): Json<EditPublicAppRateLimitRequest>,
) -> Result<String> {
require_admin(authed.is_admin, &authed.username)?;
sqlx::query!(
"UPDATE workspace_settings SET public_app_execution_limit_per_minute = $1 WHERE workspace_id = $2",
req.public_app_execution_limit_per_minute,
&w_id
)
.execute(&db)
.await?;
handle_deployment_metadata(
&authed.email,
&authed.username,
&db,
&w_id,
DeployedObject::Settings { setting_type: "public_app_rate_limit".to_string() },
None,
false,
None,
)
.await?;
Ok(format!(
"Updated public app rate limit for workspace: {}",
&w_id
))
}
pub async fn get_public_app_rate_limit(db: &DB, w_id: &str) -> Result<Option<i32>> {
let result: Option<Option<i32>> = sqlx::query_scalar(
"SELECT public_app_execution_limit_per_minute FROM workspace_settings WHERE workspace_id = $1",
)
.bind(w_id)
.fetch_optional(db)
.await?;
Ok(result.flatten())
}
#[derive(Deserialize, Serialize)]
struct ChangeOperatorSettings {
#[serde(default)]
+88 -2
View File
@@ -47,6 +47,7 @@
"hash-sum": "^2.0.0",
"highlight.js": "^11.8.0",
"idb": "^8.0.2",
"jszip": "^3.10.1",
"lru-cache": "^11.1.0",
"lucide-svelte": "^0.540.0",
"minimatch": "^10.0.1",
@@ -4308,6 +4309,12 @@
"dev": true,
"license": "MIT"
},
"node_modules/core-util-is": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz",
"integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==",
"license": "MIT"
},
"node_modules/cosmiconfig": {
"version": "8.3.6",
"resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-8.3.6.tgz",
@@ -6926,7 +6933,6 @@
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
"devOptional": true,
"license": "ISC"
},
"node_modules/ini": {
@@ -7129,6 +7135,12 @@
"integrity": "sha512-ITvGim8FhRiYe4IQ5uHSkj7pVaPDrCTkNd3yq3cV7iZAcJdHTUMPMEHcqSOy9xZ9qFenQCvi+2wjH9a1nXqHww==",
"license": "MIT"
},
"node_modules/isarray": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz",
"integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==",
"license": "MIT"
},
"node_modules/isexe": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
@@ -7331,6 +7343,54 @@
"node": ">=0.10.0"
}
},
"node_modules/jszip": {
"version": "3.10.1",
"resolved": "https://registry.npmjs.org/jszip/-/jszip-3.10.1.tgz",
"integrity": "sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==",
"license": "(MIT OR GPL-3.0-or-later)",
"dependencies": {
"lie": "~3.3.0",
"pako": "~1.0.2",
"readable-stream": "~2.3.6",
"setimmediate": "^1.0.5"
}
},
"node_modules/jszip/node_modules/pako": {
"version": "1.0.11",
"resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz",
"integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==",
"license": "(MIT AND Zlib)"
},
"node_modules/jszip/node_modules/readable-stream": {
"version": "2.3.8",
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz",
"integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==",
"license": "MIT",
"dependencies": {
"core-util-is": "~1.0.0",
"inherits": "~2.0.3",
"isarray": "~1.0.0",
"process-nextick-args": "~2.0.0",
"safe-buffer": "~5.1.1",
"string_decoder": "~1.1.1",
"util-deprecate": "~1.0.1"
}
},
"node_modules/jszip/node_modules/safe-buffer": {
"version": "5.1.2",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz",
"integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==",
"license": "MIT"
},
"node_modules/jszip/node_modules/string_decoder": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz",
"integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==",
"license": "MIT",
"dependencies": {
"safe-buffer": "~5.1.0"
}
},
"node_modules/keyv": {
"version": "4.5.4",
"resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
@@ -7572,6 +7632,21 @@
"url": "https://github.com/sponsors/dmonad"
}
},
"node_modules/lie": {
"version": "3.3.0",
"resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz",
"integrity": "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==",
"license": "MIT",
"dependencies": {
"immediate": "~3.0.5"
}
},
"node_modules/lie/node_modules/immediate": {
"version": "3.0.6",
"resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz",
"integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==",
"license": "MIT"
},
"node_modules/lightningcss": {
"version": "1.30.2",
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.30.2.tgz",
@@ -10781,6 +10856,12 @@
"node": ">= 0.6.0"
}
},
"node_modules/process-nextick-args": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz",
"integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==",
"license": "MIT"
},
"node_modules/property-information": {
"version": "7.1.0",
"resolved": "https://registry.npmjs.org/property-information/-/property-information-7.1.0.tgz",
@@ -11591,6 +11672,12 @@
"node": ">= 0.4"
}
},
"node_modules/setimmediate": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz",
"integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==",
"license": "MIT"
},
"node_modules/shebang-command": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
@@ -13351,7 +13438,6 @@
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
"devOptional": true,
"license": "MIT"
},
"node_modules/validate-npm-package-license": {
+1
View File
@@ -117,6 +117,7 @@
"hash-sum": "^2.0.0",
"highlight.js": "^11.8.0",
"idb": "^8.0.2",
"jszip": "^3.10.1",
"lru-cache": "^11.1.0",
"lucide-svelte": "^0.540.0",
"minimatch": "^10.0.1",
@@ -4,13 +4,17 @@
import UndoRedo from '$lib/components/common/button/UndoRedo.svelte'
import { AppService, DraftService, type Policy } from '$lib/gen'
import { enterpriseLicense, userStore, workspaceStore } from '$lib/stores'
import { rawAppToHubUrl } from '$lib/hub'
import { enterpriseLicense, hubBaseUrlStore, userStore, workspaceStore } from '$lib/stores'
import JSZip from 'jszip'
import YAML from 'yaml'
import {
Bug,
DiffIcon,
Download,
EllipsisVertical,
FileJson,
FileUp,
Globe,
History,
Pen,
Save,
@@ -143,8 +147,40 @@
let draftDrawerOpen = $state(false)
let saveDrawerOpen = $state(false)
let historyBrowserDrawerOpen = $state(false)
let publishToHubDrawerOpen = $state(false)
let publishingToHub = $state(false)
let deploymentMsg: string | undefined = $state(undefined)
async function publishToHub() {
if (!app) return
publishingToHub = true
try {
const { js, css } = await getBundle()
const zip = new JSZip()
zip.file('app.yaml', YAML.stringify(app))
zip.file('bundle.js', js)
zip.file('bundle.css', css)
const blob = await zip.generateAsync({ type: 'blob' })
// Download the zip
const url = window.URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `${(appPath || 'raw-app').replaceAll('/', '__')}.zip`
a.click()
setTimeout(() => URL.revokeObjectURL(url), 100)
// Open hub page
const hubUrl = rawAppToHubUrl(
$hubBaseUrlStore,
summary || appPath.split('/').pop()?.replace('_', ' ') || 'my raw app'
)
window.open(hubUrl.toString(), '_blank')
} finally {
publishingToHub = false
}
}
function closeSaveDrawer() {
saveDrawerOpen = false
}
@@ -538,11 +574,10 @@
}
},
{
displayName: 'Hub compatible JSON',
icon: FileUp,
displayName: 'Publish to Hub',
icon: Globe,
action: () => {
sendUserToast('todo')
// appExport.open(toStatic(app, $staticExporter, summary).app)
publishToHubDrawerOpen = true
}
},
{
@@ -728,6 +763,42 @@
</DrawerContent>
</Drawer>
<Drawer bind:open={publishToHubDrawerOpen} size="600px">
<DrawerContent title="Publish to Hub" on:close={() => (publishToHubDrawerOpen = false)}>
{#snippet actions()}
<Button
loading={publishingToHub}
disabled={!app}
on:click={publishToHub}
variant="accent"
startIcon={{ icon: Download }}
>
Download & open hub
</Button>
{/snippet}
<div class="flex flex-col gap-4">
<p class="text-secondary text-sm">
This will download a zip file containing your raw app bundle and open the Windmill Hub
submission page.
</p>
<div class="text-sm">
<p class="font-semibold mb-2">The zip file will contain:</p>
<ul class="list-disc list-inside text-secondary space-y-1">
<li
><code class="text-xs bg-surface-secondary px-1 rounded">app.yaml</code> - App configuration</li
>
<li
><code class="text-xs bg-surface-secondary px-1 rounded">bundle.js</code> - JavaScript bundle</li
>
<li
><code class="text-xs bg-surface-secondary px-1 rounded">bundle.css</code> - CSS styles</li
>
</ul>
</div>
</div>
</DrawerContent>
</Drawer>
<AppJobsDrawer
bind:open={jobsDrawerOpen}
on:clear={() => {
+10 -2
View File
@@ -1,7 +1,7 @@
import type { Schema } from './common'
import { AppService, FlowService, type Flow, type Script } from './gen'
import { encodeState } from './utils'
import rawHubPaths from './hubPaths.json?raw'
import hubPathsData from './hubPaths.json'
import {
replacePlaceholderForSignatureScriptTemplate,
SIGNATURE_TEMPLATE_FLOW_HUB_ID,
@@ -83,6 +83,14 @@ export function appToHubUrl(staticApp: any, hubBaseUrl: string): URL {
return url
}
export function rawAppToHubUrl(hubBaseUrl: string, summary?: string): URL {
const url = new URL(hubBaseUrl + '/raw_apps/add')
if (summary) {
url.searchParams.append('summary', summary)
}
return url
}
type HubPaths = {
gitSync: string
gitSyncTest: string
@@ -101,4 +109,4 @@ type HubPaths = {
appReport: string
}
export const hubPaths = JSON.parse(rawHubPaths) as HubPaths
export const hubPaths = hubPathsData as HubPaths
@@ -39,13 +39,14 @@
let nodraft = $page.url.searchParams.get('nodraft')
const templatePath = $page.url.searchParams.get('template')
const templateId = $page.url.searchParams.get('template_id')
const hubId = $page.url.searchParams.get('hub')
const importRaw = $importStore
if ($importStore) {
$importStore = undefined
}
const appState = nodraft ? undefined : localStorage.getItem('rawapp')
const appState = nodraft || hubId ? undefined : localStorage.getItem('rawapp')
let summary = $state('')
let files: Record<string, string> = $state(react19Template)
@@ -143,7 +144,18 @@
console.log('App loaded from template id')
sendUserToast('App loaded from template')
goto('?', { replaceState: true })
} else if (!templatePath && appState) {
} else if (hubId) {
const hub = await AppService.getHubRawAppById({ id: Number(hubId) })
if (hub.app?.value) {
extractValue(hub.app.value)
}
if (hub.app?.summary) {
summary = hub.app.summary
}
console.log('App loaded from Hub')
sendUserToast('App loaded from Hub')
goto('?', { replaceState: true })
} else if (!templatePath && !hubId && appState) {
console.log('App loaded from browser stored autosave')
sendUserToast('App restored from browser stored autosave', false, [
{
@@ -102,6 +102,8 @@
let successHandlerScriptPath: string | undefined = $state(undefined)
let criticalAlertUIMuted: boolean | undefined = $state(undefined)
let initialCriticalAlertUIMuted: boolean | undefined = $state(undefined)
let publicAppRateLimitPerMinute: number | undefined = $state(undefined)
let initialPublicAppRateLimitPerMinute: number | undefined = $state(undefined)
let aiProviders: Exclude<AIConfig['providers'], undefined> = $state({})
let codeCompletionModel: string | undefined = $state(undefined)
@@ -347,6 +349,8 @@
errorHandlerMutedOnUserPath = errorHandler?.muted_on_user_path
criticalAlertUIMuted = settings.mute_critical_alerts
initialCriticalAlertUIMuted = settings.mute_critical_alerts
publicAppRateLimitPerMinute = settings.public_app_execution_limit_per_minute ?? undefined
initialPublicAppRateLimitPerMinute = settings.public_app_execution_limit_per_minute ?? undefined
if (emptyString($enterpriseLicense)) {
errorHandlerSelected = 'custom'
} else {
@@ -561,6 +565,21 @@
}, 3000)
}
async function editPublicAppRateLimit() {
await SettingService.setPublicAppRateLimit({
workspace: $workspaceStore!,
requestBody: {
public_app_execution_limit_per_minute: publicAppRateLimitPerMinute
}
})
initialPublicAppRateLimitPerMinute = publicAppRateLimitPerMinute
sendUserToast(
publicAppRateLimitPerMinute
? `Public app rate limit set to ${publicAppRateLimitPerMinute} per minute per server`
: `Public app rate limit disabled`
)
}
// Function to check if there are unsaved changes in AI settings
function getAiSettingsInitialAndModifiedValues() {
// Only check for unsaved changes when on the AI tab
@@ -764,9 +783,9 @@
<Tab
small
value="default_app"
aiId="workspace-settings-default-app"
aiDescription="Default app workspace settings"
label="Default App"
aiId="workspace-settings-apps"
aiDescription="Apps workspace settings"
label="Apps"
/>
<Tab
@@ -1405,6 +1424,33 @@ export async function main(
/>
{/key}
</div>
<hr class="border-t my-8" />
<Section
label="Public App Rate Limiting"
description="Limit the number of public (anonymous) app executions per minute per server. Set to 0 or leave empty to disable. This is a per-server limit, not a global limit."
class="flex flex-col gap-6"
>
<div class="flex flex-row items-center gap-4">
<TextInput
inputProps={{ type: 'number', placeholder: '0 (disabled)' }}
bind:value={publicAppRateLimitPerMinute}
class="w-48"
/>
<span class="text-secondary text-sm">executions per minute per server</span>
</div>
<Button
disabled={publicAppRateLimitPerMinute === initialPublicAppRateLimitPerMinute}
size="sm"
on:click={editPublicAppRateLimit}
variant="default"
startIcon={{ icon: Save }}
btnClasses="w-fit"
>
Save rate limit
</Button>
</Section>
{:else if tab == 'native_triggers'}
{#if $workspaceStore}
{#await import('$lib/components/workspaceSettings/WorkspaceIntegrations.svelte') then { default: WorkspaceIntegrations }}