mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-09-15 08:02:27 +00:00
Compare commits
17
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
00392ba548 | ||
|
|
ba4b368706 | ||
|
|
723a65920f | ||
|
|
c644311eca | ||
|
|
31d9215e5a | ||
|
|
75bafabeee | ||
|
|
fa3596885b | ||
|
|
6d94865109 | ||
|
|
8dea38383f | ||
|
|
2879cbb65a | ||
|
|
e82a6a6830 | ||
|
|
e16061df06 | ||
|
|
3bf5b72afa | ||
|
|
6f4017d694 | ||
|
|
d5cb944cf9 | ||
|
|
e19594df2a | ||
|
|
6e96f90065 |
@@ -1,5 +1,42 @@
|
||||
# Changelog
|
||||
|
||||
## [1.737.0](https://github.com/windmill-labs/windmill/compare/v1.736.0...v1.737.0) (2026-06-23)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **apps:** opt-in sandbox isolation for published & raw apps (alpha) ([#9420](https://github.com/windmill-labs/windmill/issues/9420)) ([2879cbb](https://github.com/windmill-labs/windmill/commit/2879cbb65a4122c86b4a472206d74d9009b07904))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* allow SQL args in managed // materialize scripts ([#9733](https://github.com/windmill-labs/windmill/issues/9733)) ([fa35968](https://github.com/windmill-labs/windmill/commit/fa3596885bf2d7ee8859f295e820ee362c756911))
|
||||
* bound orphan-cleanup drain rate with capped multi-batch loop ([#9730](https://github.com/windmill-labs/windmill/issues/9730)) ([31d9215](https://github.com/windmill-labs/windmill/commit/31d9215e5a61f19662cc87be8147007e1d47ebb6))
|
||||
* **ext-jwt:** reject external JWT auth for non-existent workspaces ([#9723](https://github.com/windmill-labs/windmill/issues/9723)) ([c644311](https://github.com/windmill-labs/windmill/commit/c644311eca4bcaf4b68058cf1d5d79d4078aee1a))
|
||||
* optimize cleanup_job_perms_orphaned and job_result_stream cleanup queries ([#9727](https://github.com/windmill-labs/windmill/issues/9727)) ([6d94865](https://github.com/windmill-labs/windmill/commit/6d9486510933af9109f52011d93b13847dbdbb39))
|
||||
* prevent silent audit-partition outage via monitor watchdog + alert ([#9729](https://github.com/windmill-labs/windmill/issues/9729)) ([8dea383](https://github.com/windmill-labs/windmill/commit/8dea38383f884f59b2956c39f1424005a21265bd))
|
||||
|
||||
|
||||
### Performance Improvements
|
||||
|
||||
* **monitor:** hash active-root exclusion in retention delete (WIN-2088) ([#9732](https://github.com/windmill-labs/windmill/issues/9732)) ([75bafab](https://github.com/windmill-labs/windmill/commit/75bafabeeec76cf6da33eef41f588e37071df011))
|
||||
|
||||
## [1.736.0](https://github.com/windmill-labs/windmill/compare/v1.735.0...v1.736.0) (2026-06-23)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **ai-chat:** workspace AI chat skills (SKILL.md upload + read_skill tool) ([#9648](https://github.com/windmill-labs/windmill/issues/9648)) ([6f4017d](https://github.com/windmill-labs/windmill/commit/6f4017d694494a158ebd0579c93336c378cba0fd))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **drafts:** stop mis-filing workspace-blind legacy drafts on migration ([#9725](https://github.com/windmill-labs/windmill/issues/9725)) ([3bf5b72](https://github.com/windmill-labs/windmill/commit/3bf5b72afab3241ea41a261a40c2434764bdaf72))
|
||||
* **frontend:** destroy old WebsocketProvider on workspace switch in MultiplayerMenu ([#9719](https://github.com/windmill-labs/windmill/issues/9719)) ([6e96f90](https://github.com/windmill-labs/windmill/commit/6e96f90065dfe2f6ccc5eb4f85f4facd3515c70c))
|
||||
* **frontend:** ensure type:object in test_run_flow tool schema for Anthropic ([#9721](https://github.com/windmill-labs/windmill/issues/9721)) ([d5cb944](https://github.com/windmill-labs/windmill/commit/d5cb944cf92f074b2ee42c876595eacdfa2f4d76))
|
||||
* **health:** detect read-only replica via pg_is_in_recovery() ([#9722](https://github.com/windmill-labs/windmill/issues/9722)) ([e16061d](https://github.com/windmill-labs/windmill/commit/e16061df06babeae935a9396de5bdcd46e8119a9))
|
||||
* re-enforce scoped API token boundaries across handlers ([#9712](https://github.com/windmill-labs/windmill/issues/9712)) ([e19594d](https://github.com/windmill-labs/windmill/commit/e19594df2ad015a0336ade95e04562f5562ec3f6))
|
||||
|
||||
## [1.735.0](https://github.com/windmill-labs/windmill/compare/v1.734.0...v1.735.0) (2026-06-22)
|
||||
|
||||
|
||||
|
||||
+29
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT\n COUNT(*)::bigint AS \"total!\",\n COUNT(*) FILTER (WHERE name = ANY($2::text[]))::bigint AS \"replacing!\"\n FROM ai_skill\n WHERE workspace_id = $1",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "total!",
|
||||
"type_info": "Int8"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "replacing!",
|
||||
"type_info": "Int8"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"TextArray"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
null,
|
||||
null
|
||||
]
|
||||
},
|
||||
"hash": "002a606e71364b0581dbc496bf4337f276861dc71d2e277a7aef711543eb14d7"
|
||||
}
|
||||
+35
@@ -0,0 +1,35 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT kind::text as \"kind!\", parent_job, runnable_path\n FROM v2_job WHERE id = $1 AND workspace_id = $2",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "kind!",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "parent_job",
|
||||
"type_info": "Uuid"
|
||||
},
|
||||
{
|
||||
"ordinal": 2,
|
||||
"name": "runnable_path",
|
||||
"type_info": "Varchar"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Uuid",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
null,
|
||||
true,
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "19f1cb1c7a1974920549917a6392ff0d56f31ca5662062f4a71fed0ccf859cc4"
|
||||
}
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "DELETE FROM job_perms\n WHERE ctid IN (\n SELECT jp.ctid FROM job_perms jp\n WHERE NOT EXISTS (SELECT 1 FROM v2_job_queue q WHERE q.id = jp.job_id)\n LIMIT 100000\n )",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": []
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "25ecae25ebc03d6296b0e72482a9201c2bffb0f0f7419b0b598b2786bdb326ab"
|
||||
}
|
||||
+3
-3
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT 1",
|
||||
"query": "SELECT NOT pg_is_in_recovery()",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "?column?",
|
||||
"type_info": "Int4"
|
||||
"type_info": "Bool"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
@@ -16,5 +16,5 @@
|
||||
null
|
||||
]
|
||||
},
|
||||
"hash": "e004ebd5b5532a4b85984a62f8ad48a81aa3460c1ca07701f386135d72cdecf5"
|
||||
"hash": "282b56cfb8504312ac586cd4c1f3f914cf1651c08b8edd1b06d9a6454ed779bf"
|
||||
}
|
||||
-20
@@ -1,20 +0,0 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "DELETE FROM job_result_stream_v2\n WHERE job_id NOT IN (SELECT id FROM v2_job_queue)\n AND job_id NOT IN (\n SELECT id FROM v2_job_completed\n WHERE completed_at > NOW() - INTERVAL '60 seconds'\n )\n RETURNING job_id",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "job_id",
|
||||
"type_info": "Uuid"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": []
|
||||
},
|
||||
"nullable": [
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "454a611a5a162b2ace137c139bd5383bc7fe142c515dac3edd47991839485e51"
|
||||
}
|
||||
+18
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "INSERT INTO ai_skill (workspace_id, name, description, instructions, edited_at, edited_by)\n VALUES ($1, $2, $3, $4, now(), $5)\n ON CONFLICT (workspace_id, name) DO UPDATE\n SET description = EXCLUDED.description,\n instructions = EXCLUDED.instructions,\n edited_at = now(),\n edited_by = EXCLUDED.edited_by",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Varchar",
|
||||
"Varchar",
|
||||
"Text",
|
||||
"Text",
|
||||
"Varchar"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "734781e8e55e95c55f72e094e96297aa852e20a0f0d20db4b993947792f6b0a8"
|
||||
}
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT runnable_path FROM v2_job WHERE id = $1 AND workspace_id = $2",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "runnable_path",
|
||||
"type_info": "Varchar"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Uuid",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "c167db39eeed526449dc064eaeb26e648aa9455cb81bab86fd106f76537701ba"
|
||||
}
|
||||
-20
@@ -1,20 +0,0 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "DELETE FROM job_perms\nWHERE job_id NOT IN (SELECT id FROM v2_job_queue)\nRETURNING job_id",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "job_id",
|
||||
"type_info": "Uuid"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": []
|
||||
},
|
||||
"nullable": [
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "c825fa5c6e287068aeaad994c0b42b8ad59b9129f032c6b918c27426ab304f2b"
|
||||
}
|
||||
+28
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT name, description FROM ai_skill WHERE workspace_id = $1 ORDER BY name",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "name",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "description",
|
||||
"type_info": "Text"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "c84087a0669d0b71829b0765c7274ca0a03fb823a781fb46d2b2b6cfc535a16b"
|
||||
}
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "DELETE FROM job_result_stream_v2\n WHERE ctid IN (\n SELECT jrs.ctid FROM job_result_stream_v2 jrs\n WHERE NOT EXISTS (SELECT 1 FROM v2_job_queue q WHERE q.id = jrs.job_id)\n AND NOT EXISTS (\n SELECT 1 FROM v2_job_completed c\n WHERE c.id = jrs.job_id\n AND c.completed_at > NOW() - INTERVAL '60 seconds'\n )\n LIMIT 100000\n )",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": []
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "d059b8a3771e4ac4cd07990ecca84daaed616dc1ac609a6ca81bcd446e4dc230"
|
||||
}
|
||||
+35
@@ -0,0 +1,35 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT a.policy::text as policy, a.versions[array_upper(a.versions, 1)] as version, av.raw_app as raw_app\n FROM app a JOIN app_version av ON av.id = a.versions[array_upper(a.versions, 1)]\n WHERE a.path = $1 AND a.workspace_id = $2",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "policy",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "version",
|
||||
"type_info": "Int8"
|
||||
},
|
||||
{
|
||||
"ordinal": 2,
|
||||
"name": "raw_app",
|
||||
"type_info": "Bool"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
null,
|
||||
null,
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "e0a40d2aba02bd6c502d746471c9c14db8fcaaaf8e3c44fb5ea4ed763a1849dd"
|
||||
}
|
||||
+35
@@ -0,0 +1,35 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT name, description, instructions FROM ai_skill WHERE workspace_id = $1 AND name = $2",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "name",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "description",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 2,
|
||||
"name": "instructions",
|
||||
"type_info": "Text"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
false,
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "e50afd5156b07e550202fb9b33354dce71b37f89f68d78577b250979daa1a87d"
|
||||
}
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "DELETE FROM ai_skill WHERE workspace_id = $1 AND name = $2 RETURNING name",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "name",
|
||||
"type_info": "Varchar"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "e99fe5cd3283f1701d3a361ef31869da89fd10099b76669b9526201c85f71f61"
|
||||
}
|
||||
+41
@@ -0,0 +1,41 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT a.path, a.policy::text as policy, a.versions[array_upper(a.versions, 1)] as version, av.raw_app as raw_app\n FROM app a JOIN app_version av ON av.id = a.versions[array_upper(a.versions, 1)]\n WHERE a.id = $1 AND a.workspace_id = $2",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "path",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "policy",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 2,
|
||||
"name": "version",
|
||||
"type_info": "Int8"
|
||||
},
|
||||
{
|
||||
"ordinal": 3,
|
||||
"name": "raw_app",
|
||||
"type_info": "Bool"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Int8",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
null,
|
||||
null,
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "ef9caf3da759ee6059922632cdf1fdf5c006554a70a322ca8d3449cdba7840db"
|
||||
}
|
||||
+3
-2
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT EXISTS (\n SELECT 1 FROM v2_job_completed c JOIN v2_job j USING (id)\n WHERE j.workspace_id = $2\n AND (j.kind = 'appscript' OR j.kind = 'preview')\n AND j.created_by = 'anonymous'\n AND c.started_at > now() - interval '3 hours'\n AND j.runnable_path LIKE $3 || '/%'\n AND c.result @> ('{\"s3\":\"' || $1 || '\"}')::jsonb\n )",
|
||||
"query": "SELECT EXISTS (\n SELECT 1 FROM v2_job_completed c JOIN v2_job j USING (id)\n WHERE j.workspace_id = $2\n AND (j.kind = 'appscript' OR j.kind = 'preview')\n AND j.created_by = $4\n AND c.started_at > now() - interval '3 hours'\n AND j.runnable_path LIKE $3 || '/%'\n AND c.result @> ('{\"s3\":\"' || $1 || '\"}')::jsonb\n )",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
@@ -11,6 +11,7 @@
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"Text",
|
||||
"Text",
|
||||
"Text"
|
||||
@@ -20,5 +21,5 @@
|
||||
null
|
||||
]
|
||||
},
|
||||
"hash": "a72e7fb55d7268fe1ea40015a4a84b12dd961ba732772d8f6c59d18fe05f285d"
|
||||
"hash": "f2760b688a907e7679106aaa2c2063385785f7c2e97364bc04392df11cf364d7"
|
||||
}
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "DELETE FROM v2_job_completed\n WHERE id IN (\n SELECT jc.id FROM v2_job_completed jc\n LEFT JOIN v2_job j ON j.id = jc.id\n WHERE jc.completed_at <= now() - ($1::bigint::text || ' s')::interval\n AND COALESCE(j.root_job, j.flow_innermost_root_job, jc.id) != ALL($3)\n ORDER BY jc.completed_at ASC\n LIMIT $2\n FOR UPDATE OF jc SKIP LOCKED\n )\n RETURNING id",
|
||||
"query": "DELETE FROM v2_job_completed\n WHERE id IN (\n SELECT jc.id FROM v2_job_completed jc\n LEFT JOIN v2_job j ON j.id = jc.id\n WHERE jc.completed_at <= now() - ($1::bigint::text || ' s')::interval\n AND COALESCE(j.root_job, j.flow_innermost_root_job, jc.id) NOT IN (\n SELECT u FROM unnest($3::uuid[]) AS u WHERE u IS NOT NULL\n )\n ORDER BY jc.completed_at ASC\n LIMIT $2\n FOR UPDATE OF jc SKIP LOCKED\n )\n RETURNING id",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
@@ -20,5 +20,5 @@
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "45997fcb4d9d62c7f7011966bf59bdb86e12ce1d0c8e925e738d2645121a5c1f"
|
||||
"hash": "fbe3a876efd1253d2ef086b03366b2bd117ceb6bc152d2abcd45850ff6aecff9"
|
||||
}
|
||||
Generated
+82
-80
@@ -7046,9 +7046,9 @@ checksum = "88904434abc2901f197fe8cc55f0445e7ded921dba5911dad2e2b39b48e663c4"
|
||||
|
||||
[[package]]
|
||||
name = "memmap2"
|
||||
version = "0.9.10"
|
||||
version = "0.9.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "714098028fe011992e1c3962653c96b2d578c4b4bce9036e15ff220319b1e0e3"
|
||||
checksum = "d1219ed1b7f229ee7104d281dd01d6802fe28bb6e95d292942c4daacdeb798c0"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"stable_deref_trait",
|
||||
@@ -13735,7 +13735,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill"
|
||||
version = "1.735.0"
|
||||
version = "1.737.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-nats",
|
||||
@@ -13817,7 +13817,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-ai"
|
||||
version = "1.735.0"
|
||||
version = "1.737.0"
|
||||
dependencies = [
|
||||
"async-stream",
|
||||
"async-trait",
|
||||
@@ -13850,7 +13850,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-alerting"
|
||||
version = "1.735.0"
|
||||
version = "1.737.0"
|
||||
dependencies = [
|
||||
"axum 0.8.9",
|
||||
"chrono",
|
||||
@@ -13863,7 +13863,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-api"
|
||||
version = "1.735.0"
|
||||
version = "1.737.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"argon2",
|
||||
@@ -14001,7 +14001,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-api-agent-workers"
|
||||
version = "1.735.0"
|
||||
version = "1.737.0"
|
||||
dependencies = [
|
||||
"axum 0.8.9",
|
||||
"chrono",
|
||||
@@ -14024,7 +14024,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-api-assets"
|
||||
version = "1.735.0"
|
||||
version = "1.737.0"
|
||||
dependencies = [
|
||||
"axum 0.8.9",
|
||||
"chrono",
|
||||
@@ -14037,7 +14037,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-api-auth"
|
||||
version = "1.735.0"
|
||||
version = "1.737.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"axum 0.8.9",
|
||||
@@ -14063,7 +14063,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-api-client"
|
||||
version = "1.735.0"
|
||||
version = "1.737.0"
|
||||
dependencies = [
|
||||
"reqwest 0.12.28",
|
||||
"serde",
|
||||
@@ -14073,7 +14073,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-api-configs"
|
||||
version = "1.735.0"
|
||||
version = "1.737.0"
|
||||
dependencies = [
|
||||
"axum 0.8.9",
|
||||
"chrono",
|
||||
@@ -14090,7 +14090,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-api-debug"
|
||||
version = "1.735.0"
|
||||
version = "1.737.0"
|
||||
dependencies = [
|
||||
"axum 0.8.9",
|
||||
"base64 0.22.1",
|
||||
@@ -14112,7 +14112,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-api-embeddings"
|
||||
version = "1.735.0"
|
||||
version = "1.737.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"axum 0.8.9",
|
||||
@@ -14135,7 +14135,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-api-flow-conversations"
|
||||
version = "1.735.0"
|
||||
version = "1.737.0"
|
||||
dependencies = [
|
||||
"axum 0.8.9",
|
||||
"chrono",
|
||||
@@ -14151,7 +14151,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-api-flows"
|
||||
version = "1.735.0"
|
||||
version = "1.737.0"
|
||||
dependencies = [
|
||||
"axum 0.8.9",
|
||||
"chrono",
|
||||
@@ -14172,7 +14172,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-api-groups"
|
||||
version = "1.735.0"
|
||||
version = "1.737.0"
|
||||
dependencies = [
|
||||
"axum 0.8.9",
|
||||
"chrono",
|
||||
@@ -14193,7 +14193,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-api-inputs"
|
||||
version = "1.735.0"
|
||||
version = "1.737.0"
|
||||
dependencies = [
|
||||
"axum 0.8.9",
|
||||
"chrono",
|
||||
@@ -14207,7 +14207,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-api-integration-tests"
|
||||
version = "1.735.0"
|
||||
version = "1.737.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-nats",
|
||||
@@ -14242,7 +14242,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-api-jobs"
|
||||
version = "1.735.0"
|
||||
version = "1.737.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"axum 0.8.9",
|
||||
@@ -14267,7 +14267,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-api-npm-proxy"
|
||||
version = "1.735.0"
|
||||
version = "1.737.0"
|
||||
dependencies = [
|
||||
"axum 0.8.9",
|
||||
"flate2",
|
||||
@@ -14285,7 +14285,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-api-openapi"
|
||||
version = "1.735.0"
|
||||
version = "1.737.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"axum 0.8.9",
|
||||
@@ -14307,7 +14307,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-api-schedule"
|
||||
version = "1.735.0"
|
||||
version = "1.737.0"
|
||||
dependencies = [
|
||||
"axum 0.8.9",
|
||||
"chrono",
|
||||
@@ -14327,7 +14327,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-api-scripts"
|
||||
version = "1.735.0"
|
||||
version = "1.737.0"
|
||||
dependencies = [
|
||||
"axum 0.8.9",
|
||||
"chrono",
|
||||
@@ -14364,7 +14364,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-api-settings"
|
||||
version = "1.735.0"
|
||||
version = "1.737.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"axum 0.8.9",
|
||||
@@ -14392,7 +14392,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-api-sse"
|
||||
version = "1.735.0"
|
||||
version = "1.737.0"
|
||||
dependencies = [
|
||||
"lazy_static",
|
||||
"serde",
|
||||
@@ -14404,7 +14404,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-api-users"
|
||||
version = "1.735.0"
|
||||
version = "1.737.0"
|
||||
dependencies = [
|
||||
"argon2",
|
||||
"axum 0.8.9",
|
||||
@@ -14429,7 +14429,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-api-workers"
|
||||
version = "1.735.0"
|
||||
version = "1.737.0"
|
||||
dependencies = [
|
||||
"axum 0.8.9",
|
||||
"chrono",
|
||||
@@ -14443,7 +14443,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-api-workspaces"
|
||||
version = "1.735.0"
|
||||
version = "1.737.0"
|
||||
dependencies = [
|
||||
"axum 0.8.9",
|
||||
"chrono",
|
||||
@@ -14476,7 +14476,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-audit"
|
||||
version = "1.735.0"
|
||||
version = "1.737.0"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"lazy_static",
|
||||
@@ -14490,7 +14490,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-autoscaling"
|
||||
version = "1.735.0"
|
||||
version = "1.737.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"axum 0.8.9",
|
||||
@@ -14509,7 +14509,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-common"
|
||||
version = "1.735.0"
|
||||
version = "1.737.0"
|
||||
dependencies = [
|
||||
"aes-gcm",
|
||||
"aho-corasick",
|
||||
@@ -14611,7 +14611,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-dep-map"
|
||||
version = "1.735.0"
|
||||
version = "1.737.0"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"itertools 0.14.0",
|
||||
@@ -14630,7 +14630,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-git-sync"
|
||||
version = "1.735.0"
|
||||
version = "1.737.0"
|
||||
dependencies = [
|
||||
"regex",
|
||||
"serde",
|
||||
@@ -14645,7 +14645,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-indexer"
|
||||
version = "1.735.0"
|
||||
version = "1.737.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"astral-tokio-tar",
|
||||
@@ -14669,7 +14669,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-jseval"
|
||||
version = "1.735.0"
|
||||
version = "1.737.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"futures",
|
||||
@@ -14686,7 +14686,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-macros"
|
||||
version = "1.735.0"
|
||||
version = "1.737.0"
|
||||
dependencies = [
|
||||
"itertools 0.14.0",
|
||||
"lazy_static",
|
||||
@@ -14702,7 +14702,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-mcp"
|
||||
version = "1.735.0"
|
||||
version = "1.737.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
@@ -14723,7 +14723,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-native-triggers"
|
||||
version = "1.735.0"
|
||||
version = "1.737.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
@@ -14754,7 +14754,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-oauth"
|
||||
version = "1.735.0"
|
||||
version = "1.737.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"arc-swap",
|
||||
@@ -14779,7 +14779,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-object-store"
|
||||
version = "1.735.0"
|
||||
version = "1.737.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-stream",
|
||||
@@ -14813,7 +14813,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-operator"
|
||||
version = "1.735.0"
|
||||
version = "1.737.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"futures",
|
||||
@@ -14831,7 +14831,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser"
|
||||
version = "1.735.0"
|
||||
version = "1.737.0"
|
||||
dependencies = [
|
||||
"convert_case 0.6.0",
|
||||
"serde",
|
||||
@@ -14840,7 +14840,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-bash"
|
||||
version = "1.735.0"
|
||||
version = "1.737.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"lazy_static",
|
||||
@@ -14852,7 +14852,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-csharp"
|
||||
version = "1.735.0"
|
||||
version = "1.737.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"serde_json",
|
||||
@@ -14864,7 +14864,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-go"
|
||||
version = "1.735.0"
|
||||
version = "1.737.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"gosyn",
|
||||
@@ -14876,7 +14876,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-graphql"
|
||||
version = "1.735.0"
|
||||
version = "1.737.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"lazy_static",
|
||||
@@ -14888,7 +14888,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-java"
|
||||
version = "1.735.0"
|
||||
version = "1.737.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"serde_json",
|
||||
@@ -14900,7 +14900,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-nu"
|
||||
version = "1.735.0"
|
||||
version = "1.737.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"nu-parser",
|
||||
@@ -14911,7 +14911,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-php"
|
||||
version = "1.735.0"
|
||||
version = "1.737.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"itertools 0.14.0",
|
||||
@@ -14922,7 +14922,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-py"
|
||||
version = "1.735.0"
|
||||
version = "1.737.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"itertools 0.14.0",
|
||||
@@ -14934,7 +14934,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-py-asset"
|
||||
version = "1.735.0"
|
||||
version = "1.737.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"rustpython-ast",
|
||||
@@ -14945,7 +14945,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-py-imports"
|
||||
version = "1.735.0"
|
||||
version = "1.737.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-recursion",
|
||||
@@ -14967,7 +14967,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-r"
|
||||
version = "1.735.0"
|
||||
version = "1.737.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"serde_json",
|
||||
@@ -14979,7 +14979,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-ruby"
|
||||
version = "1.735.0"
|
||||
version = "1.737.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"lazy_static",
|
||||
@@ -14993,7 +14993,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-rust"
|
||||
version = "1.735.0"
|
||||
version = "1.737.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"convert_case 0.6.0",
|
||||
@@ -15010,7 +15010,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-sql"
|
||||
version = "1.735.0"
|
||||
version = "1.737.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"lazy_static",
|
||||
@@ -15023,7 +15023,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-sql-asset"
|
||||
version = "1.735.0"
|
||||
version = "1.737.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"serde",
|
||||
@@ -15035,7 +15035,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-ts"
|
||||
version = "1.735.0"
|
||||
version = "1.737.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"lazy_static",
|
||||
@@ -15053,7 +15053,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-ts-asset"
|
||||
version = "1.735.0"
|
||||
version = "1.737.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"serde-wasm-bindgen",
|
||||
@@ -15069,7 +15069,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-wac"
|
||||
version = "1.735.0"
|
||||
version = "1.737.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"rustpython-ast",
|
||||
@@ -15085,7 +15085,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-yaml"
|
||||
version = "1.735.0"
|
||||
version = "1.737.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"serde",
|
||||
@@ -15096,7 +15096,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-queue"
|
||||
version = "1.735.0"
|
||||
version = "1.737.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-recursion",
|
||||
@@ -15134,7 +15134,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-runtime-nativets"
|
||||
version = "1.735.0"
|
||||
version = "1.737.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"const_format",
|
||||
@@ -15173,7 +15173,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-sql-datatype-parser-wasm"
|
||||
version = "1.735.0"
|
||||
version = "1.737.0"
|
||||
dependencies = [
|
||||
"getrandom 0.3.4",
|
||||
"wasm-bindgen",
|
||||
@@ -15184,17 +15184,19 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-store"
|
||||
version = "1.735.0"
|
||||
version = "1.737.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-recursion",
|
||||
"axum 0.8.9",
|
||||
"base64 0.22.1",
|
||||
"chrono",
|
||||
"futures",
|
||||
"hex",
|
||||
"http 1.4.2",
|
||||
"hyper 1.10.1",
|
||||
"lazy_static",
|
||||
"magic-crypt",
|
||||
"quick_cache",
|
||||
"reqwest 0.13.1",
|
||||
"serde",
|
||||
@@ -15216,7 +15218,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-test-utils"
|
||||
version = "1.735.0"
|
||||
version = "1.737.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
@@ -15240,7 +15242,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-trigger"
|
||||
version = "1.735.0"
|
||||
version = "1.737.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
@@ -15273,7 +15275,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-trigger-azure"
|
||||
version = "1.735.0"
|
||||
version = "1.737.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
@@ -15306,7 +15308,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-trigger-email"
|
||||
version = "1.735.0"
|
||||
version = "1.737.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
@@ -15326,7 +15328,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-trigger-gcp"
|
||||
version = "1.735.0"
|
||||
version = "1.737.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
@@ -15360,7 +15362,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-trigger-http"
|
||||
version = "1.735.0"
|
||||
version = "1.737.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
@@ -15396,7 +15398,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-trigger-kafka"
|
||||
version = "1.735.0"
|
||||
version = "1.737.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
@@ -15419,7 +15421,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-trigger-mqtt"
|
||||
version = "1.735.0"
|
||||
version = "1.737.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
@@ -15443,7 +15445,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-trigger-nats"
|
||||
version = "1.735.0"
|
||||
version = "1.737.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-nats",
|
||||
@@ -15467,7 +15469,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-trigger-postgres"
|
||||
version = "1.735.0"
|
||||
version = "1.737.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
@@ -15502,7 +15504,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-trigger-sqs"
|
||||
version = "1.735.0"
|
||||
version = "1.737.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
@@ -15530,7 +15532,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-trigger-websocket"
|
||||
version = "1.735.0"
|
||||
version = "1.737.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
@@ -15555,7 +15557,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-types"
|
||||
version = "1.735.0"
|
||||
version = "1.737.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"bitflags 2.13.0",
|
||||
@@ -15574,7 +15576,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-worker"
|
||||
version = "1.735.0"
|
||||
version = "1.737.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-once-cell",
|
||||
@@ -15684,7 +15686,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-worker-volumes"
|
||||
version = "1.735.0"
|
||||
version = "1.737.0"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"futures",
|
||||
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "windmill"
|
||||
version = "1.735.0"
|
||||
version = "1.737.0"
|
||||
authors.workspace = true
|
||||
edition.workspace = true
|
||||
|
||||
@@ -87,7 +87,7 @@ members = [
|
||||
exclude = ["./windmill-duckdb-ffi-internal", "./parsers/windmill-parser-wasm"]
|
||||
|
||||
[workspace.package]
|
||||
version = "1.735.0"
|
||||
version = "1.737.0"
|
||||
authors = ["Ruben Fiszel <ruben@windmill.dev>"]
|
||||
edition = "2021"
|
||||
|
||||
|
||||
@@ -1 +1 @@
|
||||
de49fda2320504ad9e7d2d31c7033d71dbf6ca43
|
||||
ac1f6f666f36141cb6ba6f8eaa614821a90464ad
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
DROP TABLE IF EXISTS ai_skill;
|
||||
@@ -0,0 +1,15 @@
|
||||
-- Workspace-scoped AI chat skills (Claude/Codex-style SKILL.md instructions).
|
||||
-- `name` is the skill folder slug; `description` is advertised in the AI chat
|
||||
-- system prompt, `instructions` is the SKILL.md body fetched on demand.
|
||||
CREATE TABLE ai_skill (
|
||||
workspace_id VARCHAR(50) NOT NULL REFERENCES workspace(id) ON DELETE CASCADE,
|
||||
name VARCHAR(255) NOT NULL,
|
||||
description TEXT NOT NULL,
|
||||
instructions TEXT NOT NULL,
|
||||
edited_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
edited_by VARCHAR(255) NOT NULL DEFAULT '',
|
||||
PRIMARY KEY (workspace_id, name)
|
||||
);
|
||||
|
||||
GRANT ALL ON ai_skill TO windmill_user;
|
||||
GRANT ALL ON ai_skill TO windmill_admin;
|
||||
+24
-24
@@ -6191,7 +6191,7 @@ checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
|
||||
|
||||
[[package]]
|
||||
name = "windmill-common"
|
||||
version = "1.735.0"
|
||||
version = "1.737.0"
|
||||
dependencies = [
|
||||
"aho-corasick",
|
||||
"anyhow",
|
||||
@@ -6272,7 +6272,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-macros"
|
||||
version = "1.735.0"
|
||||
version = "1.737.0"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
@@ -6284,7 +6284,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser"
|
||||
version = "1.735.0"
|
||||
version = "1.737.0"
|
||||
dependencies = [
|
||||
"convert_case",
|
||||
"serde",
|
||||
@@ -6293,7 +6293,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-bash"
|
||||
version = "1.735.0"
|
||||
version = "1.737.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"lazy_static",
|
||||
@@ -6305,7 +6305,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-csharp"
|
||||
version = "1.735.0"
|
||||
version = "1.737.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"serde_json",
|
||||
@@ -6317,7 +6317,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-go"
|
||||
version = "1.735.0"
|
||||
version = "1.737.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"gosyn",
|
||||
@@ -6329,7 +6329,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-graphql"
|
||||
version = "1.735.0"
|
||||
version = "1.737.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"lazy_static",
|
||||
@@ -6341,7 +6341,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-java"
|
||||
version = "1.735.0"
|
||||
version = "1.737.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"serde_json",
|
||||
@@ -6353,7 +6353,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-nu"
|
||||
version = "1.735.0"
|
||||
version = "1.737.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"nu-parser",
|
||||
@@ -6364,7 +6364,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-php"
|
||||
version = "1.735.0"
|
||||
version = "1.737.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"itertools 0.14.0",
|
||||
@@ -6375,7 +6375,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-py"
|
||||
version = "1.735.0"
|
||||
version = "1.737.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"itertools 0.14.0",
|
||||
@@ -6387,7 +6387,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-py-asset"
|
||||
version = "1.735.0"
|
||||
version = "1.737.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"rustpython-ast",
|
||||
@@ -6398,7 +6398,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-py-imports"
|
||||
version = "1.735.0"
|
||||
version = "1.737.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-recursion",
|
||||
@@ -6420,7 +6420,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-r"
|
||||
version = "1.735.0"
|
||||
version = "1.737.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"serde_json",
|
||||
@@ -6432,7 +6432,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-ruby"
|
||||
version = "1.735.0"
|
||||
version = "1.737.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"lazy_static",
|
||||
@@ -6446,7 +6446,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-rust"
|
||||
version = "1.735.0"
|
||||
version = "1.737.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"convert_case",
|
||||
@@ -6463,7 +6463,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-sql"
|
||||
version = "1.735.0"
|
||||
version = "1.737.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"lazy_static",
|
||||
@@ -6476,7 +6476,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-sql-asset"
|
||||
version = "1.735.0"
|
||||
version = "1.737.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"serde",
|
||||
@@ -6488,7 +6488,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-ts"
|
||||
version = "1.735.0"
|
||||
version = "1.737.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"lazy_static",
|
||||
@@ -6506,7 +6506,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-ts-asset"
|
||||
version = "1.735.0"
|
||||
version = "1.737.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"serde-wasm-bindgen",
|
||||
@@ -6522,7 +6522,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-wac"
|
||||
version = "1.735.0"
|
||||
version = "1.737.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"rustpython-ast",
|
||||
@@ -6538,7 +6538,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-wasm"
|
||||
version = "1.735.0"
|
||||
version = "1.737.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"getrandom 0.2.17",
|
||||
@@ -6570,7 +6570,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-yaml"
|
||||
version = "1.735.0"
|
||||
version = "1.737.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"serde",
|
||||
@@ -6581,7 +6581,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-types"
|
||||
version = "1.735.0"
|
||||
version = "1.737.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"bitflags",
|
||||
|
||||
@@ -12,7 +12,7 @@ resolver = "2"
|
||||
members = ["."]
|
||||
|
||||
[workspace.package]
|
||||
version = "1.735.0"
|
||||
version = "1.737.0"
|
||||
edition = "2021"
|
||||
authors = ["Ruben Fiszel <ruben@windmill.dev>"]
|
||||
|
||||
|
||||
+38
-12
@@ -1446,19 +1446,45 @@ Windmill Community Edition {GIT_VERSION}
|
||||
} else {
|
||||
None
|
||||
};
|
||||
monitor_db(
|
||||
&conn,
|
||||
&base_internal_url,
|
||||
server_mode,
|
||||
worker_mode,
|
||||
false,
|
||||
tx.clone(),
|
||||
Some(MonitorIteration {
|
||||
rd_shift,
|
||||
iter: monitor_iteration,
|
||||
}),
|
||||
// Hard cap on a single monitor pass. monitor_db runs all its
|
||||
// periodic tasks under one join!, so a single task stuck on a
|
||||
// non-DB await (statement_timeout only bounds DB statements)
|
||||
// would otherwise freeze the whole loop indefinitely — silently
|
||||
// stopping critical maintenance like audit-partition creation.
|
||||
// Larger than statement_timeout (5min) so a slow-but-progressing
|
||||
// statement is never killed prematurely.
|
||||
const MONITOR_DB_TIMEOUT: Duration = Duration::from_secs(600);
|
||||
let monitor_timed_out = tokio::time::timeout(
|
||||
MONITOR_DB_TIMEOUT,
|
||||
monitor_db(
|
||||
&conn,
|
||||
&base_internal_url,
|
||||
server_mode,
|
||||
worker_mode,
|
||||
false,
|
||||
tx.clone(),
|
||||
Some(MonitorIteration {
|
||||
rd_shift,
|
||||
iter: monitor_iteration,
|
||||
}),
|
||||
),
|
||||
)
|
||||
.await;
|
||||
.await
|
||||
.is_err();
|
||||
if monitor_timed_out {
|
||||
windmill_common::utils::report_critical_error(
|
||||
format!(
|
||||
"monitor task did not finish within {}s and was aborted; \
|
||||
a background maintenance task is likely stuck. \
|
||||
Continuing to the next iteration.",
|
||||
MONITOR_DB_TIMEOUT.as_secs()
|
||||
),
|
||||
db.clone(),
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
monitor_iteration += 1;
|
||||
if let Some(handle) = warn_handle {
|
||||
handle.abort();
|
||||
|
||||
+124
-51
@@ -1532,14 +1532,22 @@ async fn delete_expired_jobs_batch(
|
||||
.await?;
|
||||
|
||||
// Use FOR UPDATE SKIP LOCKED to avoid contention between replicas
|
||||
// ORDER BY completed_at ensures we delete oldest jobs first
|
||||
// ORDER BY completed_at ensures we delete oldest jobs first.
|
||||
// Active-root exclusion uses `NOT IN (SELECT ... unnest($3))` rather than
|
||||
// `!= ALL($3)`: the subquery form lets the planner build a one-time hashed
|
||||
// SubPlan and apply it as a filter on the ordered index scan, giving O(1)
|
||||
// membership per candidate instead of a per-row linear array scan (which
|
||||
// degrades sharply when many root jobs are active). The `u IS NOT NULL` guard
|
||||
// sidesteps NOT IN's null-trap semantics ($3 holds non-null PK ids).
|
||||
let deleted_jobs: Vec<Uuid> = sqlx::query_scalar!(
|
||||
"DELETE FROM v2_job_completed
|
||||
WHERE id IN (
|
||||
SELECT jc.id FROM v2_job_completed jc
|
||||
LEFT JOIN v2_job j ON j.id = jc.id
|
||||
WHERE jc.completed_at <= now() - ($1::bigint::text || ' s')::interval
|
||||
AND COALESCE(j.root_job, j.flow_innermost_root_job, jc.id) != ALL($3)
|
||||
AND COALESCE(j.root_job, j.flow_innermost_root_job, jc.id) NOT IN (
|
||||
SELECT u FROM unnest($3::uuid[]) AS u WHERE u IS NOT NULL
|
||||
)
|
||||
ORDER BY jc.completed_at ASC
|
||||
LIMIT $2
|
||||
FOR UPDATE OF jc SKIP LOCKED
|
||||
@@ -4368,39 +4376,65 @@ RETURNING key,job_id
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn cleanup_job_perms_orphaned(db: &DB) -> error::Result<()> {
|
||||
let result = sqlx::query_scalar!(
|
||||
"DELETE FROM job_perms
|
||||
WHERE job_id NOT IN (SELECT id FROM v2_job_queue)
|
||||
RETURNING job_id"
|
||||
)
|
||||
.fetch_all(db)
|
||||
.await?;
|
||||
// Per-statement cap keeps each delete short and lock-light; the per-cycle batch
|
||||
// cap bounds total work per monitor iteration so monitor_db stays responsive.
|
||||
// A large backlog drains across several iterations rather than one long delete.
|
||||
const ORPHAN_CLEANUP_BATCH_SIZE: u64 = 100_000;
|
||||
const ORPHAN_CLEANUP_MAX_BATCHES: usize = 10;
|
||||
|
||||
if !result.is_empty() {
|
||||
tracing::info!("Cleaned up {} orphaned job_perms rows", result.len());
|
||||
async fn cleanup_job_perms_orphaned(db: &DB) -> error::Result<()> {
|
||||
let mut total: u64 = 0;
|
||||
for _ in 0..ORPHAN_CLEANUP_MAX_BATCHES {
|
||||
let count = sqlx::query!(
|
||||
"DELETE FROM job_perms
|
||||
WHERE ctid IN (
|
||||
SELECT jp.ctid FROM job_perms jp
|
||||
WHERE NOT EXISTS (SELECT 1 FROM v2_job_queue q WHERE q.id = jp.job_id)
|
||||
LIMIT 100000
|
||||
)"
|
||||
)
|
||||
.execute(db)
|
||||
.await?
|
||||
.rows_affected();
|
||||
total += count;
|
||||
if count < ORPHAN_CLEANUP_BATCH_SIZE {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if total > 0 {
|
||||
tracing::info!("Cleaned up {total} orphaned job_perms rows");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn cleanup_job_result_stream_orphaned_jobs(db: &DB) -> error::Result<()> {
|
||||
let result = sqlx::query!(
|
||||
"DELETE FROM job_result_stream_v2
|
||||
WHERE job_id NOT IN (SELECT id FROM v2_job_queue)
|
||||
AND job_id NOT IN (
|
||||
SELECT id FROM v2_job_completed
|
||||
WHERE completed_at > NOW() - INTERVAL '60 seconds'
|
||||
)
|
||||
RETURNING job_id",
|
||||
)
|
||||
.fetch_all(db)
|
||||
.await?;
|
||||
let mut total: u64 = 0;
|
||||
for _ in 0..ORPHAN_CLEANUP_MAX_BATCHES {
|
||||
let count = sqlx::query!(
|
||||
"DELETE FROM job_result_stream_v2
|
||||
WHERE ctid IN (
|
||||
SELECT jrs.ctid FROM job_result_stream_v2 jrs
|
||||
WHERE NOT EXISTS (SELECT 1 FROM v2_job_queue q WHERE q.id = jrs.job_id)
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM v2_job_completed c
|
||||
WHERE c.id = jrs.job_id
|
||||
AND c.completed_at > NOW() - INTERVAL '60 seconds'
|
||||
)
|
||||
LIMIT 100000
|
||||
)",
|
||||
)
|
||||
.execute(db)
|
||||
.await?
|
||||
.rows_affected();
|
||||
total += count;
|
||||
if count < ORPHAN_CLEANUP_BATCH_SIZE {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if result.len() > 0 {
|
||||
tracing::info!(
|
||||
"Cleaned up {} orphaned job_result_stream_v2 rows",
|
||||
result.len()
|
||||
);
|
||||
if total > 0 {
|
||||
tracing::info!("Cleaned up {total} orphaned job_result_stream_v2 rows");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -4436,11 +4470,19 @@ async fn audit_log_retention_days() -> i64 {
|
||||
}
|
||||
}
|
||||
|
||||
/// Number of days ahead (including today) for which an audit partition must
|
||||
/// always exist. A missing partition in this window means audit inserts fail
|
||||
/// once that date is reached — and because some callers (notably login) write
|
||||
/// the audit row in the same transaction as their own work, that failure
|
||||
/// poisons the whole transaction, so a missing partition is a hard outage, not
|
||||
/// just a dropped audit row.
|
||||
const AUDIT_PARTITION_LOOKAHEAD_DAYS: i64 = 3;
|
||||
|
||||
async fn manage_audit_partitions(db: &DB, retention_days: i64) {
|
||||
let today = chrono::Utc::now().date_naive();
|
||||
|
||||
// Create partitions for today and the next 3 days
|
||||
for days_ahead in 0..=3i64 {
|
||||
// Create partitions for today and the next few days
|
||||
for days_ahead in 0..=AUDIT_PARTITION_LOOKAHEAD_DAYS {
|
||||
let date = today + chrono::Duration::days(days_ahead);
|
||||
let next_date = date + chrono::Duration::days(1);
|
||||
let partition_name = format!("audit_{}", date.format("%Y%m%d"));
|
||||
@@ -4456,9 +4498,6 @@ async fn manage_audit_partitions(db: &DB, retention_days: i64) {
|
||||
}
|
||||
}
|
||||
|
||||
// Drop expired partitions
|
||||
let cutoff_date = today - chrono::Duration::days(retention_days);
|
||||
|
||||
let partitions = sqlx::query_scalar::<_, String>(
|
||||
"SELECT c.relname::text \
|
||||
FROM pg_inherits i \
|
||||
@@ -4468,28 +4507,62 @@ async fn manage_audit_partitions(db: &DB, retention_days: i64) {
|
||||
.fetch_all(db)
|
||||
.await;
|
||||
|
||||
match partitions {
|
||||
Ok(partitions) => {
|
||||
for partition_name in partitions {
|
||||
if let Some(date_str) = partition_name.strip_prefix("audit_") {
|
||||
if let Ok(date) = chrono::NaiveDate::parse_from_str(date_str, "%Y%m%d") {
|
||||
if date < cutoff_date {
|
||||
let quoted_name =
|
||||
format!("\"{}\"", partition_name.replace('"', "\"\""));
|
||||
let sql = format!("DROP TABLE IF EXISTS {quoted_name}");
|
||||
match sqlx::query(&sql).execute(db).await {
|
||||
Ok(_) => tracing::info!(
|
||||
"Dropped expired audit partition {partition_name}"
|
||||
),
|
||||
Err(e) => tracing::error!(
|
||||
"Error dropping audit partition {partition_name}: {e:?}"
|
||||
),
|
||||
}
|
||||
let partitions = match partitions {
|
||||
Ok(partitions) => partitions,
|
||||
Err(e) => {
|
||||
tracing::error!("Error listing audit partitions: {e:?}");
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
// Verify the lookahead window is actually covered. If a create above failed
|
||||
// (or this loop has not run for several days), alert loudly instead of
|
||||
// letting it surface days later as failed audit inserts and broken logins.
|
||||
let existing: std::collections::HashSet<&str> = partitions.iter().map(|s| s.as_str()).collect();
|
||||
let missing: Vec<String> = (0..=AUDIT_PARTITION_LOOKAHEAD_DAYS)
|
||||
.map(|days_ahead| {
|
||||
format!(
|
||||
"audit_{}",
|
||||
(today + chrono::Duration::days(days_ahead)).format("%Y%m%d")
|
||||
)
|
||||
})
|
||||
.filter(|name| !existing.contains(name.as_str()))
|
||||
.collect();
|
||||
if !missing.is_empty() {
|
||||
report_critical_error(
|
||||
format!(
|
||||
"Audit log partitions missing after maintenance run: {}. \
|
||||
Audit inserts will fail once these dates are reached, which also \
|
||||
breaks logins (the login audit row shares the login transaction). \
|
||||
Check for earlier 'Error creating audit partition' logs and verify \
|
||||
the audit-partition maintenance loop is still running.",
|
||||
missing.join(", ")
|
||||
),
|
||||
db.clone(),
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
// Drop expired partitions
|
||||
let cutoff_date = today - chrono::Duration::days(retention_days);
|
||||
for partition_name in &partitions {
|
||||
if let Some(date_str) = partition_name.strip_prefix("audit_") {
|
||||
if let Ok(date) = chrono::NaiveDate::parse_from_str(date_str, "%Y%m%d") {
|
||||
if date < cutoff_date {
|
||||
let quoted_name = format!("\"{}\"", partition_name.replace('"', "\"\""));
|
||||
let sql = format!("DROP TABLE IF EXISTS {quoted_name}");
|
||||
match sqlx::query(&sql).execute(db).await {
|
||||
Ok(_) => {
|
||||
tracing::info!("Dropped expired audit partition {partition_name}")
|
||||
}
|
||||
Err(e) => tracing::error!(
|
||||
"Error dropping audit partition {partition_name}: {e:?}"
|
||||
),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => tracing::error!("Error listing audit partitions: {e:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
+39
@@ -19,6 +19,45 @@ INSERT INTO token(token_hash, token_prefix, token, email, label, super_admin, sc
|
||||
ARRAY['jobs:read', 'if_jobs:filter_tags:deno']
|
||||
);
|
||||
|
||||
-- App embed token for the admin viewer (test-user). Mirrors a minted sandboxed
|
||||
-- low-code app token: carries the `app_embed` sentinel plus the embed scope set.
|
||||
-- Used to assert the token is confined to jobs the viewer LAUNCHED, not every job
|
||||
-- the (admin) viewer could otherwise read.
|
||||
INSERT INTO token(token_hash, token_prefix, token, email, label, super_admin, scopes) VALUES (
|
||||
encode(sha256('EMBED_APP_TOKEN'::bytea), 'hex'), 'EMBED_APP_', 'EMBED_APP_TOKEN',
|
||||
'test@windmill.dev', 'app embed token', false,
|
||||
ARRAY['apps:run', 'jobs:read', 'app_embed', 'resources:run', 'users:read', 'folders:read']
|
||||
);
|
||||
|
||||
-- A completed app-component job LAUNCHED BY the admin viewer (created_by =
|
||||
-- test-user), running as the app owner. The embed token must keep reading its own
|
||||
-- launched job (the `created_by == viewer` fast path).
|
||||
INSERT INTO public.v2_job (
|
||||
id, workspace_id, created_by, created_at, permissioned_as, permissioned_as_email,
|
||||
kind, script_lang, runnable_path, tag, visible_to_owner, args
|
||||
) VALUES (
|
||||
'12121212-1212-1212-1212-121212121212', 'test-workspace', 'test-user',
|
||||
'2023-01-01 00:00:00', 'u/test-user-2', 'test2@windmill.dev',
|
||||
'script', 'deno', 'u/test-user-2/app_component', 'deno', false,
|
||||
'{"own": "arg"}'
|
||||
);
|
||||
INSERT INTO public.v2_job_completed (id, workspace_id, duration_ms, status, result) VALUES
|
||||
('12121212-1212-1212-1212-121212121212', 'test-workspace', 1000, 'success'::job_status,
|
||||
'{"own": "EMBED_OWN_RESULT"}');
|
||||
|
||||
-- A QUEUED job launched by the admin embed viewer (created_by = test-user). The
|
||||
-- embed token may cancel its own launched job; it must NOT cancel another user's.
|
||||
INSERT INTO public.v2_job (
|
||||
id, workspace_id, created_by, created_at, permissioned_as, permissioned_as_email,
|
||||
kind, script_lang, runnable_path, tag, visible_to_owner
|
||||
) VALUES (
|
||||
'13131313-1313-1313-1313-131313131313', 'test-workspace', 'test-user',
|
||||
'2023-01-01 00:00:00', 'u/test-user-2', 'test2@windmill.dev',
|
||||
'script', 'deno', 'u/test-user-2/app_component', 'deno', false
|
||||
);
|
||||
INSERT INTO public.v2_job_queue (id, workspace_id, scheduled_for, running, tag) VALUES
|
||||
('13131313-1313-1313-1313-131313131313', 'test-workspace', '2023-01-01 00:00:00', false, 'deno');
|
||||
|
||||
-- RUNNING job: queued (no completed row) and owned by test-user-2. Used to check
|
||||
-- that `completed/get_result_maybe?get_started=true` authorizes before disclosing
|
||||
-- running-state to a non-reader.
|
||||
|
||||
@@ -38,6 +38,10 @@ const TOP_SECRET_FLOW: &str = "ffffffff-ffff-ffff-ffff-ffffffffffff";
|
||||
const DEEP_LEAF_JOB: &str = "88888888-8888-8888-8888-888888888888";
|
||||
// A queued/running job (no completed row) owned by test-user-2.
|
||||
const RUNNING_JOB: &str = "77777777-7777-7777-7777-777777777777";
|
||||
// An app-component job launched BY the admin embed viewer (created_by test-user).
|
||||
const EMBED_OWN_JOB: &str = "12121212-1212-1212-1212-121212121212";
|
||||
// A QUEUED job launched by the embed viewer (created_by test-user) — cancelable by it.
|
||||
const EMBED_OWN_QUEUED: &str = "13131313-1313-1313-1313-131313131313";
|
||||
|
||||
// Secrets that must never leak to an unauthorized viewer.
|
||||
const RESULT_SECRET: &str = "RESULT_SECRET";
|
||||
@@ -59,6 +63,19 @@ async fn get(base: &str, path: &str, token: Option<&str>) -> (reqwest::StatusCod
|
||||
(status, body)
|
||||
}
|
||||
|
||||
async fn post(base: &str, path: &str, token: Option<&str>) -> (reqwest::StatusCode, String) {
|
||||
let mut req = client()
|
||||
.post(format!("{base}/{path}"))
|
||||
.json(&serde_json::json!({}));
|
||||
if let Some(token) = token {
|
||||
req = req.header("Authorization", format!("Bearer {token}"));
|
||||
}
|
||||
let resp = req.send().await.expect("request");
|
||||
let status = resp.status();
|
||||
let body = resp.text().await.expect("body");
|
||||
(status, body)
|
||||
}
|
||||
|
||||
#[sqlx::test(fixtures("base", "jobs_read_auth"))]
|
||||
async fn test_single_job_read_authorization(db: Pool<Postgres>) -> anyhow::Result<()> {
|
||||
initialize_tracing().await;
|
||||
@@ -281,6 +298,75 @@ async fn test_single_job_read_authorization(db: Pool<Postgres>) -> anyhow::Resul
|
||||
"top flow in an unreadable folder must stay denied (got {status}): {body}"
|
||||
);
|
||||
|
||||
// ---- APP EMBED TOKEN: confined to jobs the viewer LAUNCHED, not everything
|
||||
// the (admin) viewer can otherwise read. The token carries the `app_embed`
|
||||
// sentinel; an admin's normal token reads VICTIM (asserted above), but the
|
||||
// embed token must stop at the `created_by == viewer` grant so user-authored
|
||||
// app JS can't reuse it to read unrelated jobs by UUID.
|
||||
// Its own launched component job (created_by == viewer) still reads.
|
||||
let (status, body) = get(
|
||||
&base,
|
||||
&format!("completed/get_result/{EMBED_OWN_JOB}"),
|
||||
Some("EMBED_APP_TOKEN"),
|
||||
)
|
||||
.await;
|
||||
assert!(
|
||||
status.is_success(),
|
||||
"embed token must read a job it launched (got {status}): {body}"
|
||||
);
|
||||
assert!(
|
||||
body.contains("EMBED_OWN_RESULT"),
|
||||
"embed token should get its own launched job result: {body}"
|
||||
);
|
||||
// The VICTIM job — created by another user but readable by this admin viewer's
|
||||
// normal token (asserted above) — is denied to the embed token across result /
|
||||
// logs / live update. NotFound (not 403) so the untrusted app can't even probe
|
||||
// existence, and no secret leaks.
|
||||
for path in [
|
||||
format!("completed/get_result/{VICTIM}"),
|
||||
format!("get_logs/{VICTIM}"),
|
||||
format!("getupdate/{VICTIM}?only_result=true"),
|
||||
] {
|
||||
let (status, body) = get(&base, &path, Some("EMBED_APP_TOKEN")).await;
|
||||
assert_eq!(
|
||||
status,
|
||||
reqwest::StatusCode::NOT_FOUND,
|
||||
"embed token must not read a job it did not launch ({path}, got {status}): {body}"
|
||||
);
|
||||
for secret in [RESULT_SECRET, ARGS_SECRET, LOGS_SECRET] {
|
||||
assert!(
|
||||
!body.contains(secret),
|
||||
"embed token response for {path} leaked `{secret}`: {body}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ---- APP EMBED TOKEN: cancellation confined to the app's own jobs. The token
|
||||
// may cancel a job it launched (created_by == viewer), but `cancel_job_api`
|
||||
// denies (NotFound) a job created by someone else, even though cancel
|
||||
// otherwise has no per-job ownership check.
|
||||
let (status, body) = post(
|
||||
&base,
|
||||
&format!("queue/cancel/{EMBED_OWN_QUEUED}"),
|
||||
Some("EMBED_APP_TOKEN"),
|
||||
)
|
||||
.await;
|
||||
assert!(
|
||||
status.is_success(),
|
||||
"embed token must cancel a job it launched (got {status}): {body}"
|
||||
);
|
||||
let (status, body) = post(
|
||||
&base,
|
||||
&format!("queue/cancel/{RUNNING_JOB}"),
|
||||
Some("EMBED_APP_TOKEN"),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(
|
||||
status,
|
||||
reqwest::StatusCode::NOT_FOUND,
|
||||
"embed token must not cancel another user's job (got {status}): {body}"
|
||||
);
|
||||
|
||||
// ---- UNAUTHENTICATED, unchanged: an anonymous-created job is readable
|
||||
// without a token (public trigger / public app result polling).
|
||||
let (status, body) = get(&base, &format!("completed/get_result/{ANON_JOB}"), None).await;
|
||||
|
||||
@@ -191,7 +191,11 @@ impl AuthCache {
|
||||
is_operator: claims.is_operator,
|
||||
groups: claims.groups,
|
||||
folders: claims.folders,
|
||||
scopes: None,
|
||||
// Honor the scopes embedded in the JWT (mirrors the EE
|
||||
// jwt_ext_ branch). The route middleware only enforces
|
||||
// scopes when Some, so a None-scoped JWT (e.g. the job
|
||||
// WM_TOKEN) keeps full user privileges as before.
|
||||
scopes: claims.scopes,
|
||||
username_override,
|
||||
token_prefix: claims.audit_span,
|
||||
read_only: false,
|
||||
|
||||
@@ -274,6 +274,7 @@ pub enum ScopeDomain {
|
||||
Configs,
|
||||
OAuth,
|
||||
AI,
|
||||
AiSkills,
|
||||
|
||||
Indexer,
|
||||
Teams, // Microsoft Teams integration
|
||||
@@ -329,6 +330,7 @@ impl ScopeDomain {
|
||||
Self::Configs => "configs",
|
||||
Self::OAuth => "oauth",
|
||||
Self::AI => "ai",
|
||||
Self::AiSkills => "ai_skills",
|
||||
Self::Capture => "capture",
|
||||
Self::Drafts => "drafts",
|
||||
Self::Favorites => "favorites",
|
||||
@@ -378,6 +380,7 @@ impl ScopeDomain {
|
||||
"configs" => Some(Self::Configs),
|
||||
"oauth" => Some(Self::OAuth),
|
||||
"ai" => Some(Self::AI),
|
||||
"ai_skills" => Some(Self::AiSkills),
|
||||
"indexer" | "srch" => Some(Self::Indexer),
|
||||
"teams" => Some(Self::Teams),
|
||||
"native_triggers" => Some(Self::NativeTriggers),
|
||||
@@ -448,6 +451,30 @@ pub fn check_route_access(
|
||||
// Find the domain and kind for this route
|
||||
let (required_domain, required_kind, route_suffix) = extract_domain_from_route(route_path)?;
|
||||
|
||||
// App embed tokens (sentinel) carry broad read scopes (`jobs:read`,
|
||||
// `users:read`, `folders:read`) that exist only for a handful of routes. The
|
||||
// whole `/users`, `/folders` and `/jobs` routers are CORS-enabled for the
|
||||
// opaque app iframe, so default-deny everything in those domains except the
|
||||
// intended routes — otherwise the token could enumerate/export workspace data.
|
||||
if has_app_embed_sentinel(Some(token_scopes)) {
|
||||
if let Some(suffix) = route_suffix.as_deref() {
|
||||
if app_embed_route_denied(required_domain, suffix) {
|
||||
return Err(Error::PermissionDenied(
|
||||
"Access denied. App embed token cannot access this route.".to_string(),
|
||||
));
|
||||
}
|
||||
// The by-id job cancel is a POST (write) that the token's `jobs:read`
|
||||
// wouldn't satisfy, but cancelling the app's own component runs is
|
||||
// intended (most components supersede an in-flight run on re-run). Permit
|
||||
// it here; `cancel_job_api` confines it to jobs the app launched
|
||||
// (created_by == viewer). A read_only token is still rejected by the
|
||||
// separate read-only check.
|
||||
if suffix.starts_with("jobs_u/queue/cancel/") {
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MCP scopes (mcp:all, mcp:favorites, mcp:hub:*, etc.) use a custom format
|
||||
// that doesn't fit the standard domain:action model. Verify the token has at
|
||||
// least one mcp: scope; MCP handlers do their own fine-grained checking.
|
||||
@@ -534,7 +561,7 @@ const FLOW_JOBS: [&'static str; 6] = [
|
||||
|
||||
lazy_static::lazy_static! {
|
||||
static ref RUN_PATH_ACTIONS: Vec<&'static str> = {
|
||||
let mut v = vec!["jobs/resume/", "jobs/run/batch_rerun_jobs", "jobs/run/workflow_as_code", "jobs/run/dependencies","jobs/run/flow_dependencies", "apps_u/execute_component"];
|
||||
let mut v = vec!["jobs/resume/", "jobs/run/batch_rerun_jobs", "jobs/run/workflow_as_code", "jobs/run/dependencies","jobs/run/flow_dependencies", "apps_u/execute_component", "apps_u/upload_s3_file"];
|
||||
|
||||
v.extend(SCRIPT_JOBS);
|
||||
v.extend(FLOW_JOBS);
|
||||
@@ -637,6 +664,92 @@ const RUN_WHITELISTED_GET_PATHS: [&'static str; 20] = [
|
||||
"jobs/completed/get_result_maybe/",
|
||||
];
|
||||
|
||||
/// Sentinel scope in app embed tokens. Grants nothing itself; `check_route_access`
|
||||
/// uses it to deny the workspace-wide job enumeration routes `jobs:read` would
|
||||
/// otherwise reach, so an embedded app reads only jobs it launched (by id).
|
||||
pub const APP_EMBED_SENTINEL: &str = "app_embed";
|
||||
|
||||
/// True if a token's scopes include the app-embed sentinel (a sandboxed app iframe
|
||||
/// token). Such tokens carry the viewer's identity but represent untrusted app JS,
|
||||
/// so several handlers confine them to the app's own resources/runs.
|
||||
pub fn has_app_embed_sentinel(scopes: Option<&[String]>) -> bool {
|
||||
scopes.is_some_and(|s| s.iter().any(|x| x == APP_EMBED_SENTINEL))
|
||||
}
|
||||
|
||||
/// Routes an app embed token (sentinel) is denied. Its broad scopes (`apps:run`,
|
||||
/// `jobs:read`, `users:read`, `folders:read`) exist only for a fixed set of routes a
|
||||
/// running app uses, but the whole `/apps`, `/jobs`, `/users`, `/folders` routers are
|
||||
/// CORS-enabled for the opaque app iframe. Default-deny those domains via an explicit
|
||||
/// allowlist so the token can't reach workspace inventory, counts, exports, or
|
||||
/// capability-minting routes (job signatures / resume URLs).
|
||||
fn app_embed_route_denied(domain: ScopeDomain, suffix: &str) -> bool {
|
||||
match domain {
|
||||
ScopeDomain::Apps => !app_embed_apps_route_allowed(suffix),
|
||||
ScopeDomain::Jobs => !app_embed_job_route_allowed(suffix),
|
||||
ScopeDomain::Users => suffix != "users/whoami",
|
||||
ScopeDomain::Folders => suffix != "folders/listnames",
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// App routes a running app uses: its own definition (`apps/get/p/<path>`, further
|
||||
/// path-scoped by `apps:read:<path>`) and the public app-serving endpoints
|
||||
/// (`apps_u/*`: public_app, public_resource, get_data, and the path-taking
|
||||
/// `execute_component` / `download_s3_file`, which re-check `apps:run|read:<path>`
|
||||
/// in their handlers so they stay confined to this app). Everything else in the
|
||||
/// domain — workspace app inventory (`exists`, `custom_path_exists`, `list`,
|
||||
/// `list_paths*`, `secret_of`, history, management) — is denied.
|
||||
fn app_embed_apps_route_allowed(suffix: &str) -> bool {
|
||||
// The embed-token mint endpoints live under `apps_u/` but they create
|
||||
// credentials. A running app never calls them — the trusted embedder session/JWT
|
||||
// mints the token and hands it to the iframe — so deny them here, otherwise an
|
||||
// app embed token could renew itself indefinitely past the 12h expiry.
|
||||
if suffix.starts_with("apps_u/embed_token") {
|
||||
return false;
|
||||
}
|
||||
suffix.starts_with("apps/get/p/") || suffix.starts_with("apps_u/")
|
||||
}
|
||||
|
||||
/// Job routes a running app uses (the by-id poll/cancel surface driven by the
|
||||
/// frontend JobLoader). Everything else in the jobs domain — enumeration, counts,
|
||||
/// exports, and the `job_signature`/`resume_urls` capability-minting routes — is
|
||||
/// denied. By-id reads are further confined to the app's own runs by
|
||||
/// `require_job_read_access` (the `app_embed` cutoff).
|
||||
fn app_embed_job_route_allowed(suffix: &str) -> bool {
|
||||
// `get_root_job_id` is intentionally absent: its handler has no access check at
|
||||
// all (returns any job's root id by id) and the app never calls it, so denying
|
||||
// it costs nothing and avoids leaking a foreign job's flow lineage.
|
||||
const ALLOWED: [&str; 15] = [
|
||||
"jobs_u/get/",
|
||||
"jobs_u/getupdate/",
|
||||
"jobs_u/getupdate_sse/",
|
||||
"jobs_u/get_logs/",
|
||||
"jobs_u/get_completed_logs_tail/",
|
||||
"jobs_u/get_args/",
|
||||
"jobs_u/get_flow/",
|
||||
"jobs_u/get_flow_all_logs/",
|
||||
"jobs_u/get_flow_debug_info/",
|
||||
"jobs_u/get_log_file/",
|
||||
"jobs_u/completed/get/",
|
||||
"jobs_u/completed/get_result/",
|
||||
"jobs_u/completed/get_result_maybe/",
|
||||
"jobs_u/completed/get_timing/",
|
||||
"jobs_u/queue/cancel/",
|
||||
];
|
||||
ALLOWED.iter().any(|p| suffix.starts_with(p))
|
||||
}
|
||||
|
||||
/// Resource routes a metadata-only `resources:run` scope (app embed tokens) may
|
||||
/// GET: pickers (`/list`) and type schemas. Excludes every value-returning route
|
||||
/// (`get`, `get_value`, `get_value_interpolated`, `list_search`) so resource
|
||||
/// values — which can hold credentials — are never exposed.
|
||||
fn resource_metadata_route_allowed(suffix: &str) -> bool {
|
||||
suffix == "resources/list"
|
||||
|| suffix.starts_with("resources/list_names/")
|
||||
|| suffix.starts_with("resources/exists/")
|
||||
|| suffix.starts_with("resources/type/")
|
||||
}
|
||||
|
||||
fn scope_grants_access(
|
||||
scope: &ScopeDefinition,
|
||||
required_domain: ScopeDomain,
|
||||
@@ -656,6 +769,14 @@ fn scope_grants_access(
|
||||
let scope_action = ScopeAction::from_str(&scope.action)
|
||||
.ok_or_else(|| Error::BadRequest(format!("Invalid scope action: {}", scope.action)))?;
|
||||
|
||||
// App embed tokens carry `resources:run`: metadata-only resource access via
|
||||
// default-deny + allowlist (so a new value route is never exposed by accident).
|
||||
// See `resource_metadata_route_allowed`.
|
||||
if scope_domain == ScopeDomain::Resources && scope_action == ScopeAction::Run {
|
||||
return Ok(required_action == ScopeAction::Read
|
||||
&& route_path.is_some_and(resource_metadata_route_allowed));
|
||||
}
|
||||
|
||||
if !scope_action.includes(&required_action)
|
||||
&& !(scope_domain == ScopeDomain::Jobs
|
||||
&& required_action == ScopeAction::Read
|
||||
@@ -699,6 +820,23 @@ pub fn check_read_only_for_route(route_path: &str, http_method: &str) -> Result<
|
||||
}
|
||||
}
|
||||
|
||||
/// The minimal scope string that grants access to exactly `{method} {path}`, as
|
||||
/// `check_route_access` would require it. Used to mint a least-privilege JWT for
|
||||
/// a single proxied request (the MCP endpoint proxy), so the minted token can do
|
||||
/// only that one operation rather than acting as a blank check.
|
||||
///
|
||||
/// `path` is the request path (e.g. `/api/w/{workspace}/variables/get/...`).
|
||||
/// Returns `None` if the route's domain can't be determined — the caller should
|
||||
/// then fail closed.
|
||||
pub fn scope_for_route(method: &str, path: &str) -> Option<String> {
|
||||
let action = map_http_method_to_action(method, path);
|
||||
let (domain, kind, _suffix) = extract_domain_from_route(path).ok()?;
|
||||
Some(match (domain, action, kind) {
|
||||
(ScopeDomain::Jobs, ScopeAction::Run, Some(kind)) => format!("jobs:run:{}", kind),
|
||||
(domain, action, _) => format!("{}:{}", domain.as_str(), action.as_str()),
|
||||
})
|
||||
}
|
||||
|
||||
/// Helper function to check if scopes allow access to a route
|
||||
pub fn check_scopes_for_route(
|
||||
token_scopes: Option<&[String]>,
|
||||
@@ -789,6 +927,12 @@ mod tests {
|
||||
assert_eq!(domain, ScopeDomain::FlowConversations);
|
||||
assert_eq!(kind, None);
|
||||
assert_eq!(route_suffix, Some("flow_conversations/list".to_string()));
|
||||
|
||||
let (domain, kind, route_suffix) =
|
||||
extract_domain_from_route("/api/w/test_workspace/ai_skills/list").unwrap();
|
||||
assert_eq!(domain, ScopeDomain::AiSkills);
|
||||
assert_eq!(kind, None);
|
||||
assert_eq!(route_suffix, Some("ai_skills/list".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -845,6 +989,10 @@ mod tests {
|
||||
ScopeDomain::from_str("flow_conversations"),
|
||||
Some(ScopeDomain::FlowConversations)
|
||||
);
|
||||
assert_eq!(
|
||||
ScopeDomain::from_str("ai_skills"),
|
||||
Some(ScopeDomain::AiSkills)
|
||||
);
|
||||
|
||||
// Test canonical string conversion
|
||||
assert_eq!(ScopeDomain::Acls.as_str(), "acls");
|
||||
@@ -854,6 +1002,41 @@ mod tests {
|
||||
ScopeDomain::FlowConversations.as_str(),
|
||||
"flow_conversations"
|
||||
);
|
||||
assert_eq!(ScopeDomain::AiSkills.as_str(), "ai_skills");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_ai_skills_scope_access() {
|
||||
let read_scopes = vec!["ai_skills:read".to_string()];
|
||||
assert!(
|
||||
check_route_access(&read_scopes, "/api/w/test_workspace/ai_skills/list", "GET").is_ok()
|
||||
);
|
||||
assert!(check_route_access(
|
||||
&read_scopes,
|
||||
"/api/w/test_workspace/ai_skills/get/foo",
|
||||
"GET"
|
||||
)
|
||||
.is_ok());
|
||||
assert!(check_route_access(
|
||||
&read_scopes,
|
||||
"/api/w/test_workspace/ai_skills/upload",
|
||||
"POST"
|
||||
)
|
||||
.is_err());
|
||||
|
||||
let write_scopes = vec!["ai_skills:write".to_string()];
|
||||
assert!(check_route_access(
|
||||
&write_scopes,
|
||||
"/api/w/test_workspace/ai_skills/upload",
|
||||
"POST"
|
||||
)
|
||||
.is_ok());
|
||||
assert!(check_route_access(
|
||||
&write_scopes,
|
||||
"/api/w/test_workspace/ai_skills/delete/foo",
|
||||
"DELETE"
|
||||
)
|
||||
.is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1083,4 +1266,38 @@ mod tests {
|
||||
let scopes = vec!["jobs:read".to_string(), "mcp:all".to_string()];
|
||||
assert!(check_route_access(&scopes, "/api/w/test_workspace/mcp/something", "GET").is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_scope_for_route() {
|
||||
// The minted scope must be exactly what check_route_access requires for
|
||||
// the same route, so a JWT carrying it passes for that one route only.
|
||||
assert_eq!(
|
||||
scope_for_route("GET", "/api/w/ws/variables/get/u/x/y").as_deref(),
|
||||
Some("variables:read")
|
||||
);
|
||||
assert_eq!(
|
||||
scope_for_route("POST", "/api/w/ws/variables/create").as_deref(),
|
||||
Some("variables:write")
|
||||
);
|
||||
assert_eq!(
|
||||
scope_for_route("DELETE", "/api/w/ws/resources/delete/u/x/y").as_deref(),
|
||||
Some("resources:write")
|
||||
);
|
||||
// jobs run paths carry the runnable kind.
|
||||
assert_eq!(
|
||||
scope_for_route("POST", "/api/w/ws/jobs/run/p/u/x/y").as_deref(),
|
||||
Some("jobs:run:scripts")
|
||||
);
|
||||
assert_eq!(
|
||||
scope_for_route("POST", "/api/w/ws/jobs/run/f/u/x/y").as_deref(),
|
||||
Some("jobs:run:flows")
|
||||
);
|
||||
|
||||
// The minted scope actually satisfies the route check it targets.
|
||||
let s = scope_for_route("POST", "/api/w/ws/variables/create").unwrap();
|
||||
assert!(check_route_access(&[s], "/api/w/ws/variables/create", "POST").is_ok());
|
||||
|
||||
// Unknown route -> None so the caller fails closed.
|
||||
assert!(scope_for_route("GET", "/healthz").is_none());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1284,28 +1284,10 @@ async fn create_script_internal<'c>(
|
||||
if let Err(e) = windmill_parser::sql_materialize::classify_wrap(&ns.content) {
|
||||
return Err(Error::BadRequest(e.message()));
|
||||
}
|
||||
// Managed materialize strips line comments when it wraps the SELECT,
|
||||
// so a `-- $name (TYPE)` declaration is lost while its `$name`
|
||||
// reference survives in the embedded SELECT — it would run unbound.
|
||||
// Managed materialize takes no SQL args (the partition is supplied by
|
||||
// the engine, not bound). Reject declared args with a clear error.
|
||||
if let Ok(sig) = windmill_parser_sql::parse_duckdb_sig(&ns.content) {
|
||||
if !sig.args.is_empty() {
|
||||
let names = sig
|
||||
.args
|
||||
.iter()
|
||||
.map(|a| format!("${}", a.name))
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ");
|
||||
return Err(Error::BadRequest(format!(
|
||||
"managed `// materialize` cannot take SQL arguments ({names}): wrapping your \
|
||||
SELECT drops the `-- $arg` declarations, so they would run unbound. The \
|
||||
partition is supplied by the engine — reference its value with the \
|
||||
`{{partition}}` token, or use `// materialize manual` to write the DDL (and \
|
||||
bind args) yourself."
|
||||
)));
|
||||
}
|
||||
}
|
||||
// SQL args are supported: managed materialize strips line comments
|
||||
// (including `-- $name (type)` declarations) when it wraps the SELECT,
|
||||
// but the executor parses the signature from the un-wrapped script, so
|
||||
// `$name` references in the SELECT stay bound at run time.
|
||||
}
|
||||
// `key=` (merge) and `append` are mutually exclusive reconciliation
|
||||
// strategies; append (INSERT-only) wins. Surface the conflict rather
|
||||
|
||||
@@ -395,13 +395,17 @@ async fn delete_expired_jobs_batch(
|
||||
.fetch_all(&mut *tx)
|
||||
.await?;
|
||||
|
||||
// Active-root exclusion via NOT IN (hashed SubPlan) instead of `!= ALL($3)`;
|
||||
// see backend/src/monitor.rs::delete_expired_jobs_batch for the rationale.
|
||||
let deleted_jobs: Vec<Uuid> = sqlx::query_scalar!(
|
||||
"DELETE FROM v2_job_completed
|
||||
WHERE id IN (
|
||||
SELECT jc.id FROM v2_job_completed jc
|
||||
LEFT JOIN v2_job j ON j.id = jc.id
|
||||
WHERE jc.completed_at <= now() - ($1::bigint::text || ' s')::interval
|
||||
AND COALESCE(j.root_job, j.flow_innermost_root_job, jc.id) != ALL($3)
|
||||
AND COALESCE(j.root_job, j.flow_innermost_root_job, jc.id) NOT IN (
|
||||
SELECT u FROM unnest($3::uuid[]) AS u WHERE u IS NOT NULL
|
||||
)
|
||||
ORDER BY jc.completed_at ASC
|
||||
LIMIT $2
|
||||
FOR UPDATE OF jc SKIP LOCKED
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"openapi": "3.0.3",
|
||||
"info": {
|
||||
"version": "1.728.0",
|
||||
"version": "1.734.0",
|
||||
"title": "Windmill API",
|
||||
"contact": {
|
||||
"name": "Windmill Team",
|
||||
@@ -12196,6 +12196,14 @@
|
||||
"parameters": [
|
||||
{
|
||||
"$ref": "#/components/parameters/WorkspaceId"
|
||||
},
|
||||
{
|
||||
"name": "all_users",
|
||||
"in": "query",
|
||||
"description": "List every draft in the workspace (all users), not just the current user's own + legacy rows. Other users' rows come back with `mine=false` (view-only).",
|
||||
"schema": {
|
||||
"type": "boolean"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
@@ -12233,6 +12241,27 @@
|
||||
"created_at": {
|
||||
"type": "string",
|
||||
"format": "date-time"
|
||||
},
|
||||
"can_write": {
|
||||
"type": "boolean",
|
||||
"description": "Whether the current user may deploy/discard this draft (same check the deploy/discard endpoints enforce)."
|
||||
},
|
||||
"mine": {
|
||||
"type": "boolean",
|
||||
"description": "The row belongs to the current user (own draft or the legacy no-owner row) and is therefore actionable. Always true in the default listing; with `all_users=true`, other users' rows are false (view-only)."
|
||||
},
|
||||
"draft_users": {
|
||||
"description": "Draft authors at this (path, kind) — the legacy NULL-email row surfaced as a null username.\nPopulated only for the shared full-page-editor kinds (script/flow/app/raw_app); omitted for\ndrawer kinds, which keep their drafts private. Feeds the Draft badge's owner-avatar circles.\n",
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"username": {
|
||||
"type": "string",
|
||||
"nullable": true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
@@ -12240,7 +12269,9 @@
|
||||
"path",
|
||||
"draft_only",
|
||||
"legacy_draft",
|
||||
"created_at"
|
||||
"created_at",
|
||||
"can_write",
|
||||
"mine"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -12310,6 +12341,55 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/w/{workspace}/drafts/get_own/{kind}/{path}": {
|
||||
"get": {
|
||||
"summary": "fetch the current user's own draft content at a path (any kind)",
|
||||
"operationId": "getOwnDraft",
|
||||
"tags": [
|
||||
"draft"
|
||||
],
|
||||
"parameters": [
|
||||
{
|
||||
"$ref": "#/components/parameters/WorkspaceId"
|
||||
},
|
||||
{
|
||||
"name": "kind",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/UserDraftItemKind"
|
||||
}
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/parameters/ScriptPath"
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "the user's draft content, or null when none exists",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"nullable": true,
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"value": {},
|
||||
"created_at": {
|
||||
"type": "string",
|
||||
"format": "date-time"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"value",
|
||||
"created_at"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/w/{workspace}/drafts/update/{kind}/{path}": {
|
||||
"post": {
|
||||
"summary": "upsert (or clear) the current user's draft at a path",
|
||||
@@ -12356,6 +12436,11 @@
|
||||
"legacy": {
|
||||
"type": "boolean",
|
||||
"description": "Delete-only. Target the legacy workspace-level row (email NULL) instead of the current user's row. Used to discard a legacy draft from the review page."
|
||||
},
|
||||
"created_at": {
|
||||
"type": "string",
|
||||
"format": "date-time",
|
||||
"description": "Upsert-only override for the stored creation timestamp. Normal saves omit it (stamped server-side); the localStorage→DB migration passes the draft's original write time so migrated drafts keep their age."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -12393,6 +12478,67 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/w/{workspace}/drafts/migrate_legacy/{kind}/{path}": {
|
||||
"post": {
|
||||
"summary": "resolve a legacy (workspace-level) draft (admin only)",
|
||||
"description": "Delete a legacy draft (email NULL) or assign it to the authed admin as a per-user draft. Workspace admins / superadmins only.",
|
||||
"operationId": "migrateLegacyDraft",
|
||||
"tags": [
|
||||
"draft"
|
||||
],
|
||||
"parameters": [
|
||||
{
|
||||
"$ref": "#/components/parameters/WorkspaceId"
|
||||
},
|
||||
{
|
||||
"name": "kind",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/UserDraftItemKind"
|
||||
}
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/parameters/ScriptPath"
|
||||
}
|
||||
],
|
||||
"requestBody": {
|
||||
"required": true,
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"action": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"delete",
|
||||
"assign_to_self"
|
||||
],
|
||||
"description": "delete the legacy draft, or take ownership of it."
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"action"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "migration result",
|
||||
"content": {
|
||||
"text/plain": {
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/w/{workspace}/scripts/create": {
|
||||
"post": {
|
||||
"summary": "create script",
|
||||
@@ -16107,6 +16253,202 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/w/{workspace}/ai_skills/list": {
|
||||
"get": {
|
||||
"summary": "list the workspace AI chat skills (name + description only)",
|
||||
"operationId": "listAiSkills",
|
||||
"tags": [
|
||||
"workspace"
|
||||
],
|
||||
"parameters": [
|
||||
{
|
||||
"$ref": "#/components/parameters/WorkspaceId"
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "skill listing",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"name",
|
||||
"description"
|
||||
],
|
||||
"properties": {
|
||||
"name": {
|
||||
"type": "string"
|
||||
},
|
||||
"description": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/w/{workspace}/ai_skills/get/{name}": {
|
||||
"get": {
|
||||
"summary": "get a workspace AI chat skill including its instructions",
|
||||
"operationId": "getAiSkill",
|
||||
"tags": [
|
||||
"workspace"
|
||||
],
|
||||
"parameters": [
|
||||
{
|
||||
"$ref": "#/components/parameters/WorkspaceId"
|
||||
},
|
||||
{
|
||||
"name": "name",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "skill",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"name",
|
||||
"description",
|
||||
"instructions"
|
||||
],
|
||||
"properties": {
|
||||
"name": {
|
||||
"type": "string"
|
||||
},
|
||||
"description": {
|
||||
"type": "string"
|
||||
},
|
||||
"instructions": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/w/{workspace}/ai_skills/upload": {
|
||||
"post": {
|
||||
"summary": "upsert workspace AI chat skills (admin only)",
|
||||
"operationId": "uploadAiSkills",
|
||||
"tags": [
|
||||
"workspace"
|
||||
],
|
||||
"parameters": [
|
||||
{
|
||||
"$ref": "#/components/parameters/WorkspaceId"
|
||||
}
|
||||
],
|
||||
"requestBody": {
|
||||
"required": true,
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"skills"
|
||||
],
|
||||
"properties": {
|
||||
"skills": {
|
||||
"type": "array",
|
||||
"maxItems": 50,
|
||||
"items": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"name",
|
||||
"description",
|
||||
"instructions"
|
||||
],
|
||||
"properties": {
|
||||
"name": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 64,
|
||||
"pattern": "^[a-z0-9-]+$"
|
||||
},
|
||||
"description": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 1024
|
||||
},
|
||||
"instructions": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 65536
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "uploaded",
|
||||
"content": {
|
||||
"text/plain": {
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/w/{workspace}/ai_skills/delete/{name}": {
|
||||
"delete": {
|
||||
"summary": "delete a workspace AI chat skill (admin only)",
|
||||
"operationId": "deleteAiSkill",
|
||||
"tags": [
|
||||
"workspace"
|
||||
],
|
||||
"parameters": [
|
||||
{
|
||||
"$ref": "#/components/parameters/WorkspaceId"
|
||||
},
|
||||
{
|
||||
"name": "name",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "deleted",
|
||||
"content": {
|
||||
"text/plain": {
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/w/{workspace}/apps/get_data/v/{secretWithExtension}": {
|
||||
"get": {
|
||||
"summary": "get raw app data by",
|
||||
@@ -20409,6 +20751,192 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/w/{workspace}/jobs_u/dispatch_events/{id}": {
|
||||
"get": {
|
||||
"summary": "list asset-trigger dispatch events for a producer job",
|
||||
"description": "Returns the chronological log of decisions the asset-trigger dispatcher made after this producer job completed. Each row is one (subscriber, asset write) decision: `dispatched` (with `child_job_id`), `join_pending` (with `received_inputs` / `required_inputs` / `partition`), or `skipped` (with `reason`). Rows are reaped automatically when the producer's `v2_job` row is deleted by the retention sweep.\n",
|
||||
"operationId": "listDispatchEvents",
|
||||
"tags": [
|
||||
"job"
|
||||
],
|
||||
"parameters": [
|
||||
{
|
||||
"$ref": "#/components/parameters/WorkspaceId"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/parameters/JobId"
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "dispatch events for this producer job",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"subscriber_path": {
|
||||
"type": "string"
|
||||
},
|
||||
"asset_kind": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"s3object",
|
||||
"resource",
|
||||
"variable",
|
||||
"ducklake",
|
||||
"datatable",
|
||||
"volume"
|
||||
]
|
||||
},
|
||||
"asset_path": {
|
||||
"type": "string"
|
||||
},
|
||||
"outcome": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"dispatched",
|
||||
"join_pending",
|
||||
"skipped"
|
||||
]
|
||||
},
|
||||
"child_job_id": {
|
||||
"type": "string",
|
||||
"format": "uuid"
|
||||
},
|
||||
"partition": {
|
||||
"type": "string"
|
||||
},
|
||||
"received_inputs": {
|
||||
"type": "integer"
|
||||
},
|
||||
"required_inputs": {
|
||||
"type": "integer"
|
||||
},
|
||||
"debounce_s": {
|
||||
"type": "integer"
|
||||
},
|
||||
"reason": {
|
||||
"type": "string"
|
||||
},
|
||||
"created_at": {
|
||||
"type": "string",
|
||||
"format": "date-time"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"subscriber_path",
|
||||
"asset_kind",
|
||||
"asset_path",
|
||||
"outcome",
|
||||
"created_at"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/w/{workspace}/jobs/asset_dispatch_edges": {
|
||||
"get": {
|
||||
"summary": "list asset-cascade producer→child job edges for a folder",
|
||||
"description": "Returns the `dispatched` asset-trigger edges (producer job → child job) whose subscriber lives under `path_start`. Lets a pipeline view reconstruct the cascade tree of a folder by job id and group connected runs. Visibility follows the producer job's RLS.\n",
|
||||
"operationId": "listAssetDispatchEdges",
|
||||
"tags": [
|
||||
"job"
|
||||
],
|
||||
"parameters": [
|
||||
{
|
||||
"$ref": "#/components/parameters/WorkspaceId"
|
||||
},
|
||||
{
|
||||
"name": "path_start",
|
||||
"in": "query",
|
||||
"required": true,
|
||||
"description": "Folder path prefix the children live under, e.g. `f/orders/`.",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "created_after",
|
||||
"in": "query",
|
||||
"required": false,
|
||||
"description": "Only edges dispatched at/after this instant.",
|
||||
"schema": {
|
||||
"type": "string",
|
||||
"format": "date-time"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "asset-cascade edges for the folder",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"producer_job_id": {
|
||||
"type": "string",
|
||||
"format": "uuid"
|
||||
},
|
||||
"child_job_id": {
|
||||
"type": "string",
|
||||
"format": "uuid",
|
||||
"description": "Set for `dispatched`; absent for `join_pending` inputs."
|
||||
},
|
||||
"subscriber_path": {
|
||||
"type": "string"
|
||||
},
|
||||
"outcome": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"dispatched",
|
||||
"join_pending"
|
||||
]
|
||||
},
|
||||
"asset_kind": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"s3object",
|
||||
"resource",
|
||||
"variable",
|
||||
"ducklake",
|
||||
"datatable",
|
||||
"volume"
|
||||
]
|
||||
},
|
||||
"asset_path": {
|
||||
"type": "string"
|
||||
},
|
||||
"created_at": {
|
||||
"type": "string",
|
||||
"format": "date-time"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"producer_job_id",
|
||||
"subscriber_path",
|
||||
"outcome",
|
||||
"asset_kind",
|
||||
"asset_path",
|
||||
"created_at"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/w/{workspace}/jobs/completed/delete/{id}": {
|
||||
"post": {
|
||||
"summary": "delete completed job (erase content but keep run id)",
|
||||
@@ -21105,6 +21633,10 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"view_token": {
|
||||
"type": "string",
|
||||
"description": "Share-read-link token for the flow. An authenticated workspace member can append it as a `view_token` query param on the run page to read a flow they don't otherwise have access to."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -21525,6 +22057,10 @@
|
||||
"approver"
|
||||
]
|
||||
}
|
||||
},
|
||||
"view_token": {
|
||||
"type": "string",
|
||||
"description": "Share-read-link token for the parent flow. An authenticated workspace member can append it as a `view_token` query param on the run page to read a flow they don't otherwise have access to."
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
@@ -32745,6 +33281,241 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/w/{workspace}/assets/graph": {
|
||||
"get": {
|
||||
"summary": "Get the workspace-wide asset <-> runnable graph",
|
||||
"operationId": "getAssetsGraph",
|
||||
"tags": [
|
||||
"asset"
|
||||
],
|
||||
"parameters": [
|
||||
{
|
||||
"$ref": "#/components/parameters/WorkspaceId"
|
||||
},
|
||||
{
|
||||
"name": "asset_kinds",
|
||||
"in": "query",
|
||||
"description": "Filter by asset kinds (comma-separated list)",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "folder",
|
||||
"in": "query",
|
||||
"description": "Scope the graph to runnables in a single folder",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "asset graph nodes, lineage edges and trigger edges",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"assets",
|
||||
"runnables",
|
||||
"edges",
|
||||
"triggers"
|
||||
],
|
||||
"properties": {
|
||||
"assets": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"kind",
|
||||
"path"
|
||||
],
|
||||
"properties": {
|
||||
"kind": {
|
||||
"$ref": "#/components/schemas/AssetKind"
|
||||
},
|
||||
"path": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"runnables": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"path",
|
||||
"usage_kind"
|
||||
],
|
||||
"properties": {
|
||||
"path": {
|
||||
"type": "string"
|
||||
},
|
||||
"usage_kind": {
|
||||
"$ref": "#/components/schemas/AssetUsageKind"
|
||||
},
|
||||
"in_pipeline": {
|
||||
"type": "boolean",
|
||||
"description": "True iff the script is a pipeline member (deployed with `// pipeline`). Omitted when false."
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"edges": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"runnable_path",
|
||||
"runnable_kind",
|
||||
"asset_kind",
|
||||
"asset_path"
|
||||
],
|
||||
"properties": {
|
||||
"runnable_path": {
|
||||
"type": "string"
|
||||
},
|
||||
"runnable_kind": {
|
||||
"$ref": "#/components/schemas/AssetUsageKind"
|
||||
},
|
||||
"asset_kind": {
|
||||
"$ref": "#/components/schemas/AssetKind"
|
||||
},
|
||||
"asset_path": {
|
||||
"type": "string"
|
||||
},
|
||||
"access_type": {
|
||||
"$ref": "#/components/schemas/AssetUsageAccessType"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"triggers": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"oneOf": [
|
||||
{
|
||||
"type": "object",
|
||||
"description": "Asset trigger edge (`// on <asset>`)",
|
||||
"required": [
|
||||
"trigger_kind",
|
||||
"asset_kind",
|
||||
"asset_path",
|
||||
"runnable_kind",
|
||||
"runnable_path"
|
||||
],
|
||||
"properties": {
|
||||
"trigger_kind": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"asset"
|
||||
]
|
||||
},
|
||||
"asset_kind": {
|
||||
"$ref": "#/components/schemas/AssetKind"
|
||||
},
|
||||
"asset_path": {
|
||||
"type": "string"
|
||||
},
|
||||
"runnable_kind": {
|
||||
"$ref": "#/components/schemas/AssetUsageKind"
|
||||
},
|
||||
"runnable_path": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"description": "Native trigger edge (schedule, email, kafka, ...). `path` is the trigger row's path.",
|
||||
"required": [
|
||||
"trigger_kind",
|
||||
"path",
|
||||
"runnable_kind",
|
||||
"runnable_path"
|
||||
],
|
||||
"properties": {
|
||||
"trigger_kind": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"schedule",
|
||||
"email",
|
||||
"kafka",
|
||||
"mqtt",
|
||||
"nats",
|
||||
"postgres",
|
||||
"sqs",
|
||||
"gcp"
|
||||
]
|
||||
},
|
||||
"path": {
|
||||
"type": "string"
|
||||
},
|
||||
"runnable_kind": {
|
||||
"$ref": "#/components/schemas/AssetUsageKind"
|
||||
},
|
||||
"runnable_path": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/w/{workspace}/assets/pipelines": {
|
||||
"get": {
|
||||
"summary": "List folders that contain at least one pipeline-member script",
|
||||
"operationId": "listPipelineFolders",
|
||||
"tags": [
|
||||
"asset"
|
||||
],
|
||||
"parameters": [
|
||||
{
|
||||
"$ref": "#/components/parameters/WorkspaceId"
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "folders containing pipeline scripts, with their script counts",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"folder",
|
||||
"script_count"
|
||||
],
|
||||
"properties": {
|
||||
"folder": {
|
||||
"type": "string",
|
||||
"description": "The folder name (without the `f/` prefix)"
|
||||
},
|
||||
"script_count": {
|
||||
"type": "integer",
|
||||
"format": "int64",
|
||||
"description": "Number of pipeline-member scripts in the folder"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/w/{workspace}/volumes/list": {
|
||||
"get": {
|
||||
"summary": "List all volumes in the workspace",
|
||||
@@ -33694,7 +34465,8 @@
|
||||
"trigger_cli",
|
||||
"trigger_nextcloud",
|
||||
"trigger_google",
|
||||
"trigger_github"
|
||||
"trigger_github",
|
||||
"data_pipeline"
|
||||
]
|
||||
},
|
||||
"OpenFlow": {
|
||||
@@ -36316,6 +37088,10 @@
|
||||
"parent_hash": {
|
||||
"type": "string"
|
||||
},
|
||||
"auto_parent": {
|
||||
"type": "boolean",
|
||||
"description": "When true, the backend resolves the parent to the current deployed head for this path within the transaction (ignoring parent_hash), instead of failing with a \"lineage must be linear\" error when the supplied parent_hash is stale."
|
||||
},
|
||||
"summary": {
|
||||
"type": "string"
|
||||
},
|
||||
@@ -37381,6 +38157,12 @@
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"folders_read": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"folders_owners": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
@@ -37408,6 +38190,7 @@
|
||||
"operator",
|
||||
"disabled",
|
||||
"folders",
|
||||
"folders_read",
|
||||
"folders_owners"
|
||||
]
|
||||
},
|
||||
@@ -39311,7 +40094,8 @@
|
||||
"gcp",
|
||||
"azure",
|
||||
"google",
|
||||
"github"
|
||||
"github",
|
||||
"asset"
|
||||
]
|
||||
},
|
||||
"TriggerMode": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
openapi: 3.0.3
|
||||
info:
|
||||
version: 1.728.0
|
||||
version: 1.734.0
|
||||
title: Windmill API
|
||||
contact:
|
||||
name: Windmill Team
|
||||
@@ -749,6 +749,10 @@ paths:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
folders_read:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
folders_owners:
|
||||
type: array
|
||||
items:
|
||||
@@ -788,6 +792,7 @@ paths:
|
||||
- operator
|
||||
- disabled
|
||||
- folders
|
||||
- folders_read
|
||||
- folders_owners
|
||||
/w/{workspace}/users/update/{username}:
|
||||
post:
|
||||
@@ -8995,9 +9000,10 @@ paths:
|
||||
cc_token_url:
|
||||
type: string
|
||||
description: >-
|
||||
Bring-your-own token endpoint override. Only honored together
|
||||
with cc_client_id/cc_client_secret and mutually exclusive with
|
||||
cc_instance; rejected on the shared-instance path.
|
||||
Bring-your-own token endpoint override. Only honored
|
||||
together with cc_client_id/cc_client_secret and mutually
|
||||
exclusive with cc_instance; rejected on the shared-instance
|
||||
path.
|
||||
responses:
|
||||
'200':
|
||||
description: OAuth token response
|
||||
@@ -12721,6 +12727,14 @@ paths:
|
||||
in: path
|
||||
required: true
|
||||
schema: *ref_4
|
||||
- name: all_users
|
||||
in: query
|
||||
description: >-
|
||||
List every draft in the workspace (all users), not just the current
|
||||
user's own + legacy rows. Other users' rows come back with
|
||||
`mine=false` (view-only).
|
||||
schema:
|
||||
type: boolean
|
||||
responses:
|
||||
'200':
|
||||
description: the user's drafts
|
||||
@@ -12764,6 +12778,7 @@ paths:
|
||||
- trigger_nextcloud
|
||||
- trigger_google
|
||||
- trigger_github
|
||||
- data_pipeline
|
||||
path:
|
||||
type: string
|
||||
summary:
|
||||
@@ -12792,12 +12807,43 @@ paths:
|
||||
created_at:
|
||||
type: string
|
||||
format: date-time
|
||||
can_write:
|
||||
type: boolean
|
||||
description: >-
|
||||
Whether the current user may deploy/discard this draft
|
||||
(same check the deploy/discard endpoints enforce).
|
||||
mine:
|
||||
type: boolean
|
||||
description: >-
|
||||
The row belongs to the current user (own draft or the
|
||||
legacy no-owner row) and is therefore actionable. Always
|
||||
true in the default listing; with `all_users=true`,
|
||||
other users' rows are false (view-only).
|
||||
draft_users:
|
||||
description: >
|
||||
Draft authors at this (path, kind) — the legacy
|
||||
NULL-email row surfaced as a null username.
|
||||
|
||||
Populated only for the shared full-page-editor kinds
|
||||
(script/flow/app/raw_app); omitted for
|
||||
|
||||
drawer kinds, which keep their drafts private. Feeds the
|
||||
Draft badge's owner-avatar circles.
|
||||
type: array
|
||||
items:
|
||||
type: object
|
||||
properties:
|
||||
username:
|
||||
type: string
|
||||
nullable: true
|
||||
required:
|
||||
- kind
|
||||
- path
|
||||
- draft_only
|
||||
- legacy_draft
|
||||
- created_at
|
||||
- can_write
|
||||
- mine
|
||||
/w/{workspace}/drafts/get/{kind}/{path}:
|
||||
get:
|
||||
summary: >-
|
||||
@@ -12851,6 +12897,48 @@ paths:
|
||||
- created_at
|
||||
'404':
|
||||
description: no draft for that owner at that path
|
||||
/w/{workspace}/drafts/get_own/{kind}/{path}:
|
||||
get:
|
||||
summary: fetch the current user's own draft content at a path (any kind)
|
||||
operationId: getOwnDraft
|
||||
tags:
|
||||
- draft
|
||||
parameters:
|
||||
- name: workspace
|
||||
in: path
|
||||
required: true
|
||||
schema: *ref_4
|
||||
- name: kind
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
description: >
|
||||
Closed set of item kinds a user can autosave as a draft. Mirrors
|
||||
the
|
||||
|
||||
Postgres `DRAFT_KIND` enum and the backend `UserDraftItemKind`.
|
||||
enum: *ref_100
|
||||
- name: path
|
||||
in: path
|
||||
required: true
|
||||
schema: *ref_97
|
||||
responses:
|
||||
'200':
|
||||
description: the user's draft content, or null when none exists
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
nullable: true
|
||||
type: object
|
||||
properties:
|
||||
value: {}
|
||||
created_at:
|
||||
type: string
|
||||
format: date-time
|
||||
required:
|
||||
- value
|
||||
- created_at
|
||||
/w/{workspace}/drafts/update/{kind}/{path}:
|
||||
post:
|
||||
summary: upsert (or clear) the current user's draft at a path
|
||||
@@ -12904,6 +12992,14 @@ paths:
|
||||
Delete-only. Target the legacy workspace-level row (email
|
||||
NULL) instead of the current user's row. Used to discard a
|
||||
legacy draft from the review page.
|
||||
created_at:
|
||||
type: string
|
||||
format: date-time
|
||||
description: >-
|
||||
Upsert-only override for the stored creation timestamp.
|
||||
Normal saves omit it (stamped server-side); the
|
||||
localStorage→DB migration passes the draft's original write
|
||||
time so migrated drafts keep their age.
|
||||
responses:
|
||||
'200':
|
||||
description: save result
|
||||
@@ -12923,6 +13019,57 @@ paths:
|
||||
required:
|
||||
- status
|
||||
- current_timestamp
|
||||
/w/{workspace}/drafts/migrate_legacy/{kind}/{path}:
|
||||
post:
|
||||
summary: resolve a legacy (workspace-level) draft (admin only)
|
||||
description: >-
|
||||
Delete a legacy draft (email NULL) or assign it to the authed admin as a
|
||||
per-user draft. Workspace admins / superadmins only.
|
||||
operationId: migrateLegacyDraft
|
||||
tags:
|
||||
- draft
|
||||
parameters:
|
||||
- name: workspace
|
||||
in: path
|
||||
required: true
|
||||
schema: *ref_4
|
||||
- name: kind
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
description: >
|
||||
Closed set of item kinds a user can autosave as a draft. Mirrors
|
||||
the
|
||||
|
||||
Postgres `DRAFT_KIND` enum and the backend `UserDraftItemKind`.
|
||||
enum: *ref_100
|
||||
- name: path
|
||||
in: path
|
||||
required: true
|
||||
schema: *ref_97
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
action:
|
||||
type: string
|
||||
enum:
|
||||
- delete
|
||||
- assign_to_self
|
||||
description: delete the legacy draft, or take ownership of it.
|
||||
required:
|
||||
- action
|
||||
responses:
|
||||
'200':
|
||||
description: migration result
|
||||
content:
|
||||
text/plain:
|
||||
schema:
|
||||
type: string
|
||||
/w/{workspace}/scripts/create:
|
||||
post:
|
||||
summary: create script
|
||||
@@ -12971,6 +13118,13 @@ paths:
|
||||
type: string
|
||||
parent_hash:
|
||||
type: string
|
||||
auto_parent:
|
||||
type: boolean
|
||||
description: >-
|
||||
When true, the backend resolves the parent to the current
|
||||
deployed head for this path within the transaction (ignoring
|
||||
parent_hash), instead of failing with a "lineage must be
|
||||
linear" error when the supplied parent_hash is stale.
|
||||
summary:
|
||||
type: string
|
||||
description:
|
||||
@@ -16613,6 +16767,141 @@ paths:
|
||||
text/plain:
|
||||
schema:
|
||||
type: string
|
||||
/w/{workspace}/ai_skills/list:
|
||||
get:
|
||||
summary: list the workspace AI chat skills (name + description only)
|
||||
operationId: listAiSkills
|
||||
tags:
|
||||
- workspace
|
||||
parameters:
|
||||
- name: workspace
|
||||
in: path
|
||||
required: true
|
||||
schema: *ref_4
|
||||
responses:
|
||||
'200':
|
||||
description: skill listing
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: array
|
||||
items:
|
||||
type: object
|
||||
required:
|
||||
- name
|
||||
- description
|
||||
properties:
|
||||
name:
|
||||
type: string
|
||||
description:
|
||||
type: string
|
||||
/w/{workspace}/ai_skills/get/{name}:
|
||||
get:
|
||||
summary: get a workspace AI chat skill including its instructions
|
||||
operationId: getAiSkill
|
||||
tags:
|
||||
- workspace
|
||||
parameters:
|
||||
- name: workspace
|
||||
in: path
|
||||
required: true
|
||||
schema: *ref_4
|
||||
- name: name
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
responses:
|
||||
'200':
|
||||
description: skill
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required:
|
||||
- name
|
||||
- description
|
||||
- instructions
|
||||
properties:
|
||||
name:
|
||||
type: string
|
||||
description:
|
||||
type: string
|
||||
instructions:
|
||||
type: string
|
||||
/w/{workspace}/ai_skills/upload:
|
||||
post:
|
||||
summary: upsert workspace AI chat skills (admin only)
|
||||
operationId: uploadAiSkills
|
||||
tags:
|
||||
- workspace
|
||||
parameters:
|
||||
- name: workspace
|
||||
in: path
|
||||
required: true
|
||||
schema: *ref_4
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required:
|
||||
- skills
|
||||
properties:
|
||||
skills:
|
||||
type: array
|
||||
maxItems: 50
|
||||
items:
|
||||
type: object
|
||||
required:
|
||||
- name
|
||||
- description
|
||||
- instructions
|
||||
properties:
|
||||
name:
|
||||
type: string
|
||||
minLength: 1
|
||||
maxLength: 64
|
||||
pattern: ^[a-z0-9-]+$
|
||||
description:
|
||||
type: string
|
||||
minLength: 1
|
||||
maxLength: 1024
|
||||
instructions:
|
||||
type: string
|
||||
minLength: 1
|
||||
maxLength: 65536
|
||||
responses:
|
||||
'200':
|
||||
description: uploaded
|
||||
content:
|
||||
text/plain:
|
||||
schema:
|
||||
type: string
|
||||
/w/{workspace}/ai_skills/delete/{name}:
|
||||
delete:
|
||||
summary: delete a workspace AI chat skill (admin only)
|
||||
operationId: deleteAiSkill
|
||||
tags:
|
||||
- workspace
|
||||
parameters:
|
||||
- name: workspace
|
||||
in: path
|
||||
required: true
|
||||
schema: *ref_4
|
||||
- name: name
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
responses:
|
||||
'200':
|
||||
description: deleted
|
||||
content:
|
||||
text/plain:
|
||||
schema:
|
||||
type: string
|
||||
/w/{workspace}/apps/get_data/v/{secretWithExtension}:
|
||||
get:
|
||||
summary: get raw app data by
|
||||
@@ -19986,6 +20275,7 @@ paths:
|
||||
- azure
|
||||
- google
|
||||
- github
|
||||
- asset
|
||||
- name: trigger_path
|
||||
description: The path of the trigger (can contain forward slashes)
|
||||
in: path
|
||||
@@ -21551,6 +21841,154 @@ paths:
|
||||
type: integer
|
||||
required:
|
||||
- created_at
|
||||
/w/{workspace}/jobs_u/dispatch_events/{id}:
|
||||
get:
|
||||
summary: list asset-trigger dispatch events for a producer job
|
||||
description: >
|
||||
Returns the chronological log of decisions the asset-trigger dispatcher
|
||||
made after this producer job completed. Each row is one (subscriber,
|
||||
asset write) decision: `dispatched` (with `child_job_id`),
|
||||
`join_pending` (with `received_inputs` / `required_inputs` /
|
||||
`partition`), or `skipped` (with `reason`). Rows are reaped
|
||||
automatically when the producer's `v2_job` row is deleted by the
|
||||
retention sweep.
|
||||
operationId: listDispatchEvents
|
||||
tags:
|
||||
- job
|
||||
parameters:
|
||||
- name: workspace
|
||||
in: path
|
||||
required: true
|
||||
schema: *ref_4
|
||||
- name: id
|
||||
in: path
|
||||
required: true
|
||||
schema: *ref_176
|
||||
responses:
|
||||
'200':
|
||||
description: dispatch events for this producer job
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: array
|
||||
items:
|
||||
type: object
|
||||
properties:
|
||||
subscriber_path:
|
||||
type: string
|
||||
asset_kind:
|
||||
type: string
|
||||
enum:
|
||||
- s3object
|
||||
- resource
|
||||
- variable
|
||||
- ducklake
|
||||
- datatable
|
||||
- volume
|
||||
asset_path:
|
||||
type: string
|
||||
outcome:
|
||||
type: string
|
||||
enum:
|
||||
- dispatched
|
||||
- join_pending
|
||||
- skipped
|
||||
child_job_id:
|
||||
type: string
|
||||
format: uuid
|
||||
partition:
|
||||
type: string
|
||||
received_inputs:
|
||||
type: integer
|
||||
required_inputs:
|
||||
type: integer
|
||||
debounce_s:
|
||||
type: integer
|
||||
reason:
|
||||
type: string
|
||||
created_at:
|
||||
type: string
|
||||
format: date-time
|
||||
required:
|
||||
- subscriber_path
|
||||
- asset_kind
|
||||
- asset_path
|
||||
- outcome
|
||||
- created_at
|
||||
/w/{workspace}/jobs/asset_dispatch_edges:
|
||||
get:
|
||||
summary: list asset-cascade producer→child job edges for a folder
|
||||
description: >
|
||||
Returns the `dispatched` asset-trigger edges (producer job → child job)
|
||||
whose subscriber lives under `path_start`. Lets a pipeline view
|
||||
reconstruct the cascade tree of a folder by job id and group connected
|
||||
runs. Visibility follows the producer job's RLS.
|
||||
operationId: listAssetDispatchEdges
|
||||
tags:
|
||||
- job
|
||||
parameters:
|
||||
- name: workspace
|
||||
in: path
|
||||
required: true
|
||||
schema: *ref_4
|
||||
- name: path_start
|
||||
in: query
|
||||
required: true
|
||||
description: Folder path prefix the children live under, e.g. `f/orders/`.
|
||||
schema:
|
||||
type: string
|
||||
- name: created_after
|
||||
in: query
|
||||
required: false
|
||||
description: Only edges dispatched at/after this instant.
|
||||
schema:
|
||||
type: string
|
||||
format: date-time
|
||||
responses:
|
||||
'200':
|
||||
description: asset-cascade edges for the folder
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: array
|
||||
items:
|
||||
type: object
|
||||
properties:
|
||||
producer_job_id:
|
||||
type: string
|
||||
format: uuid
|
||||
child_job_id:
|
||||
type: string
|
||||
format: uuid
|
||||
description: Set for `dispatched`; absent for `join_pending` inputs.
|
||||
subscriber_path:
|
||||
type: string
|
||||
outcome:
|
||||
type: string
|
||||
enum:
|
||||
- dispatched
|
||||
- join_pending
|
||||
asset_kind:
|
||||
type: string
|
||||
enum:
|
||||
- s3object
|
||||
- resource
|
||||
- variable
|
||||
- ducklake
|
||||
- datatable
|
||||
- volume
|
||||
asset_path:
|
||||
type: string
|
||||
created_at:
|
||||
type: string
|
||||
format: date-time
|
||||
required:
|
||||
- producer_job_id
|
||||
- subscriber_path
|
||||
- outcome
|
||||
- asset_kind
|
||||
- asset_path
|
||||
- created_at
|
||||
/w/{workspace}/jobs/completed/delete/{id}:
|
||||
post:
|
||||
summary: delete completed job (erase content but keep run id)
|
||||
@@ -22053,6 +22491,13 @@ paths:
|
||||
type: integer
|
||||
approver:
|
||||
type: string
|
||||
view_token:
|
||||
type: string
|
||||
description: >-
|
||||
Share-read-link token for the flow. An authenticated
|
||||
workspace member can append it as a `view_token` query
|
||||
param on the run page to read a flow they don't otherwise
|
||||
have access to.
|
||||
/w/{workspace}/jobs_u/resume/{id}/{resume_id}/{signature}:
|
||||
get:
|
||||
summary: resume a job for a suspended flow
|
||||
@@ -22353,6 +22798,13 @@ paths:
|
||||
required:
|
||||
- resume_id
|
||||
- approver
|
||||
view_token:
|
||||
type: string
|
||||
description: >-
|
||||
Share-read-link token for the parent flow. An
|
||||
authenticated workspace member can append it as a
|
||||
`view_token` query param on the run page to read a flow
|
||||
they don't otherwise have access to.
|
||||
required:
|
||||
- job
|
||||
- approvers
|
||||
@@ -34855,6 +35307,181 @@ paths:
|
||||
path:
|
||||
type: string
|
||||
description: The asset path
|
||||
/w/{workspace}/assets/graph:
|
||||
get:
|
||||
summary: Get the workspace-wide asset <-> runnable graph
|
||||
operationId: getAssetsGraph
|
||||
tags:
|
||||
- asset
|
||||
parameters:
|
||||
- name: workspace
|
||||
in: path
|
||||
required: true
|
||||
schema: *ref_4
|
||||
- name: asset_kinds
|
||||
in: query
|
||||
description: Filter by asset kinds (comma-separated list)
|
||||
schema:
|
||||
type: string
|
||||
- name: folder
|
||||
in: query
|
||||
description: Scope the graph to runnables in a single folder
|
||||
schema:
|
||||
type: string
|
||||
responses:
|
||||
'200':
|
||||
description: asset graph nodes, lineage edges and trigger edges
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required:
|
||||
- assets
|
||||
- runnables
|
||||
- edges
|
||||
- triggers
|
||||
properties:
|
||||
assets:
|
||||
type: array
|
||||
items:
|
||||
type: object
|
||||
required:
|
||||
- kind
|
||||
- path
|
||||
properties:
|
||||
kind:
|
||||
type: string
|
||||
enum: *ref_306
|
||||
path:
|
||||
type: string
|
||||
runnables:
|
||||
type: array
|
||||
items:
|
||||
type: object
|
||||
required:
|
||||
- path
|
||||
- usage_kind
|
||||
properties:
|
||||
path:
|
||||
type: string
|
||||
usage_kind:
|
||||
type: string
|
||||
enum: *ref_308
|
||||
in_pipeline:
|
||||
type: boolean
|
||||
description: >-
|
||||
True iff the script is a pipeline member (deployed
|
||||
with `// pipeline`). Omitted when false.
|
||||
edges:
|
||||
type: array
|
||||
items:
|
||||
type: object
|
||||
required:
|
||||
- runnable_path
|
||||
- runnable_kind
|
||||
- asset_kind
|
||||
- asset_path
|
||||
properties:
|
||||
runnable_path:
|
||||
type: string
|
||||
runnable_kind:
|
||||
type: string
|
||||
enum: *ref_308
|
||||
asset_kind:
|
||||
type: string
|
||||
enum: *ref_306
|
||||
asset_path:
|
||||
type: string
|
||||
access_type:
|
||||
type: string
|
||||
enum: *ref_307
|
||||
nullable: true
|
||||
triggers:
|
||||
type: array
|
||||
items:
|
||||
oneOf:
|
||||
- type: object
|
||||
description: Asset trigger edge (`// on <asset>`)
|
||||
required:
|
||||
- trigger_kind
|
||||
- asset_kind
|
||||
- asset_path
|
||||
- runnable_kind
|
||||
- runnable_path
|
||||
properties:
|
||||
trigger_kind:
|
||||
type: string
|
||||
enum:
|
||||
- asset
|
||||
asset_kind:
|
||||
type: string
|
||||
enum: *ref_306
|
||||
asset_path:
|
||||
type: string
|
||||
runnable_kind:
|
||||
type: string
|
||||
enum: *ref_308
|
||||
runnable_path:
|
||||
type: string
|
||||
- type: object
|
||||
description: >-
|
||||
Native trigger edge (schedule, email, kafka, ...).
|
||||
`path` is the trigger row's path.
|
||||
required:
|
||||
- trigger_kind
|
||||
- path
|
||||
- runnable_kind
|
||||
- runnable_path
|
||||
properties:
|
||||
trigger_kind:
|
||||
type: string
|
||||
enum:
|
||||
- schedule
|
||||
- email
|
||||
- kafka
|
||||
- mqtt
|
||||
- nats
|
||||
- postgres
|
||||
- sqs
|
||||
- gcp
|
||||
path:
|
||||
type: string
|
||||
runnable_kind:
|
||||
type: string
|
||||
enum: *ref_308
|
||||
runnable_path:
|
||||
type: string
|
||||
/w/{workspace}/assets/pipelines:
|
||||
get:
|
||||
summary: List folders that contain at least one pipeline-member script
|
||||
operationId: listPipelineFolders
|
||||
tags:
|
||||
- asset
|
||||
parameters:
|
||||
- name: workspace
|
||||
in: path
|
||||
required: true
|
||||
schema: *ref_4
|
||||
responses:
|
||||
'200':
|
||||
description: folders containing pipeline scripts, with their script counts
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: array
|
||||
items:
|
||||
type: object
|
||||
required:
|
||||
- folder
|
||||
- script_count
|
||||
properties:
|
||||
folder:
|
||||
type: string
|
||||
description: The folder name (without the `f/` prefix)
|
||||
script_count:
|
||||
type: integer
|
||||
format: int64
|
||||
description: Number of pipeline-member scripts in the folder
|
||||
/w/{workspace}/volumes/list:
|
||||
get:
|
||||
summary: List all volumes in the workspace
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
openapi: "3.0.3"
|
||||
|
||||
info:
|
||||
version: 1.735.0
|
||||
version: 1.737.0
|
||||
title: Windmill API
|
||||
|
||||
contact:
|
||||
@@ -7501,6 +7501,22 @@ paths:
|
||||
workspace_id:
|
||||
type: string
|
||||
|
||||
/apps_u/embed_token_by_custom_path/{custom_path}:
|
||||
get:
|
||||
summary: get app embed token by custom path
|
||||
operationId: getAppEmbedTokenByCustomPath
|
||||
tags:
|
||||
- app
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/CustomPath"
|
||||
responses:
|
||||
"200":
|
||||
description: embed token
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/EmbedTokenResponse"
|
||||
|
||||
/scripts/hub/get/{path}:
|
||||
get:
|
||||
summary: get hub script content by path
|
||||
@@ -10431,6 +10447,133 @@ paths:
|
||||
schema:
|
||||
type: string
|
||||
|
||||
/w/{workspace}/ai_skills/list:
|
||||
get:
|
||||
summary: list the workspace AI chat skills (name + description only)
|
||||
operationId: listAiSkills
|
||||
tags:
|
||||
- workspace
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/WorkspaceId"
|
||||
responses:
|
||||
"200":
|
||||
description: skill listing
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: array
|
||||
items:
|
||||
type: object
|
||||
required:
|
||||
- name
|
||||
- description
|
||||
properties:
|
||||
name:
|
||||
type: string
|
||||
description:
|
||||
type: string
|
||||
|
||||
/w/{workspace}/ai_skills/get/{name}:
|
||||
get:
|
||||
summary: get a workspace AI chat skill including its instructions
|
||||
operationId: getAiSkill
|
||||
tags:
|
||||
- workspace
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/WorkspaceId"
|
||||
- name: name
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
responses:
|
||||
"200":
|
||||
description: skill
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required:
|
||||
- name
|
||||
- description
|
||||
- instructions
|
||||
properties:
|
||||
name:
|
||||
type: string
|
||||
description:
|
||||
type: string
|
||||
instructions:
|
||||
type: string
|
||||
|
||||
/w/{workspace}/ai_skills/upload:
|
||||
post:
|
||||
summary: upsert workspace AI chat skills (admin only)
|
||||
operationId: uploadAiSkills
|
||||
tags:
|
||||
- workspace
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/WorkspaceId"
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required:
|
||||
- skills
|
||||
properties:
|
||||
skills:
|
||||
type: array
|
||||
maxItems: 50
|
||||
items:
|
||||
type: object
|
||||
required:
|
||||
- name
|
||||
- description
|
||||
- instructions
|
||||
properties:
|
||||
name:
|
||||
type: string
|
||||
minLength: 1
|
||||
maxLength: 64
|
||||
pattern: "^[a-z0-9-]+$"
|
||||
description:
|
||||
type: string
|
||||
minLength: 1
|
||||
maxLength: 1024
|
||||
instructions:
|
||||
type: string
|
||||
minLength: 1
|
||||
maxLength: 65536
|
||||
responses:
|
||||
"200":
|
||||
description: uploaded
|
||||
content:
|
||||
text/plain:
|
||||
schema:
|
||||
type: string
|
||||
|
||||
/w/{workspace}/ai_skills/delete/{name}:
|
||||
delete:
|
||||
summary: delete a workspace AI chat skill (admin only)
|
||||
operationId: deleteAiSkill
|
||||
tags:
|
||||
- workspace
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/WorkspaceId"
|
||||
- name: name
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
responses:
|
||||
"200":
|
||||
description: deleted
|
||||
content:
|
||||
text/plain:
|
||||
schema:
|
||||
type: string
|
||||
|
||||
/w/{workspace}/apps/get_data/v/{secretWithExtension}:
|
||||
get:
|
||||
summary: get raw app data by
|
||||
@@ -10442,6 +10585,10 @@ paths:
|
||||
- name: secretWithExtension
|
||||
in: path
|
||||
required: true
|
||||
description: >-
|
||||
App version secret suffixed with the requested file type extension.
|
||||
Supported extensions are `.js` (JavaScript bundle), `.css`
|
||||
(stylesheet), and `.html` (sandboxed wrapper document).
|
||||
schema:
|
||||
type: string
|
||||
responses:
|
||||
@@ -10451,6 +10598,12 @@ paths:
|
||||
text/javascript:
|
||||
schema:
|
||||
type: string
|
||||
text/css:
|
||||
schema:
|
||||
type: string
|
||||
text/html:
|
||||
schema:
|
||||
type: string
|
||||
|
||||
/w/{workspace}/apps/list_search:
|
||||
get:
|
||||
@@ -10702,6 +10855,23 @@ paths:
|
||||
- $ref: "#/components/schemas/AppWithLastVersion"
|
||||
- $ref: "#/components/schemas/UserDraftOverlay"
|
||||
|
||||
/w/{workspace}/apps/embed_token/p/{path}:
|
||||
get:
|
||||
summary: get app embed token by path
|
||||
operationId: getAppEmbedTokenByPath
|
||||
tags:
|
||||
- app
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/WorkspaceId"
|
||||
- $ref: "#/components/parameters/ScriptPath"
|
||||
responses:
|
||||
"200":
|
||||
description: embed token
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/EmbedTokenResponse"
|
||||
|
||||
/w/{workspace}/apps/get/lite/{path}:
|
||||
get:
|
||||
summary: get app lite by path
|
||||
@@ -10819,6 +10989,27 @@ paths:
|
||||
schema:
|
||||
$ref: "#/components/schemas/AppWithLastVersion"
|
||||
|
||||
/w/{workspace}/apps_u/embed_token/{secret}:
|
||||
get:
|
||||
summary: get app embed token by secret
|
||||
operationId: getAppEmbedTokenBySecret
|
||||
tags:
|
||||
- app
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/WorkspaceId"
|
||||
- name: secret
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
responses:
|
||||
"200":
|
||||
description: embed token
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/EmbedTokenResponse"
|
||||
|
||||
/w/{workspace}/apps_u/public_resource/{path}:
|
||||
get:
|
||||
summary: get public resource
|
||||
@@ -27706,6 +27897,13 @@ components:
|
||||
type: string
|
||||
on_behalf_of_email:
|
||||
type: string
|
||||
sandbox:
|
||||
type: boolean
|
||||
description: >
|
||||
Publisher opt-in to app sandbox isolation (alpha). When true the app
|
||||
is isolated from each viewer's Windmill session. When false/absent
|
||||
the app runs same-origin with the viewer's full session (the
|
||||
default, pre-isolation behavior).
|
||||
|
||||
ListableApp:
|
||||
type: object
|
||||
@@ -27925,6 +28123,36 @@ components:
|
||||
required:
|
||||
- version
|
||||
|
||||
EmbedTokenResponse:
|
||||
type: object
|
||||
properties:
|
||||
token:
|
||||
type: string
|
||||
nullable: true
|
||||
description: Narrowly-scoped embed token for the iframe. Absent for fully anonymous or raw apps, which load without a scoped token.
|
||||
expiration:
|
||||
type: string
|
||||
format: date-time
|
||||
nullable: true
|
||||
description: Expiration of the embed token.
|
||||
raw_app:
|
||||
type: boolean
|
||||
description: Raw apps render single-iframe and skip the opaque-viewer indirection and the embed token entirely.
|
||||
sandbox:
|
||||
type: boolean
|
||||
description: Publisher opted this app into sandbox isolation. When false the viewer runs the app same-origin with its full session.
|
||||
app_path:
|
||||
type: string
|
||||
nullable: true
|
||||
description: The resolved app path; the embedder uses it to scope the app's backing localStorage per app.
|
||||
workspace_id:
|
||||
type: string
|
||||
nullable: true
|
||||
description: The resolved workspace; pairs with app_path so apps at the same path in different workspaces don't share a localStorage store.
|
||||
required:
|
||||
- raw_app
|
||||
- sandbox
|
||||
|
||||
FlowVersion:
|
||||
type: object
|
||||
properties:
|
||||
|
||||
@@ -0,0 +1,394 @@
|
||||
/*
|
||||
* Author: Ruben Fiszel
|
||||
* Copyright: Windmill Labs, Inc 2026
|
||||
* This file and its contents are licensed under the AGPLv3 License.
|
||||
* Please see the included NOTICE for copyright information and
|
||||
* LICENSE-AGPL for a copy of the license.
|
||||
*/
|
||||
|
||||
use crate::db::{ApiAuthed, DB};
|
||||
use std::collections::HashSet;
|
||||
use axum::{
|
||||
extract::{Extension, Json, Path},
|
||||
routing::{delete, get, post},
|
||||
Router,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use windmill_audit::audit_oss::audit_log;
|
||||
use windmill_audit::ActionKind;
|
||||
use windmill_common::{
|
||||
db::UserDB,
|
||||
error::{Error, JsonResult, Result},
|
||||
utils::require_admin,
|
||||
};
|
||||
|
||||
pub fn workspaced_service() -> Router {
|
||||
Router::new()
|
||||
.route("/list", get(list_skills))
|
||||
.route("/get/{name}", get(get_skill))
|
||||
.route("/upload", post(upload_skills))
|
||||
.route("/delete/{name}", delete(delete_skill))
|
||||
}
|
||||
|
||||
/// Cheap listing surfaced in the AI chat system prompt — no `instructions` body.
|
||||
#[derive(Serialize)]
|
||||
pub struct SkillListItem {
|
||||
pub name: String,
|
||||
pub description: String,
|
||||
}
|
||||
|
||||
/// Full skill, including the SKILL.md body, fetched on demand by `read_skill`.
|
||||
#[derive(Serialize)]
|
||||
pub struct Skill {
|
||||
pub name: String,
|
||||
pub description: String,
|
||||
pub instructions: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct UploadSkills {
|
||||
pub skills: Vec<SkillUpload>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct SkillUpload {
|
||||
pub name: String,
|
||||
pub description: String,
|
||||
pub instructions: String,
|
||||
}
|
||||
|
||||
const MAX_SKILLS_PER_UPLOAD: usize = 50;
|
||||
// Every stored skill's name + description is advertised in the global AI chat
|
||||
// system prompt, so bound the total a workspace can accumulate across uploads.
|
||||
const MAX_SKILLS_PER_WORKSPACE: usize = 100;
|
||||
// `name` and `description` follow the Claude SKILL.md spec
|
||||
// (https://platform.claude.com/docs/en/agents-and-tools/agent-skills): both are
|
||||
// loaded into the AI chat system prompt and `name` is the model-facing skill id,
|
||||
// so matching the upstream limits keeps skills portable with Claude Code.
|
||||
const MAX_SKILL_NAME_CHARS: usize = 64;
|
||||
const MAX_SKILL_DESCRIPTION_CHARS: usize = 1_024;
|
||||
// Not a spec field — a payload bound on the SKILL.md body, so measured in bytes.
|
||||
const MAX_SKILL_INSTRUCTIONS_BYTES: usize = 64 * 1024;
|
||||
|
||||
fn validate_skill(skill: &SkillUpload) -> Result<()> {
|
||||
let name = skill.name.trim();
|
||||
if name.is_empty() || name.chars().count() > MAX_SKILL_NAME_CHARS {
|
||||
return Err(Error::BadRequest(format!(
|
||||
"skill name must be between 1 and {MAX_SKILL_NAME_CHARS} characters, got {:?}",
|
||||
skill.name
|
||||
)));
|
||||
}
|
||||
if !name
|
||||
.chars()
|
||||
.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
|
||||
{
|
||||
return Err(Error::BadRequest(format!(
|
||||
"skill name {name:?} must only contain lowercase letters, digits or '-'"
|
||||
)));
|
||||
}
|
||||
if skill.description.trim().is_empty() {
|
||||
return Err(Error::BadRequest(format!(
|
||||
"skill {name:?} is missing a description (the SKILL.md frontmatter `description`)"
|
||||
)));
|
||||
}
|
||||
if skill.description.chars().count() > MAX_SKILL_DESCRIPTION_CHARS {
|
||||
return Err(Error::BadRequest(format!(
|
||||
"skill {name:?} description must be at most {MAX_SKILL_DESCRIPTION_CHARS} characters"
|
||||
)));
|
||||
}
|
||||
if skill.instructions.trim().is_empty() {
|
||||
return Err(Error::BadRequest(format!(
|
||||
"skill {name:?} has an empty SKILL.md body"
|
||||
)));
|
||||
}
|
||||
if skill.instructions.len() > MAX_SKILL_INSTRUCTIONS_BYTES {
|
||||
return Err(Error::BadRequest(format!(
|
||||
"skill {name:?} instructions must be at most {MAX_SKILL_INSTRUCTIONS_BYTES} bytes"
|
||||
)));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Collect the trimmed skill names, rejecting duplicates within a single upload.
|
||||
/// The insert upserts by name, so a duplicate would silently keep only the last
|
||||
/// and make the reported/audited count wrong.
|
||||
fn collect_upload_names(skills: &[SkillUpload]) -> Result<Vec<String>> {
|
||||
let mut names = Vec::with_capacity(skills.len());
|
||||
let mut seen = HashSet::with_capacity(skills.len());
|
||||
for skill in skills {
|
||||
let name = skill.name.trim().to_string();
|
||||
if !seen.insert(name.clone()) {
|
||||
return Err(Error::BadRequest(format!(
|
||||
"duplicate skill name {name:?} in upload"
|
||||
)));
|
||||
}
|
||||
names.push(name);
|
||||
}
|
||||
Ok(names)
|
||||
}
|
||||
|
||||
/// Reject an upload that would push the workspace past `MAX_SKILLS_PER_WORKSPACE`.
|
||||
/// Uploads upsert, so names already present (`replacing`) don't count as new.
|
||||
fn check_workspace_skill_capacity(
|
||||
existing_total: i64,
|
||||
replacing: i64,
|
||||
upload_count: usize,
|
||||
) -> Result<()> {
|
||||
let new_count = upload_count as i64 - replacing;
|
||||
if existing_total + new_count > MAX_SKILLS_PER_WORKSPACE as i64 {
|
||||
return Err(Error::BadRequest(format!(
|
||||
"workspace cannot store more than {MAX_SKILLS_PER_WORKSPACE} skills"
|
||||
)));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn list_skills(
|
||||
authed: ApiAuthed,
|
||||
Extension(user_db): Extension<UserDB>,
|
||||
Path(w_id): Path<String>,
|
||||
) -> JsonResult<Vec<SkillListItem>> {
|
||||
let mut tx = user_db.begin(&authed).await?;
|
||||
let rows = sqlx::query!(
|
||||
"SELECT name, description FROM ai_skill WHERE workspace_id = $1 ORDER BY name",
|
||||
&w_id
|
||||
)
|
||||
.fetch_all(&mut *tx)
|
||||
.await?;
|
||||
tx.commit().await?;
|
||||
|
||||
Ok(Json(
|
||||
rows.into_iter()
|
||||
.map(|r| SkillListItem { name: r.name, description: r.description })
|
||||
.collect(),
|
||||
))
|
||||
}
|
||||
|
||||
async fn get_skill(
|
||||
authed: ApiAuthed,
|
||||
Extension(user_db): Extension<UserDB>,
|
||||
Path((w_id, name)): Path<(String, String)>,
|
||||
) -> JsonResult<Skill> {
|
||||
let mut tx = user_db.begin(&authed).await?;
|
||||
let row = sqlx::query!(
|
||||
"SELECT name, description, instructions FROM ai_skill WHERE workspace_id = $1 AND name = $2",
|
||||
&w_id,
|
||||
&name
|
||||
)
|
||||
.fetch_optional(&mut *tx)
|
||||
.await?;
|
||||
tx.commit().await?;
|
||||
|
||||
row.map(|r| {
|
||||
Json(Skill { name: r.name, description: r.description, instructions: r.instructions })
|
||||
})
|
||||
.ok_or_else(|| Error::NotFound(format!("no skill named {name:?} in workspace {w_id}")))
|
||||
}
|
||||
|
||||
/// Bulk upsert the uploaded skills by name. Existing skills not in the payload
|
||||
/// are left untouched — removal goes through `delete_skill`.
|
||||
async fn upload_skills(
|
||||
authed: ApiAuthed,
|
||||
Extension(db): Extension<DB>,
|
||||
Path(w_id): Path<String>,
|
||||
Json(payload): Json<UploadSkills>,
|
||||
) -> Result<String> {
|
||||
require_admin(authed.is_admin, &authed.username)?;
|
||||
|
||||
if payload.skills.is_empty() {
|
||||
return Err(Error::BadRequest("no skills to upload".to_string()));
|
||||
}
|
||||
if payload.skills.len() > MAX_SKILLS_PER_UPLOAD {
|
||||
return Err(Error::BadRequest(format!(
|
||||
"cannot upload more than {MAX_SKILLS_PER_UPLOAD} skills at a time"
|
||||
)));
|
||||
}
|
||||
for skill in &payload.skills {
|
||||
validate_skill(skill)?;
|
||||
}
|
||||
let names = collect_upload_names(&payload.skills)?;
|
||||
|
||||
let mut tx = db.begin().await?;
|
||||
let counts = sqlx::query!(
|
||||
r#"SELECT
|
||||
COUNT(*)::bigint AS "total!",
|
||||
COUNT(*) FILTER (WHERE name = ANY($2::text[]))::bigint AS "replacing!"
|
||||
FROM ai_skill
|
||||
WHERE workspace_id = $1"#,
|
||||
&w_id,
|
||||
&names
|
||||
)
|
||||
.fetch_one(&mut *tx)
|
||||
.await?;
|
||||
check_workspace_skill_capacity(counts.total, counts.replacing, names.len())?;
|
||||
|
||||
for (skill, name) in payload.skills.iter().zip(names.iter()) {
|
||||
sqlx::query!(
|
||||
r#"INSERT INTO ai_skill (workspace_id, name, description, instructions, edited_at, edited_by)
|
||||
VALUES ($1, $2, $3, $4, now(), $5)
|
||||
ON CONFLICT (workspace_id, name) DO UPDATE
|
||||
SET description = EXCLUDED.description,
|
||||
instructions = EXCLUDED.instructions,
|
||||
edited_at = now(),
|
||||
edited_by = EXCLUDED.edited_by"#,
|
||||
&w_id,
|
||||
name,
|
||||
skill.description,
|
||||
skill.instructions,
|
||||
&authed.username,
|
||||
)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
}
|
||||
|
||||
let audit_resource = names.join(",");
|
||||
audit_log(
|
||||
&mut *tx,
|
||||
&authed,
|
||||
"ai_skills.upload",
|
||||
ActionKind::Update,
|
||||
&w_id,
|
||||
Some(&audit_resource),
|
||||
Some([("skill_count", &names.len().to_string()[..])].into()),
|
||||
)
|
||||
.await?;
|
||||
tx.commit().await?;
|
||||
|
||||
Ok(format!(
|
||||
"Uploaded {} skill(s) to workspace {}",
|
||||
payload.skills.len(),
|
||||
&w_id
|
||||
))
|
||||
}
|
||||
|
||||
async fn delete_skill(
|
||||
authed: ApiAuthed,
|
||||
Extension(db): Extension<DB>,
|
||||
Path((w_id, name)): Path<(String, String)>,
|
||||
) -> Result<String> {
|
||||
require_admin(authed.is_admin, &authed.username)?;
|
||||
|
||||
let mut tx = db.begin().await?;
|
||||
let deleted = sqlx::query_scalar!(
|
||||
"DELETE FROM ai_skill WHERE workspace_id = $1 AND name = $2 RETURNING name",
|
||||
&w_id,
|
||||
&name
|
||||
)
|
||||
.fetch_optional(&mut *tx)
|
||||
.await?;
|
||||
|
||||
if deleted.is_none() {
|
||||
tx.commit().await?;
|
||||
return Err(Error::NotFound(format!(
|
||||
"no skill named {name:?} in workspace {w_id}"
|
||||
)));
|
||||
}
|
||||
|
||||
audit_log(
|
||||
&mut *tx,
|
||||
&authed,
|
||||
"ai_skills.delete",
|
||||
ActionKind::Delete,
|
||||
&w_id,
|
||||
Some(&name),
|
||||
None,
|
||||
)
|
||||
.await?;
|
||||
tx.commit().await?;
|
||||
|
||||
Ok(format!("Deleted skill {name} from workspace {w_id}"))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn skill() -> SkillUpload {
|
||||
SkillUpload {
|
||||
name: "test-skill".to_string(),
|
||||
description: "Useful for tests".to_string(),
|
||||
instructions: "# Test\n\nDo the thing.".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_skill_rejects_oversized_description() {
|
||||
let mut skill = skill();
|
||||
skill.description = "x".repeat(MAX_SKILL_DESCRIPTION_CHARS + 1);
|
||||
|
||||
assert!(matches!(validate_skill(&skill), Err(Error::BadRequest(_))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_skill_rejects_oversized_instructions() {
|
||||
let mut skill = skill();
|
||||
skill.instructions = "x".repeat(MAX_SKILL_INSTRUCTIONS_BYTES + 1);
|
||||
|
||||
assert!(matches!(validate_skill(&skill), Err(Error::BadRequest(_))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_skill_rejects_oversized_name() {
|
||||
let mut skill = skill();
|
||||
skill.name = "a".repeat(MAX_SKILL_NAME_CHARS + 1);
|
||||
|
||||
assert!(matches!(validate_skill(&skill), Err(Error::BadRequest(_))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_skill_rejects_non_slug_name() {
|
||||
// Uppercase, underscore, space and punctuation are all outside the
|
||||
// Claude SKILL.md `[a-z0-9-]` name charset.
|
||||
for bad in ["My-Skill", "my_skill", "my skill", "skill!"] {
|
||||
let mut skill = skill();
|
||||
skill.name = bad.to_string();
|
||||
|
||||
assert!(
|
||||
matches!(validate_skill(&skill), Err(Error::BadRequest(_))),
|
||||
"{bad:?} should be rejected"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_skill_counts_description_in_characters() {
|
||||
// 1024 two-byte chars exceed the byte limit but sit exactly on the
|
||||
// character limit, so they must be accepted.
|
||||
let mut skill = skill();
|
||||
skill.description = "é".repeat(MAX_SKILL_DESCRIPTION_CHARS);
|
||||
|
||||
assert!(validate_skill(&skill).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn workspace_capacity_allows_replacement_at_cap() {
|
||||
// Already at the cap, but the upload only replaces an existing skill.
|
||||
let at_cap = MAX_SKILLS_PER_WORKSPACE as i64;
|
||||
assert!(check_workspace_skill_capacity(at_cap, 1, 1).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn workspace_capacity_rejects_new_skill_over_cap() {
|
||||
let at_cap = MAX_SKILLS_PER_WORKSPACE as i64;
|
||||
assert!(matches!(
|
||||
check_workspace_skill_capacity(at_cap, 0, 1),
|
||||
Err(Error::BadRequest(_))
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn collect_upload_names_trims_and_collects() {
|
||||
let names = collect_upload_names(&[skill()]).unwrap();
|
||||
assert_eq!(names, vec!["test-skill".to_string()]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn collect_upload_names_rejects_duplicates() {
|
||||
// Names are compared after trimming, so whitespace can't smuggle a dup in.
|
||||
let dup = SkillUpload { name: " test-skill ".to_string(), ..skill() };
|
||||
assert!(matches!(
|
||||
collect_upload_names(&[skill(), dup]),
|
||||
Err(Error::BadRequest(_))
|
||||
));
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -219,12 +219,16 @@ struct DatabaseCheckResult {
|
||||
|
||||
async fn check_database_with_latency(db: &DB) -> DatabaseCheckResult {
|
||||
let start = std::time::Instant::now();
|
||||
// `pg_is_in_recovery()` is true on standbys/read-only replicas, so a primary
|
||||
// returns true here. A read-only replica (e.g. after a failover where the
|
||||
// primary became a secondary) reports unhealthy, letting liveness probes
|
||||
// restart the pod instead of silently failing all writes.
|
||||
let healthy = tokio::time::timeout(
|
||||
HEALTH_CHECK_TIMEOUT,
|
||||
sqlx::query_scalar!("SELECT 1").fetch_one(db),
|
||||
sqlx::query_scalar!("SELECT NOT pg_is_in_recovery()").fetch_one(db),
|
||||
)
|
||||
.await
|
||||
.map(|r| r.is_ok())
|
||||
.map(|r| matches!(r, Ok(Some(true))))
|
||||
.unwrap_or(false);
|
||||
let latency_ms = start.elapsed().as_millis() as i64;
|
||||
|
||||
|
||||
@@ -513,6 +513,26 @@ async fn cancel_job_api(
|
||||
Path((w_id, id)): Path<(String, Uuid)>,
|
||||
Json(CancelJob { reason }): Json<CancelJob>,
|
||||
) -> error::Result<String> {
|
||||
// App embed tokens (the sandboxed app iframe) may cancel ONLY jobs they launched
|
||||
// — their app's component runs, stamped created_by == viewer. cancel_job_api has
|
||||
// no other per-job ownership check, so without this an embed token (which carries
|
||||
// the viewer's identity) could cancel any job by id. NotFound (not 403) so the
|
||||
// untrusted app can't probe job existence.
|
||||
if let Some(authed) = opt_authed.as_ref() {
|
||||
if windmill_api_auth::scopes::has_app_embed_sentinel(authed.scopes.as_deref()) {
|
||||
let created_by = sqlx::query_scalar!(
|
||||
"SELECT created_by FROM v2_job WHERE id = $1 AND workspace_id = $2",
|
||||
id,
|
||||
&w_id
|
||||
)
|
||||
.fetch_optional(&db)
|
||||
.await?;
|
||||
if created_by.as_deref() != Some(authed.username.as_str()) {
|
||||
return Err(Error::NotFound(format!("Job {id} not found")));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let tx = db.begin().await?;
|
||||
|
||||
let audit_author: AuditAuthor = match opt_authed.as_ref() {
|
||||
@@ -1007,6 +1027,17 @@ async fn require_job_read_access(
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// App embed tokens (the sandboxed app iframe) carry the viewer's identity so the
|
||||
// app can read its own component runs — which are stamped `created_by == viewer`
|
||||
// and so already returned above. They must NOT inherit the viewer's *broader*
|
||||
// job access (share links, folder ACLs, admin RLS): user-authored app JS holds
|
||||
// this token, and letting it reach any job merely visible to the viewer would
|
||||
// expose unrelated runs' results/logs. Stop at the launched-by-viewer grant.
|
||||
// NotFound (not PermissionDenied) so the untrusted app can't probe job existence.
|
||||
if windmill_api_auth::scopes::has_app_embed_sentinel(authed.scopes.as_deref()) {
|
||||
return Err(Error::NotFound(format!("Job {job_id} not found")));
|
||||
}
|
||||
|
||||
// `username_override` is derived from the token *label* (`username_override_from_label`),
|
||||
// which is fully user-controlled with no uniqueness/ownership check (webhook-/http-/
|
||||
// email-/ws- trigger tokens, `ephemeral-script-end-user-*`, and the generic `label-*`
|
||||
@@ -4022,11 +4053,17 @@ fn conditionally_require_authed_user(
|
||||
}
|
||||
|
||||
pub async fn create_job_signature(
|
||||
_authed: ApiAuthed,
|
||||
authed: ApiAuthed,
|
||||
Extension(db): Extension<DB>,
|
||||
Path((w_id, job_id, resume_id)): Path<(String, Uuid, u32)>,
|
||||
Query(approver): Query<QueryApprover>,
|
||||
) -> error::Result<String> {
|
||||
// The HMAC is treated as full authority by the resume endpoints, so minting
|
||||
// it requires run scope on the suspended job's flow — not merely any
|
||||
// jobs:run scope. No-op for unscoped tokens (incl. the in-flow substep token
|
||||
// used by wmill.get_resume_urls()).
|
||||
let flow_path = resume_target_flow_path(&db, &w_id, job_id).await?;
|
||||
check_scopes(&authed, || format!("jobs:run:flows:{}", flow_path))?;
|
||||
let key = get_workspace_key(&w_id, &db).await?;
|
||||
create_signature(key, job_id, resume_id, approver.approver)
|
||||
}
|
||||
@@ -4109,11 +4146,17 @@ fn build_resume_url(
|
||||
}
|
||||
|
||||
pub async fn get_resume_urls(
|
||||
_authed: ApiAuthed,
|
||||
authed: ApiAuthed,
|
||||
Extension(db): Extension<DB>,
|
||||
Path((w_id, job_id, resume_id)): Path<(String, Uuid, u32)>,
|
||||
Query(approver): Query<QueryApprover>,
|
||||
) -> error::JsonResult<ResumeUrls> {
|
||||
// These URLs embed a resume signature (full resume capability), so a scoped
|
||||
// token must hold run scope on the suspended job's flow. No-op for unscoped
|
||||
// tokens (incl. the in-flow substep token). Trusted internal callers use
|
||||
// get_resume_urls_internal directly and are unaffected.
|
||||
let flow_path = resume_target_flow_path(&db, &w_id, job_id).await?;
|
||||
check_scopes(&authed, || format!("jobs:run:flows:{}", flow_path))?;
|
||||
get_resume_urls_internal(
|
||||
Extension(db),
|
||||
Path((w_id, job_id, resume_id)),
|
||||
@@ -4180,6 +4223,46 @@ pub async fn get_resume_urls_internal(
|
||||
Ok(Json(res))
|
||||
}
|
||||
|
||||
/// Resolve the runnable path of the flow a (possibly step) job belongs to, used
|
||||
/// to scope-check resume-signature minting against `jobs:run:flows:<path>`.
|
||||
/// Returns an empty string when the path can't be resolved (e.g. previews or an
|
||||
/// unknown job); an empty path only matters for path-restricted tokens, which
|
||||
/// would not be running such a flow. Never hard-fails, so it can't break resume
|
||||
/// for unscoped tokens (the in-flow `get_resume_urls()` path).
|
||||
async fn resume_target_flow_path(db: &DB, w_id: &str, job_id: Uuid) -> error::Result<String> {
|
||||
let job = sqlx::query!(
|
||||
r#"SELECT kind::text as "kind!", parent_job, runnable_path
|
||||
FROM v2_job WHERE id = $1 AND workspace_id = $2"#,
|
||||
job_id,
|
||||
w_id
|
||||
)
|
||||
.fetch_optional(db)
|
||||
.await?;
|
||||
let Some(job) = job else {
|
||||
return Ok(String::new());
|
||||
};
|
||||
// All flow kinds: the job itself is the flow whose path scopes the resume.
|
||||
if matches!(
|
||||
job.kind.as_str(),
|
||||
"flow" | "flowpreview" | "flownode" | "singlestepflow"
|
||||
) {
|
||||
return Ok(job.runnable_path.unwrap_or_default());
|
||||
}
|
||||
// Otherwise it's a step; its parent is the flow.
|
||||
if let Some(parent) = job.parent_job {
|
||||
return Ok(sqlx::query_scalar!(
|
||||
"SELECT runnable_path FROM v2_job WHERE id = $1 AND workspace_id = $2",
|
||||
parent,
|
||||
w_id
|
||||
)
|
||||
.fetch_optional(db)
|
||||
.await?
|
||||
.flatten()
|
||||
.unwrap_or_default());
|
||||
}
|
||||
Ok(job.runnable_path.unwrap_or_default())
|
||||
}
|
||||
|
||||
/// Get the flow ID for a job. If the job is a flow, returns the job_id.
|
||||
/// If the job is a step in a flow, returns the parent flow ID.
|
||||
async fn get_flow_id_for_job(db: &DB, job_id: Uuid) -> error::Result<Uuid> {
|
||||
@@ -9488,16 +9571,31 @@ mod approval_view_gate_tests {
|
||||
fn anonymous_cannot_view_when_auth_required() {
|
||||
// The regression: an unauthenticated holder of the approval token must see nothing.
|
||||
let c = Some(conds(true, vec![]));
|
||||
assert!(!can_view(&None, &c, Some("f/team/flow"), "trigger@example.com"));
|
||||
assert!(!can_view(
|
||||
&None,
|
||||
&c,
|
||||
Some("f/team/flow"),
|
||||
"trigger@example.com"
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn anonymous_can_view_when_no_auth_required() {
|
||||
// Unchanged behaviour: token alone is sufficient when auth isn't required.
|
||||
let c = Some(conds(false, vec![]));
|
||||
assert!(can_view(&None, &c, Some("f/team/flow"), "trigger@example.com"));
|
||||
assert!(can_view(
|
||||
&None,
|
||||
&c,
|
||||
Some("f/team/flow"),
|
||||
"trigger@example.com"
|
||||
));
|
||||
// No approval conditions at all also allows token-only view.
|
||||
assert!(can_view(&None, &None, Some("f/team/flow"), "trigger@example.com"));
|
||||
assert!(can_view(
|
||||
&None,
|
||||
&None,
|
||||
Some("f/team/flow"),
|
||||
"trigger@example.com"
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -9522,7 +9620,17 @@ mod approval_view_gate_tests {
|
||||
let member = Some(authed("carol", false, vec!["approvers".to_string()]));
|
||||
let outsider = Some(authed("dave", false, vec!["other".to_string()]));
|
||||
// Use a non-owned folder path so ownership doesn't short-circuit the check.
|
||||
assert!(can_view(&member, &c, Some("f/team/flow"), "trigger@example.com"));
|
||||
assert!(!can_view(&outsider, &c, Some("f/team/flow"), "trigger@example.com"));
|
||||
assert!(can_view(
|
||||
&member,
|
||||
&c,
|
||||
Some("f/team/flow"),
|
||||
"trigger@example.com"
|
||||
));
|
||||
assert!(!can_view(
|
||||
&outsider,
|
||||
&c,
|
||||
Some("f/team/flow"),
|
||||
"trigger@example.com"
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -66,6 +66,7 @@ use crate::scim_oss::has_scim_token;
|
||||
use windmill_common::error::AppError;
|
||||
|
||||
mod ai;
|
||||
mod ai_skills;
|
||||
mod apps;
|
||||
pub mod args;
|
||||
mod audit;
|
||||
@@ -545,7 +546,15 @@ pub async fn run_server(
|
||||
Router::new()
|
||||
// Reordered alphabetically
|
||||
.nest("/acls", granular_acls::workspaced_service())
|
||||
.nest("/apps", apps::workspaced_service(request_size_limit * 5))
|
||||
// CORS so the opaque-origin in-workspace app viewer (WIN-2006,
|
||||
// sandboxed /apps/get) can read the app definition by path
|
||||
// (apps/get/p, apps/embed_token/p) with a scoped embed token.
|
||||
// Bearer-token-only (no cookies), consistent with the other
|
||||
// workspaced services the iframe calls.
|
||||
.nest(
|
||||
"/apps",
|
||||
apps::workspaced_service(request_size_limit * 5).layer(cors.clone()),
|
||||
)
|
||||
.nest("/assets", windmill_api_assets::workspaced_service())
|
||||
.nest("/audit", audit::workspaced_service())
|
||||
.nest("/capture", capture::workspaced_service())
|
||||
@@ -565,7 +574,13 @@ pub async fn run_server(
|
||||
"/flow_conversations",
|
||||
windmill_api_flow_conversations::workspaced_service(),
|
||||
)
|
||||
.nest("/folders", folders::workspaced_service())
|
||||
// CORS so an opaque-origin app iframe (WIN-2006 embed,
|
||||
// no separate domain) can read folders/listnames with a
|
||||
// scoped embed token. Consistent with apps_u/jobs_u cors.
|
||||
.nest(
|
||||
"/folders",
|
||||
folders::workspaced_service().layer(cors.clone()),
|
||||
)
|
||||
.nest("/folders_history", folder_history::workspaced_service())
|
||||
.nest("/groups", groups::workspaced_service())
|
||||
.nest("/groups_history", group_history::workspaced_service())
|
||||
@@ -608,20 +623,30 @@ pub async fn run_server(
|
||||
Router::new()
|
||||
})
|
||||
.nest("/ai", ai::workspaced_service())
|
||||
.nest("/ai_skills", ai_skills::workspaced_service())
|
||||
.nest("/npm_proxy", windmill_api_npm_proxy::workspaced_service())
|
||||
.nest(
|
||||
"/path_autocomplete",
|
||||
path_autocomplete::workspaced_service(),
|
||||
)
|
||||
.nest("/raw_apps", raw_apps::workspaced_service())
|
||||
.nest("/resources", resources::workspaced_service())
|
||||
// CORS so the opaque-origin app iframe can read
|
||||
// resources/list, resources/type/* with a scoped token.
|
||||
.nest(
|
||||
"/resources",
|
||||
resources::workspaced_service().layer(cors.clone()),
|
||||
)
|
||||
.nest("/shared_ui", workspace_shared_ui::workspaced_service())
|
||||
.nest("/schedules", windmill_api_schedule::workspaced_service())
|
||||
.nest("/scripts", scripts::workspaced_service())
|
||||
.nest("/trash", trash::workspaced_service())
|
||||
.nest(
|
||||
"/users",
|
||||
users::workspaced_service().layer(Extension(argon2.clone())),
|
||||
// CORS so the opaque-origin app iframe can read
|
||||
// users/whoami with a scoped embed token.
|
||||
users::workspaced_service()
|
||||
.layer(Extension(argon2.clone()))
|
||||
.layer(cors.clone()),
|
||||
)
|
||||
.nest("/variables", variables::workspaced_service())
|
||||
.nest("/volumes", volumes_oss::workspaced_service())
|
||||
@@ -727,7 +752,11 @@ pub async fn run_server(
|
||||
.nest("/apps_u", {
|
||||
#[cfg(feature = "enterprise")]
|
||||
{
|
||||
apps_oss::global_unauthed_service()
|
||||
// CORS so the opaque-origin app viewer (WIN-2006 embed, no
|
||||
// separate domain) can load a custom-path public app via
|
||||
// public_app_by_custom_path cross-origin. Consistent with
|
||||
// the workspaced /w/{workspace_id}/apps_u mount below.
|
||||
apps_oss::global_unauthed_service().layer(cors.clone())
|
||||
}
|
||||
|
||||
#[cfg(not(feature = "enterprise"))]
|
||||
|
||||
@@ -18,6 +18,7 @@ use windmill_common::{
|
||||
};
|
||||
|
||||
use crate::db::ApiAuthed;
|
||||
use windmill_mcp::parse_mcp_scopes;
|
||||
|
||||
/// Token expiration for MCP OAuth tokens (1 week in seconds)
|
||||
const MCP_OAUTH_TOKEN_EXPIRATION_SECS: u64 = 7 * 24 * 60 * 60;
|
||||
@@ -585,6 +586,8 @@ async fn handle_refresh_token_grant(
|
||||
Some(&new_access_token)
|
||||
};
|
||||
let new_refresh_token = rd_string(32);
|
||||
// Re-issues the already-approved (hence already-contained) scopes verbatim;
|
||||
// containment is enforced once at approval time, so no re-check here.
|
||||
let scopes = token_row.scopes;
|
||||
|
||||
// Create new access token (rejects archived workspaces inline)
|
||||
@@ -820,6 +823,40 @@ async fn oauth_approve_inner(
|
||||
.map(|s| s.to_string())
|
||||
.collect();
|
||||
|
||||
// The approver's own token bounds what it may grant: a scope-restricted MCP
|
||||
// token must not approve a broader one (e.g. mcp:scripts:f/x -> mcp:all). An
|
||||
// unrestricted approver (interactive session, scopes None) grants freely,
|
||||
// which is the normal consent flow. This is the legitimate MCP-narrowing
|
||||
// path, so it uses MCP-pattern containment rather than the byte-identical
|
||||
// rule ensure_scopes_within_caller applies on the generic token endpoints.
|
||||
let caller_restricted = authed
|
||||
.scopes
|
||||
.as_deref()
|
||||
.is_some_and(|s| s.iter().any(|x| !x.starts_with("if_jobs:filter_tags:")));
|
||||
if caller_restricted {
|
||||
// An empty grant would mint a token the auth layer treats as unscoped
|
||||
// (full privileges), so a restricted approver must not produce one.
|
||||
if scopes.is_empty() {
|
||||
return Err(Error::NotAuthorized(
|
||||
"A scope-restricted token cannot approve an empty scope grant".to_string(),
|
||||
));
|
||||
}
|
||||
if scopes.iter().any(|s| !s.starts_with("mcp:")) {
|
||||
return Err(Error::NotAuthorized(
|
||||
"A scope-restricted token can only approve MCP (mcp:*) scopes".to_string(),
|
||||
));
|
||||
}
|
||||
let caller_config = parse_mcp_scopes(authed.scopes.as_deref().unwrap_or(&[]))
|
||||
.map_err(|e| Error::InternalErr(format!("Failed to parse caller MCP scopes: {e}")))?;
|
||||
let requested_config = parse_mcp_scopes(&scopes)
|
||||
.map_err(|e| Error::BadRequest(format!("Failed to parse requested MCP scopes: {e}")))?;
|
||||
if !caller_config.contains(&requested_config) {
|
||||
return Err(Error::NotAuthorized(
|
||||
"Requested scopes exceed the approving token's own MCP scopes".to_string(),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
sqlx::query!(
|
||||
"INSERT INTO mcp_oauth_server_code
|
||||
(code, client_id, user_email, workspace_id, scopes, redirect_uri, code_challenge, code_challenge_method)
|
||||
|
||||
@@ -455,9 +455,35 @@ pub async fn create_http_request(
|
||||
}
|
||||
};
|
||||
|
||||
// Bound the minted JWT to exactly this proxied route so a scope-restricted
|
||||
// MCP token can't be widened into a full-privilege blank check. The
|
||||
// endpoint-name gate (in the MCP runner) already authorized *which* endpoint
|
||||
// may be called; this constrains what the resulting request can do. Unscoped
|
||||
// callers (cookie / full-privilege tokens) keep an unscoped JWT to preserve
|
||||
// existing behavior. A scope-restricted caller whose route can't be resolved
|
||||
// fails closed.
|
||||
let caller_restricted = api_authed
|
||||
.scopes
|
||||
.as_deref()
|
||||
.is_some_and(|s| s.iter().any(|x| !x.starts_with("if_jobs:filter_tags:")));
|
||||
let scopes = if caller_restricted {
|
||||
let parsed = reqwest::Url::parse(url)
|
||||
.map_err(|e| ErrorData::internal_error(format!("Invalid proxied URL: {}", e), None))?;
|
||||
let scope =
|
||||
windmill_api_auth::scopes::scope_for_route(method, parsed.path()).ok_or_else(|| {
|
||||
ErrorData::internal_error(
|
||||
"Could not derive route scope for proxied MCP endpoint".to_string(),
|
||||
None,
|
||||
)
|
||||
})?;
|
||||
Some(vec![scope])
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
// Add authorization header
|
||||
let authed = Authed::from(api_authed.clone());
|
||||
let token = create_jwt_token(authed, workspace_id, 3600, None, None, None, None)
|
||||
let token = create_jwt_token(authed, workspace_id, 3600, None, None, None, scopes)
|
||||
.await
|
||||
.map_err(|e| ErrorData::internal_error(e.to_string(), None))?;
|
||||
request_builder = request_builder.header("Authorization", format!("Bearer {}", token));
|
||||
|
||||
@@ -98,6 +98,7 @@ fn build_standard_scope_domains() -> Vec<ScopeDomain> {
|
||||
("configs", "Configs", "Configuration management", false),
|
||||
("oauth", "OAuth", "OAuth management", false),
|
||||
("ai", "AI", "AI feature management", false),
|
||||
("ai_skills", "AI Skills", "AI skill management", false),
|
||||
(
|
||||
"agent_workers",
|
||||
"Agent Workers",
|
||||
|
||||
@@ -12,6 +12,8 @@ use crate::db::ApiAuthed;
|
||||
|
||||
use crate::{apps::AppWithLastVersion, db::DB, folders::Folder};
|
||||
|
||||
use windmill_api_auth::check_scopes;
|
||||
|
||||
#[cfg(any(
|
||||
feature = "http_trigger",
|
||||
feature = "websocket",
|
||||
@@ -582,6 +584,18 @@ pub(crate) async fn tarball_workspace(
|
||||
skip_resources
|
||||
);
|
||||
|
||||
// The route is gated by workspaces:read, but exporting DECRYPTED secrets is a
|
||||
// variable-read capability beyond workspace metadata. Require variables:read
|
||||
// only on the plaintext-secret path: ordinary tarball pulls (structure and
|
||||
// encrypted-only values) keep working with workspaces:read, and the workspace
|
||||
// key itself stays admin-only (include_key). No-op for unscoped tokens.
|
||||
if plain_secret.or(plain_secrets).unwrap_or(false)
|
||||
&& !skip_secrets.unwrap_or(false)
|
||||
&& !skip_variables.unwrap_or(false)
|
||||
{
|
||||
check_scopes(&authed, || "variables:read".to_string())?;
|
||||
}
|
||||
|
||||
// Opt-in behavior for surfacing per-resource ACLs on flow/app rows.
|
||||
// Folder and group rows have always carried `extra_perms` in source and
|
||||
// continue to do so unconditionally (`KeepEvenEmpty`) so existing
|
||||
@@ -594,6 +608,24 @@ pub(crate) async fn tarball_workspace(
|
||||
|
||||
let mut tx = user_db.begin(&authed).await?;
|
||||
|
||||
// Exporting decrypted secrets in bulk is the same capability as a per-item
|
||||
// secret read, so record it for parity with variables.decrypt_secret.
|
||||
if plain_secret.or(plain_secrets).unwrap_or(false)
|
||||
&& !skip_variables.unwrap_or(false)
|
||||
&& !skip_secrets.unwrap_or(false)
|
||||
{
|
||||
windmill_audit::audit_oss::audit_log(
|
||||
&mut *tx,
|
||||
&authed,
|
||||
"variables.decrypt_secret",
|
||||
windmill_audit::ActionKind::Execute,
|
||||
&w_id,
|
||||
Some("workspace_tarball_export"),
|
||||
None,
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
|
||||
// Source-of-truth for fork-ness: the workspace's parent_workspace_id column.
|
||||
// The wm-fork-* prefix is a creation-time naming convention that could in
|
||||
// principle drift (rename, manual SQL); the column is the contract that
|
||||
|
||||
@@ -38,6 +38,71 @@ impl McpScopeConfig {
|
||||
|
||||
is_resource_allowed(path, patterns)
|
||||
}
|
||||
|
||||
/// Directional subset check: does this config grant at least everything
|
||||
/// `requested` grants? Used to enforce monotonic containment when an MCP
|
||||
/// OAuth approval mints a token (the granted scopes must be within the
|
||||
/// approving token's own scopes).
|
||||
///
|
||||
/// Unlike `is_allowed` (which tests a single concrete path with OR
|
||||
/// semantics), this requires every requested pattern to be covered by some
|
||||
/// caller pattern — so `mcp:scripts:f/x` cannot widen into `mcp:scripts:*`.
|
||||
pub fn contains(&self, requested: &McpScopeConfig) -> bool {
|
||||
if self.all {
|
||||
return true;
|
||||
}
|
||||
if requested.all {
|
||||
return false;
|
||||
}
|
||||
if requested.favorites && !self.favorites {
|
||||
return false;
|
||||
}
|
||||
if let Some(req_hub) = requested.hub_apps.as_ref() {
|
||||
match self.hub_apps.as_ref() {
|
||||
Some(caller_hub) => {
|
||||
let caller_apps: std::collections::HashSet<&str> =
|
||||
caller_hub.split(',').map(|s| s.trim()).collect();
|
||||
if !req_hub
|
||||
.split(',')
|
||||
.map(|s| s.trim())
|
||||
.all(|a| caller_apps.contains(a))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
None => return false,
|
||||
}
|
||||
}
|
||||
resource_list_covers(&self.scripts, &requested.scripts)
|
||||
&& resource_list_covers(&self.flows, &requested.flows)
|
||||
&& resource_list_covers(&self.endpoints, &requested.endpoints)
|
||||
}
|
||||
}
|
||||
|
||||
/// Every requested pattern must be covered by some caller pattern.
|
||||
fn resource_list_covers(caller: &[String], requested: &[String]) -> bool {
|
||||
requested
|
||||
.iter()
|
||||
.all(|req| caller.iter().any(|c| pattern_covers(c, req)))
|
||||
}
|
||||
|
||||
/// Directional: does the single caller pattern cover `requested`? `caller` may
|
||||
/// be `*`, an exact path/name, or a `<prefix>/*` subtree; `requested` may itself
|
||||
/// be a subtree wildcard, in which case the whole requested subtree must fall
|
||||
/// within the caller's. Mirrors the route-scope containment in windmill-api-auth.
|
||||
fn pattern_covers(caller: &str, requested: &str) -> bool {
|
||||
if caller == "*" || caller == requested {
|
||||
return true;
|
||||
}
|
||||
// An exact caller pattern only covers itself (handled above); a wildcard
|
||||
// requested can never be covered by a non-`*` exact caller.
|
||||
let Some(prefix) = caller.strip_suffix("/*") else {
|
||||
return false;
|
||||
};
|
||||
let requested_base = requested.strip_suffix("/*").unwrap_or(requested);
|
||||
requested_base == prefix
|
||||
|| (requested_base.starts_with(prefix)
|
||||
&& requested_base.as_bytes().get(prefix.len()) == Some(&b'/'))
|
||||
}
|
||||
|
||||
/// Parse MCP scopes from token scope strings
|
||||
@@ -254,4 +319,51 @@ mod tests {
|
||||
assert!(config.is_allowed("flow", "f/automation/test"));
|
||||
assert!(!config.is_allowed("flow", "f/other/test"));
|
||||
}
|
||||
|
||||
fn cfg(scopes: &[&str]) -> McpScopeConfig {
|
||||
parse_mcp_scopes(&scopes.iter().map(|s| s.to_string()).collect::<Vec<_>>()).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_contains_subset_and_widening() {
|
||||
// mcp:all contains anything.
|
||||
assert!(cfg(&["mcp:all"]).contains(&cfg(&["mcp:scripts:f/x"])));
|
||||
assert!(cfg(&["mcp:all"]).contains(&cfg(&["mcp:all"])));
|
||||
|
||||
// A wildcard caller covers narrower requests, but not other domains/all.
|
||||
let star = cfg(&["mcp:scripts:*"]);
|
||||
assert!(star.contains(&cfg(&["mcp:scripts:f/x"])));
|
||||
assert!(star.contains(&cfg(&["mcp:scripts:*"])));
|
||||
assert!(!star.contains(&cfg(&["mcp:all"])));
|
||||
assert!(!star.contains(&cfg(&["mcp:flows:f/x"])));
|
||||
|
||||
// The core regression: a single-path caller must NOT widen into `*` or
|
||||
// into another path.
|
||||
let narrow = cfg(&["mcp:scripts:f/x"]);
|
||||
assert!(narrow.contains(&cfg(&["mcp:scripts:f/x"])));
|
||||
assert!(!narrow.contains(&cfg(&["mcp:scripts:*"])));
|
||||
assert!(!narrow.contains(&cfg(&["mcp:scripts:f/y"])));
|
||||
assert!(!narrow.contains(&cfg(&["mcp:all"])));
|
||||
|
||||
// Subtree wildcard covers paths within it but not a sibling subtree.
|
||||
let subtree = cfg(&["mcp:scripts:f/team/*"]);
|
||||
assert!(subtree.contains(&cfg(&["mcp:scripts:f/team/sub"])));
|
||||
assert!(subtree.contains(&cfg(&["mcp:scripts:f/team/sub/*"])));
|
||||
assert!(!subtree.contains(&cfg(&["mcp:scripts:f/other/x"])));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_contains_favorites_and_endpoints() {
|
||||
assert!(cfg(&["mcp:favorites"]).contains(&cfg(&["mcp:favorites"])));
|
||||
// A caller without favorites cannot grant favorites.
|
||||
assert!(!cfg(&["mcp:scripts:*"]).contains(&cfg(&["mcp:favorites"])));
|
||||
|
||||
// Endpoint names match exactly (or via `*`).
|
||||
let ep = cfg(&["mcp:endpoints:getVariable"]);
|
||||
assert!(ep.contains(&cfg(&["mcp:endpoints:getVariable"])));
|
||||
assert!(!ep.contains(&cfg(&["mcp:endpoints:getResource"])));
|
||||
assert!(!ep.contains(&cfg(&["mcp:all"])));
|
||||
// mcp:all grants all endpoints.
|
||||
assert!(cfg(&["mcp:all"]).contains(&cfg(&["mcp:endpoints:getResource"])));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -53,3 +53,7 @@ futures.workspace = true
|
||||
chrono.workspace = true
|
||||
reqwest.workspace = true
|
||||
anyhow.workspace = true
|
||||
base64.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
magic-crypt.workspace = true
|
||||
|
||||
@@ -1634,6 +1634,12 @@ async fn update_resource(
|
||||
|
||||
let path = path.to_path();
|
||||
check_scopes(&authed, || format!("resources:write:{}", path))?;
|
||||
// A rename moves the resource (and its linked variable) to ns.path, so the
|
||||
// destination must also be within the token's write scope, not just the
|
||||
// source path.
|
||||
if let Some(npath) = ns.path.as_deref() {
|
||||
check_scopes(&authed, || format!("resources:write:{}", npath))?;
|
||||
}
|
||||
if let RuleCheckResult::Blocked(msg) = check_deploy_rules(
|
||||
&w_id,
|
||||
AuditAuthorable::username(&authed),
|
||||
|
||||
@@ -14,8 +14,8 @@ use windmill_common::db::DB;
|
||||
use windmill_common::workspaces::{check_deploy_rules, RuleCheckResult};
|
||||
|
||||
use crate::secret_backend_ext::{
|
||||
delete_secret_from_backend, get_secret_value, is_vault_stored_value, rename_vault_secret,
|
||||
store_secret_value,
|
||||
delete_secret_from_backend, get_secret_value, is_external_stored_value, is_vault_stored_value,
|
||||
rename_vault_secret, store_secret_value,
|
||||
};
|
||||
use windmill_common::utils::{escape_ilike_pattern, BulkDeleteRequest};
|
||||
use windmill_common::webhook::{WebhookMessage, WebhookShared};
|
||||
@@ -25,6 +25,7 @@ use axum::{
|
||||
routing::{delete, get, post},
|
||||
Json, Router,
|
||||
};
|
||||
use base64::{engine::general_purpose::STANDARD, Engine as _};
|
||||
use futures::future::try_join_all;
|
||||
use hyper::StatusCode;
|
||||
use serde_json::Value;
|
||||
@@ -535,6 +536,35 @@ async fn check_path_conflict(db: &DB, w_id: &str, path: &str) -> Result<()> {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
/// Reject a secret value flagged as already-encrypted (`already_encrypted=true`)
|
||||
/// that is not actually workspace-key ciphertext — e.g. plaintext mistakenly
|
||||
/// pushed as encrypted. Storing plaintext in the encrypted `value` column
|
||||
/// silently bricks the variable: every later read fails to decrypt it.
|
||||
///
|
||||
/// The check is purely structural and never decrypts, so it cannot act as a
|
||||
/// decryption/padding oracle for a caller who can write but not read secrets.
|
||||
/// `encrypt` (AES-256-CBC) always yields standard base64 decoding to a non-zero
|
||||
/// multiple of the 16-byte block size; anything else cannot be our ciphertext.
|
||||
/// Values stored by an external backend ($vault:/$aws_sm:/$azure_kv: markers)
|
||||
/// are not workspace ciphertext and are passed through untouched.
|
||||
fn validate_already_encrypted_secret(path: &str, value: &str) -> Result<()> {
|
||||
if is_external_stored_value(value) {
|
||||
return Ok(());
|
||||
}
|
||||
let looks_like_ciphertext = STANDARD
|
||||
.decode(value)
|
||||
.map(|bytes| !bytes.is_empty() && bytes.len() % 16 == 0)
|
||||
.unwrap_or(false);
|
||||
if !looks_like_ciphertext {
|
||||
return Err(Error::BadRequest(format!(
|
||||
"Variable {path} was sent as already-encrypted (already_encrypted=true) but its \
|
||||
value is not valid workspace-encrypted ciphertext. To push a plaintext secret, \
|
||||
send it without already_encrypted (CLI: use --plain-secrets) so it gets encrypted."
|
||||
)));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn create_variable(
|
||||
authed: ApiAuthed,
|
||||
Extension(db): Extension<DB>,
|
||||
@@ -585,6 +615,11 @@ async fn create_variable(
|
||||
// Use secret backend for encryption (supports both DB and Vault)
|
||||
store_secret_value(&db, &w_id, &variable.path, &plain).await?
|
||||
} else {
|
||||
if variable.is_secret {
|
||||
// already_encrypted == true: value is stored verbatim, so it must be
|
||||
// ciphertext and not plaintext mislabeled as encrypted.
|
||||
validate_already_encrypted_secret(&variable.path, &variable.value)?;
|
||||
}
|
||||
variable.value
|
||||
};
|
||||
|
||||
@@ -1037,6 +1072,12 @@ async fn update_variable(
|
||||
|
||||
let path = path.to_path();
|
||||
check_scopes(&authed, || format!("variables:write:{}", path))?;
|
||||
// A rename moves the (possibly secret) variable to ns.path, so the
|
||||
// destination must also be within the token's write scope, not just the
|
||||
// source path.
|
||||
if let Some(npath) = ns.path.as_deref() {
|
||||
check_scopes(&authed, || format!("variables:write:{}", npath))?;
|
||||
}
|
||||
let authed = maybe_refresh_folders(&path, &w_id, authed, &db).await;
|
||||
|
||||
let mut sqlb = SqlBuilder::update_table("variable");
|
||||
@@ -1076,6 +1117,11 @@ async fn update_variable(
|
||||
// Store at target_path (new path if renaming, otherwise current path)
|
||||
store_secret_value(&db, &w_id, target_path, &plain).await?
|
||||
} else {
|
||||
if is_secret {
|
||||
// already_encrypted == true: value is stored verbatim, so it must
|
||||
// be ciphertext and not plaintext mislabeled as encrypted.
|
||||
validate_already_encrypted_secret(target_path, &nvalue)?;
|
||||
}
|
||||
nvalue
|
||||
};
|
||||
sqlb.set_str("value", &value);
|
||||
@@ -1507,3 +1553,61 @@ pub async fn get_value_internal<'a>(
|
||||
|
||||
Ok(r)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use magic_crypt::MagicCryptTrait;
|
||||
|
||||
#[test]
|
||||
fn accepts_real_workspace_ciphertext() {
|
||||
// The exact shape produced by `encrypt` (AES-256-CBC, base64).
|
||||
let mc = magic_crypt::new_magic_crypt!("a-test-workspace-key", 256);
|
||||
for plain in [
|
||||
"",
|
||||
"original-secret",
|
||||
"some: plaintext\n",
|
||||
"a".repeat(500).as_str(),
|
||||
] {
|
||||
let ciphertext = mc.encrypt_str_to_base64(plain);
|
||||
assert!(
|
||||
validate_already_encrypted_secret("f/x/cfg", &ciphertext).is_ok(),
|
||||
"should accept genuine ciphertext for plaintext {plain:?}: {ciphertext}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_plaintext_mislabeled_as_encrypted() {
|
||||
// Plaintext mislabeled as encrypted: storing it verbatim would make the
|
||||
// variable undecryptable on every read, so it must be rejected.
|
||||
for plaintext in [
|
||||
"some: plaintext\n",
|
||||
"original-secret",
|
||||
"hunter2",
|
||||
"{\"a\": 1}",
|
||||
"not base64!!",
|
||||
" leading-space",
|
||||
] {
|
||||
assert!(
|
||||
validate_already_encrypted_secret("f/x/cfg", plaintext).is_err(),
|
||||
"should reject plaintext mislabeled as encrypted: {plaintext:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_empty_and_non_block_aligned() {
|
||||
// Valid base64 but not a whole number of AES blocks -> cannot be our ciphertext.
|
||||
assert!(validate_already_encrypted_secret("p", "").is_err());
|
||||
assert!(validate_already_encrypted_secret("p", "dGVzdA==").is_err()); // "test" -> 4 bytes
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn passes_through_external_backend_markers() {
|
||||
// External secret backends store $-prefixed markers, not workspace ciphertext.
|
||||
for marker in ["$vault:f/x/cfg", "$aws_sm:f/x/cfg", "$azure_kv:f/x/cfg"] {
|
||||
assert!(validate_already_encrypted_secret("f/x/cfg", marker).is_ok());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ use axum::{extract::Path, routing::post, Extension, Json, Router};
|
||||
use http::StatusCode;
|
||||
use sqlx::PgConnection;
|
||||
use std::collections::HashSet;
|
||||
use windmill_api_auth::ApiAuthed;
|
||||
use windmill_api_auth::{check_scopes, ApiAuthed};
|
||||
use windmill_audit::{audit_oss::audit_log, ActionKind};
|
||||
use windmill_common::global_settings::HTTP_ROUTE_WORKSPACED_ROUTE;
|
||||
use windmill_common::{
|
||||
@@ -262,6 +262,12 @@ pub async fn create_many_http_triggers(
|
||||
let mut route_path_keys = Vec::with_capacity(new_http_triggers.len());
|
||||
|
||||
for new_http_trigger in new_http_triggers.iter() {
|
||||
// Per-item write scope, matching the single-create handler. The bulk
|
||||
// endpoint must not let a path-scoped token create triggers outside it.
|
||||
check_scopes(&authed, || {
|
||||
format!("http_triggers:write:{}", &new_http_trigger.base.path)
|
||||
})?;
|
||||
|
||||
handler
|
||||
.validate_new(&db, &w_id, &new_http_trigger.config)
|
||||
.await
|
||||
@@ -373,7 +379,8 @@ impl TriggerCrud for HttpTrigger {
|
||||
|
||||
const TABLE_NAME: &'static str = "http_trigger";
|
||||
const TRIGGER_TYPE: &'static str = "http";
|
||||
const DRAFT_KIND: windmill_common::user_drafts::UserDraftItemKind = windmill_common::user_drafts::UserDraftItemKind::TriggerHttp;
|
||||
const DRAFT_KIND: windmill_common::user_drafts::UserDraftItemKind =
|
||||
windmill_common::user_drafts::UserDraftItemKind::TriggerHttp;
|
||||
const SUPPORTS_SERVER_STATE: bool = false;
|
||||
const SUPPORTS_TEST_CONNECTION: bool = false;
|
||||
const ROUTE_PREFIX: &'static str = "/http_triggers";
|
||||
|
||||
@@ -4,7 +4,7 @@ use async_trait::async_trait;
|
||||
use itertools::Itertools;
|
||||
use serde_json::value::RawValue;
|
||||
use sqlx::{types::Json as SqlxJson, PgConnection};
|
||||
use windmill_api_auth::ApiAuthed;
|
||||
use windmill_api_auth::{check_scopes, ApiAuthed};
|
||||
use windmill_common::DB;
|
||||
use windmill_common::{
|
||||
db::UserDB,
|
||||
@@ -15,10 +15,39 @@ use windmill_git_sync::DeployedObject;
|
||||
use windmill_trigger::{Trigger, TriggerCrud, TriggerData};
|
||||
|
||||
use super::{
|
||||
get_url_from_runnable_value, proxy::connect_async_with_proxy, validate_websocket_url_for_ssrf,
|
||||
TestWebsocketConfig, WebsocketConfig, WebsocketConfigRequest, WebsocketTrigger,
|
||||
get_url_from_runnable_value, listener::InitialMessage, proxy::connect_async_with_proxy,
|
||||
validate_websocket_url_for_ssrf, TestWebsocketConfig, WebsocketConfig, WebsocketConfigRequest,
|
||||
WebsocketTrigger,
|
||||
};
|
||||
|
||||
/// A websocket_triggers:write token can configure secondary runnables that the
|
||||
/// listener later executes under the trigger owner's identity: a `$flow:`/
|
||||
/// `$script:` URL resolver and `initial_messages` of kind `runnable_result`.
|
||||
/// That execution happens in a background task where the reconstructed authed is
|
||||
/// scopeless (so its check_scopes is a no-op), so enforce run scope here, at
|
||||
/// create/update time, against the API caller's token.
|
||||
fn check_secondary_runnable_scopes(
|
||||
authed: &ApiAuthed,
|
||||
config: &WebsocketConfigRequest,
|
||||
) -> Result<()> {
|
||||
if let Some(rest) = config.url.strip_prefix("$flow:") {
|
||||
check_scopes(authed, || format!("jobs:run:flows:{}", rest))?;
|
||||
} else if let Some(rest) = config.url.strip_prefix("$script:") {
|
||||
check_scopes(authed, || format!("jobs:run:scripts:{}", rest))?;
|
||||
}
|
||||
if let Some(messages) = config.initial_messages.as_ref() {
|
||||
for msg in messages {
|
||||
if let Ok(InitialMessage::RunnableResult { path, is_flow, .. }) =
|
||||
serde_json::from_value::<InitialMessage>(msg.clone())
|
||||
{
|
||||
let kind = if is_flow { "flows" } else { "scripts" };
|
||||
check_scopes(authed, || format!("jobs:run:{}:{}", kind, path))?;
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl TriggerCrud for WebsocketTrigger {
|
||||
type TriggerConfig = WebsocketConfig;
|
||||
@@ -101,6 +130,7 @@ impl TriggerCrud for WebsocketTrigger {
|
||||
w_id: &str,
|
||||
trigger: TriggerData<Self::TriggerConfigRequest>,
|
||||
) -> Result<()> {
|
||||
check_secondary_runnable_scopes(authed, &trigger.config)?;
|
||||
let resolved_edited_by = trigger.base.resolve_edited_by(authed);
|
||||
let resolved_permissioned_as = trigger.base.resolve_permissioned_as(authed);
|
||||
let filters = trigger
|
||||
@@ -178,6 +208,7 @@ impl TriggerCrud for WebsocketTrigger {
|
||||
path: &str,
|
||||
trigger: TriggerData<Self::TriggerConfigRequest>,
|
||||
) -> Result<()> {
|
||||
check_secondary_runnable_scopes(authed, &trigger.config)?;
|
||||
let resolved_edited_by = trigger.base.resolve_edited_by(authed);
|
||||
let resolved_permissioned_as = trigger.base.resolve_permissioned_as(authed);
|
||||
let filters = trigger
|
||||
|
||||
@@ -509,7 +509,7 @@ impl Clone for ReturnMessageChannels {
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
enum InitialMessage {
|
||||
pub(crate) enum InitialMessage {
|
||||
#[serde(rename = "raw_message")]
|
||||
RawMessage(String),
|
||||
#[serde(rename = "runnable_result")]
|
||||
|
||||
@@ -255,6 +255,14 @@ pub async fn do_duckdb(
|
||||
} else {
|
||||
None
|
||||
};
|
||||
// Parse the signature from the ORIGINAL script: managed materialize wraps
|
||||
// the trailing SELECT and strips line comments, which drops the
|
||||
// `-- $name (type)` arg declarations while their `$name` references
|
||||
// survive in the embedded SELECT. Parsing args here (pre-wrap) keeps them
|
||||
// declared so they are still bound — and s3object args translated to
|
||||
// `s3://` URIs — at run time.
|
||||
let sig = parse_duckdb_sig(query)?.args;
|
||||
|
||||
let materialized_query;
|
||||
let query: &str = match &materialize {
|
||||
Some((Some(rewritten), _)) => {
|
||||
@@ -263,8 +271,6 @@ pub async fn do_duckdb(
|
||||
}
|
||||
_ => query,
|
||||
};
|
||||
|
||||
let sig = parse_duckdb_sig(query)?.args;
|
||||
let mut job_args = build_args_values(job, client, conn).await?;
|
||||
|
||||
let reserved_variables =
|
||||
@@ -1106,6 +1112,40 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
// Managed `// materialize` may take SQL args (e.g. an s3object uploaded on
|
||||
// the run form). The wrap strips line comments — including the
|
||||
// `-- $name (type)` declarations — so the executor parses the signature from
|
||||
// the original script (done above, before the rewrite) while the `$name`
|
||||
// references survive inside the wrapped SELECT. This pins both halves of that
|
||||
// contract so a regression that drops either is caught.
|
||||
#[test]
|
||||
fn materialize_preserves_sql_args() {
|
||||
let script = "-- materialize ducklake://main/rows\n\
|
||||
-- $file (s3object)\n\
|
||||
SELECT * FROM read_json_auto($file)";
|
||||
|
||||
// The signature is recoverable from the original (un-wrapped) script.
|
||||
let sig = parse_duckdb_sig(script).expect("sig parses").args;
|
||||
let file_arg = sig
|
||||
.iter()
|
||||
.find(|a| a.name == "file")
|
||||
.expect("`$file` declared");
|
||||
assert_eq!(file_arg.otyp.as_deref(), Some("s3object"));
|
||||
|
||||
// The wrapped query still references `$file`, so the parsed sig binds it.
|
||||
let (rewritten, _) = build_materialized_query(script, None)
|
||||
.expect("materialize builds")
|
||||
.expect("materialize present");
|
||||
let rewritten = rewritten.expect("managed mode rewrites the query");
|
||||
assert!(
|
||||
rewritten.contains("$file"),
|
||||
"wrapped query must keep the `$file` reference, got:\n{rewritten}"
|
||||
);
|
||||
// The declaration comment is gone (wrap strips line comments) — which is
|
||||
// exactly why the sig must come from the original, not the rewrite.
|
||||
assert!(!rewritten.contains("-- $file"));
|
||||
}
|
||||
|
||||
// Tests for parse_attach_db_resource function
|
||||
#[test]
|
||||
fn test_parse_attach_db_resource_postgres_res_prefix() {
|
||||
|
||||
+1
-1
@@ -2,7 +2,7 @@ import { sleep } from "https://deno.land/x/sleep@v1.2.1/mod.ts";
|
||||
import * as windmill from "https://deno.land/x/windmill@v1.174.0/mod.ts";
|
||||
import * as api from "https://deno.land/x/windmill@v1.174.0/windmill-api/index.ts";
|
||||
|
||||
export const VERSION = "v1.735.0";
|
||||
export const VERSION = "v1.737.0";
|
||||
|
||||
export async function login(email: string, password: string): Promise<string> {
|
||||
return await windmill.UserService.login({
|
||||
|
||||
@@ -99,6 +99,31 @@ export interface VariableFile {
|
||||
is_oauth?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether `value` has the structural shape of a workspace-encrypted secret
|
||||
* (the form produced by `sync pull` without --plain-secrets), as opposed to a
|
||||
* plaintext value a user authored by hand.
|
||||
*
|
||||
* Mirrors the server guard (windmill-store/src/variables.rs): workspace
|
||||
* ciphertext (AES-256-CBC, base64) is standard base64 decoding to a non-zero
|
||||
* multiple of the 16-byte block size. External secret-backend markers
|
||||
* ($vault:/$aws_sm:/$azure_kv:) are stored verbatim too, so they count as
|
||||
* already-encrypted. This is a shape check only — it never decrypts.
|
||||
*/
|
||||
export function looksLikeWorkspaceCiphertext(value: string): boolean {
|
||||
if (
|
||||
value.startsWith("$vault:") ||
|
||||
value.startsWith("$aws_sm:") ||
|
||||
value.startsWith("$azure_kv:")
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
if (value.length === 0 || value.length % 4 !== 0) return false;
|
||||
if (!/^[A-Za-z0-9+/]+={0,2}$/.test(value)) return false;
|
||||
const decodedLen = Buffer.from(value, "base64").length;
|
||||
return decodedLen > 0 && decodedLen % 16 === 0;
|
||||
}
|
||||
|
||||
export async function pushVariable(
|
||||
workspace: string,
|
||||
remotePath: string,
|
||||
@@ -106,6 +131,11 @@ export async function pushVariable(
|
||||
localVariable: VariableFile,
|
||||
plainSecrets: boolean,
|
||||
wsSpecific?: boolean,
|
||||
// Whether a secret->non-secret downgrade may be applied. Only an authoritative
|
||||
// single-file `variable push` sets this. Bulk `sync push` leaves it false: a
|
||||
// pulled secret's spec value is ciphertext, and demoting it would store that
|
||||
// ciphertext verbatim as a visible non-secret value.
|
||||
allowSecretDowngrade: boolean = false,
|
||||
): Promise<void> {
|
||||
remotePath = removeType(remotePath, "variable");
|
||||
log.debug(`Processing local variable ${remotePath}`);
|
||||
@@ -130,14 +160,26 @@ export async function pushVariable(
|
||||
|
||||
log.debug(`Variable ${remotePath} is not up-to-date, updating`);
|
||||
|
||||
// Apply is_secret only when it differs from the remote (the value is always
|
||||
// sent, so the server allows the flag change). Upgrades (non-secret->secret)
|
||||
// always apply; downgrades only when explicitly allowed (single-file push) —
|
||||
// see allowSecretDowngrade. `undefined` leaves the flag untouched.
|
||||
let nextIsSecret: boolean | undefined = undefined;
|
||||
if (localVariable.is_secret !== variable.is_secret) {
|
||||
if (localVariable.is_secret) {
|
||||
nextIsSecret = true;
|
||||
} else if (allowSecretDowngrade) {
|
||||
nextIsSecret = false;
|
||||
}
|
||||
}
|
||||
|
||||
await wmill.updateVariable({
|
||||
workspace,
|
||||
path: remotePath.replaceAll(SEP, "/"),
|
||||
alreadyEncrypted: !plainSecrets,
|
||||
requestBody: {
|
||||
...localVariable,
|
||||
is_secret:
|
||||
localVariable.is_secret && !variable.is_secret ? true : undefined,
|
||||
is_secret: nextIsSecret,
|
||||
...(wsSpecific !== undefined ? { ws_specific: wsSpecific } : {}),
|
||||
},
|
||||
});
|
||||
@@ -174,12 +216,40 @@ async function push(
|
||||
|
||||
log.info(colors.bold.yellow("Pushing variable..."));
|
||||
|
||||
const local = parseFromFile(filePath) as VariableFile;
|
||||
|
||||
// A secret value in a single-file push is authored by the user and is
|
||||
// therefore plaintext that must be encrypted server-side — unless it has the
|
||||
// shape of workspace ciphertext (a value round-tripped from `sync pull`).
|
||||
// Pushing plaintext as already-encrypted would brick the variable. An explicit
|
||||
// --plain-secrets always forces the plaintext (encrypt) path.
|
||||
let plainSecrets = opts.plainSecrets ?? false;
|
||||
if (opts.plainSecrets === undefined && local.is_secret) {
|
||||
if (!looksLikeWorkspaceCiphertext(local.value)) {
|
||||
log.info(
|
||||
colors.yellow(
|
||||
"Secret value is not in encrypted form; pushing as plaintext to be encrypted server-side (pass --plain-secrets to silence)."
|
||||
)
|
||||
);
|
||||
plainSecrets = true;
|
||||
} else {
|
||||
// The value has the shape of workspace ciphertext, so it's stored as-is.
|
||||
// A plaintext secret that coincidentally looks like ciphertext (e.g. a
|
||||
// base64 token) would be stored unreadable, so surface the assumption.
|
||||
log.warn(
|
||||
"Secret value looks already-encrypted; pushing it as-is. If it is a plaintext secret, re-run with --plain-secrets so it gets encrypted."
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
await pushVariable(
|
||||
workspace.workspaceId,
|
||||
remotePath,
|
||||
undefined,
|
||||
parseFromFile(filePath),
|
||||
opts.plainSecrets ?? false
|
||||
local,
|
||||
plainSecrets,
|
||||
undefined,
|
||||
true // single-file push is authoritative: allow secret->non-secret downgrade
|
||||
);
|
||||
log.info(colors.bold.underline.green(`Variable ${remotePath} pushed`));
|
||||
}
|
||||
|
||||
@@ -104,25 +104,25 @@ export async function requireLogin(
|
||||
// 403 means the token authenticated but lacks scope — re-issuing
|
||||
// won't help. Keep this distinct from the 401 message so the user
|
||||
// doesn't waste time reproducing the token.
|
||||
log.info(colors.red(
|
||||
log.infoStderr(colors.red(
|
||||
`Permission denied: the token is valid but lacks the required scope.${bodyStr ? `\n${bodyStr}` : ""}`
|
||||
));
|
||||
} else if (status === 401) {
|
||||
log.info(colors.red(
|
||||
log.infoStderr(colors.red(
|
||||
`Could not authenticate with the provided credentials. Please check your --token and --base-url and try again.${bodyStr ? `\n${bodyStr}` : ""}`
|
||||
));
|
||||
} else {
|
||||
log.info(colors.red(
|
||||
log.infoStderr(colors.red(
|
||||
`Request failed (${status ?? "unknown"}): ${bodyStr}`
|
||||
));
|
||||
}
|
||||
return process.exit(1);
|
||||
}
|
||||
log.info(colors.red("Could not authenticate with the provided credentials. Please check your --token and --base-url and try again."));
|
||||
log.infoStderr(colors.red("Could not authenticate with the provided credentials. Please check your --token and --base-url and try again."));
|
||||
return process.exit(1);
|
||||
}
|
||||
|
||||
log.info(
|
||||
log.infoStderr(
|
||||
"! Could not reach API given existing credentials. Attempting to reauth..."
|
||||
);
|
||||
const newToken = await loginInteractive(workspace.remote);
|
||||
|
||||
@@ -10,4 +10,4 @@ export const WM_FORK_PREFIX = "wm-fork";
|
||||
// (e.g. utils.ts) can read it without importing main.ts and creating a circular
|
||||
// dependency (main → workspace → utils → main) that triggers a TDZ.
|
||||
// Re-exported from main.ts for backwards compatibility.
|
||||
export const VERSION = "1.735.0";
|
||||
export const VERSION = "1.737.0";
|
||||
|
||||
+29
-29
@@ -57,7 +57,7 @@ async function selectFromMultipleProfiles(
|
||||
(p) => p.name === lastUsedProfileName
|
||||
);
|
||||
if (lastUsedProfile) {
|
||||
log.info(
|
||||
log.infoStderr(
|
||||
colors.green(
|
||||
`Using last used profile '${lastUsedProfile.name}' for ${context}`
|
||||
)
|
||||
@@ -69,7 +69,7 @@ async function selectFromMultipleProfiles(
|
||||
// No last used or it no longer exists - prompt for selection
|
||||
if (!!!process.stdin.isTTY || !!!process.stdout.isTTY) {
|
||||
const selectedProfile = profiles[0];
|
||||
log.info(
|
||||
log.infoStderr(
|
||||
colors.yellow(
|
||||
`Multiple profiles found for ${context}. Using first available profile: '${selectedProfile.name}'`
|
||||
)
|
||||
@@ -87,7 +87,7 @@ async function selectFromMultipleProfiles(
|
||||
return selectedProfile;
|
||||
}
|
||||
|
||||
log.info(
|
||||
log.infoStderr(
|
||||
colors.yellow(`\nMultiple workspace profiles found for ${context}:`)
|
||||
);
|
||||
|
||||
@@ -125,14 +125,14 @@ async function createWorkspaceProfileInteractively(
|
||||
): Promise<Workspace | undefined> {
|
||||
// Log appropriate message based on context
|
||||
if (!context.isForked) {
|
||||
log.info(
|
||||
log.infoStderr(
|
||||
colors.yellow(
|
||||
`\nNo workspace profile found for branch '${context.rawBranch}'\n` +
|
||||
`(${normalizedBaseUrl}, ${workspaceId})`
|
||||
)
|
||||
);
|
||||
} else {
|
||||
log.info(
|
||||
log.infoStderr(
|
||||
colors.yellow(
|
||||
`\nNo workspace profile was found for this forked workspace\n` +
|
||||
`(${normalizedBaseUrl}, ${workspaceId})`
|
||||
@@ -141,7 +141,7 @@ async function createWorkspaceProfileInteractively(
|
||||
}
|
||||
|
||||
if (!!!process.stdin.isTTY || !!!process.stdout.isTTY) {
|
||||
log.info(
|
||||
log.infoStderr(
|
||||
"Not a TTY, cannot create profile interactively. Use 'wmill workspace add' first."
|
||||
);
|
||||
return undefined;
|
||||
@@ -187,12 +187,12 @@ async function createWorkspaceProfileInteractively(
|
||||
opts.configDir
|
||||
);
|
||||
|
||||
log.info(
|
||||
log.infoStderr(
|
||||
colors.green(
|
||||
`✓ Created profile '${profileName}' for ${workspaceId} on ${normalizedBaseUrl}`
|
||||
)
|
||||
);
|
||||
log.info(colors.green(`✓ Profile '${profileName}' is now active`));
|
||||
log.infoStderr(colors.green(`✓ Profile '${profileName}' is now active`));
|
||||
|
||||
return newWorkspace;
|
||||
}
|
||||
@@ -244,7 +244,7 @@ async function tryResolveWorkspace(
|
||||
`workspace '${opts.workspace}'`,
|
||||
opts.configDir
|
||||
);
|
||||
log.info(
|
||||
log.infoStderr(
|
||||
colors.green(
|
||||
`Using workspace profile '${selected.name}' for workspace '${opts.workspace}' (${workspaceId} on ${normalizedBaseUrl})`
|
||||
)
|
||||
@@ -254,7 +254,7 @@ async function tryResolveWorkspace(
|
||||
}
|
||||
|
||||
// No matching profile — offer to create one
|
||||
log.info(
|
||||
log.infoStderr(
|
||||
`No profile found for workspace '${opts.workspace}' (${workspaceId} on ${normalizedBaseUrl})`
|
||||
);
|
||||
const ws = await createWorkspaceProfileInteractively(
|
||||
@@ -309,7 +309,7 @@ export async function tryResolveBranchWorkspace(
|
||||
wsEntry = config.workspaces?.[workspaceNameOverride] as WorkspaceEntryConfig | undefined;
|
||||
if (wsEntry) {
|
||||
wsName = workspaceNameOverride;
|
||||
log.info(`Using workspace override: ${workspaceNameOverride}`);
|
||||
log.infoStderr(`Using workspace override: ${workspaceNameOverride}`);
|
||||
}
|
||||
} else {
|
||||
// Only try branch-based resolution if in a Git repository
|
||||
@@ -328,7 +328,7 @@ export async function tryResolveBranchWorkspace(
|
||||
|
||||
const branchToLookup = originalBranchIfForked ?? rawBranch;
|
||||
if (originalBranchIfForked) {
|
||||
log.info(
|
||||
log.infoStderr(
|
||||
`Using original branch \`${originalBranchIfForked}\` for finding workspace from workspaces section in wmill.yaml`
|
||||
);
|
||||
}
|
||||
@@ -346,7 +346,7 @@ export async function tryResolveBranchWorkspace(
|
||||
if (!wsEntry.baseUrl) {
|
||||
if (workspaceNameOverride) {
|
||||
// User explicitly asked for this workspace but it has no baseUrl
|
||||
log.warn(
|
||||
log.warnStderr(
|
||||
`⚠️ Workspace '${wsName}' has no baseUrl configured. Cannot resolve a profile.\n` +
|
||||
` Add baseUrl to workspace '${wsName}' in wmill.yaml, or use --base-url flag.`
|
||||
);
|
||||
@@ -370,7 +370,7 @@ export async function tryResolveBranchWorkspace(
|
||||
reason = `matched current git branch '${rawBranch}'`;
|
||||
}
|
||||
|
||||
log.info(
|
||||
log.infoStderr(
|
||||
`Using workspace '${wsName}' (${reason}) → ${workspaceId} on ${baseUrl}`
|
||||
);
|
||||
|
||||
@@ -406,7 +406,7 @@ export async function tryResolveBranchWorkspace(
|
||||
|
||||
if (matchingProfiles.length === 1) {
|
||||
selectedProfile = matchingProfiles[0];
|
||||
log.info(
|
||||
log.infoStderr(
|
||||
colors.green(
|
||||
`Using workspace profile '${selectedProfile.name}' for workspace '${wsName}' with workspace id \`${workspaceId}\``
|
||||
)
|
||||
@@ -424,7 +424,7 @@ export async function tryResolveBranchWorkspace(
|
||||
(p) => p.name === lastUsedName
|
||||
);
|
||||
if (lastUsedProfile) {
|
||||
log.info(
|
||||
log.infoStderr(
|
||||
colors.green(
|
||||
`Using workspace profile '${lastUsedProfile.name}' for workspace '${wsName}' (last used)`
|
||||
)
|
||||
@@ -449,7 +449,7 @@ export async function tryResolveBranchWorkspace(
|
||||
opts.configDir
|
||||
);
|
||||
|
||||
log.info(
|
||||
log.infoStderr(
|
||||
colors.green(
|
||||
`Using workspace profile '${selectedProfile.name}' for workspace '${wsName}'`
|
||||
)
|
||||
@@ -459,7 +459,7 @@ export async function tryResolveBranchWorkspace(
|
||||
if (workspaceIdIfForked) {
|
||||
selectedProfile.name = `${selectedProfile.name}/${workspaceIdIfForked}`;
|
||||
selectedProfile.workspaceId = workspaceIdIfForked;
|
||||
log.info(
|
||||
log.infoStderr(
|
||||
`Using fork workspace \`${workspaceIdIfForked}\` (parent: \`${workspaceId}\`) from branch \`${rawBranch}\``
|
||||
);
|
||||
}
|
||||
@@ -480,7 +480,7 @@ export async function resolveWorkspace(
|
||||
try {
|
||||
normalizedBaseUrl = new URL(opts.baseUrl).toString();
|
||||
} catch (error) {
|
||||
log.info(colors.red(`Invalid base URL: ${opts.baseUrl}`));
|
||||
log.infoStderr(colors.red(`Invalid base URL: ${opts.baseUrl}`));
|
||||
return process.exit(-1);
|
||||
}
|
||||
|
||||
@@ -514,7 +514,7 @@ export async function resolveWorkspace(
|
||||
|
||||
if (existingWorkspace) {
|
||||
if (existingWorkspace.remote !== normalizedBaseUrl) {
|
||||
log.info(
|
||||
log.infoStderr(
|
||||
colors.red(
|
||||
`Base URL mismatch: --base-url is ${normalizedBaseUrl} but workspace profile "${opts.workspace}" uses ${existingWorkspace.remote}`
|
||||
)
|
||||
@@ -535,7 +535,7 @@ export async function resolveWorkspace(
|
||||
token: opts.token,
|
||||
};
|
||||
} else {
|
||||
log.info(
|
||||
log.infoStderr(
|
||||
colors.red(
|
||||
"If you specify a base URL with --base-url, you must also specify a workspace (--workspace) and token (--token)."
|
||||
)
|
||||
@@ -555,7 +555,7 @@ export async function resolveWorkspace(
|
||||
if (workspaceNameOverride || opts.workspace || !branch || !branch.startsWith(WM_FORK_PREFIX)) {
|
||||
return workspace;
|
||||
} else {
|
||||
log.info(
|
||||
log.infoStderr(
|
||||
`Found an active workspace \`${workspace.name}\` but the branch name indicates this is a forked workspace. Ignoring active workspace and trying to resolve the correct workspace from the branch name \`${branch}\`. Use --workspace to override.`
|
||||
);
|
||||
}
|
||||
@@ -572,9 +572,9 @@ export async function resolveWorkspace(
|
||||
if (suggestions.length > 0) {
|
||||
msg += ` Did you mean: ${suggestions.map((s) => `"${s.name}"`).join(", ")}?`;
|
||||
}
|
||||
log.info(colors.red.bold(msg));
|
||||
log.infoStderr(colors.red.bold(msg));
|
||||
if (profiles.length > 0) {
|
||||
log.info("\nAvailable workspaces:");
|
||||
log.infoStderr("\nAvailable workspaces:");
|
||||
new Table()
|
||||
.header(["name", "remote", "workspace id"])
|
||||
.padding(2)
|
||||
@@ -620,12 +620,12 @@ export async function resolveWorkspace(
|
||||
|
||||
if (wsNames.length === 1) {
|
||||
pickedWsName = wsNames[0];
|
||||
log.info(
|
||||
log.infoStderr(
|
||||
`Auto-selected workspace '${pickedWsName}' (only workspace in config).\n` +
|
||||
`Use --workspace to override or 'wmill workspace bind' to add more workspaces.`
|
||||
);
|
||||
} else if (process.stdin.isTTY) {
|
||||
log.info(
|
||||
log.infoStderr(
|
||||
`Multiple workspaces configured but none matched the current context.\n` +
|
||||
`Configured workspaces:\n${wsListStr}\n` +
|
||||
`Use --workspace to skip this prompt.`
|
||||
@@ -675,7 +675,7 @@ export async function resolveWorkspace(
|
||||
try {
|
||||
normalizedBaseUrl = new URL(envBaseUrl).toString();
|
||||
} catch {
|
||||
log.info(colors.red(`Invalid BASE_INTERNAL_URL: ${envBaseUrl}`));
|
||||
log.infoStderr(colors.red(`Invalid BASE_INTERNAL_URL: ${envBaseUrl}`));
|
||||
return process.exit(-1);
|
||||
}
|
||||
log.debug(
|
||||
@@ -691,7 +691,7 @@ export async function resolveWorkspace(
|
||||
return ws;
|
||||
}
|
||||
|
||||
log.info(colors.red.bold("No workspace given and no default set. Run 'wmill workspace add' to configure one."));
|
||||
log.infoStderr(colors.red.bold("No workspace given and no default set. Run 'wmill workspace add' to configure one."));
|
||||
return process.exit(-1);
|
||||
}
|
||||
|
||||
@@ -746,7 +746,7 @@ export async function tryResolveVersion(
|
||||
|
||||
export function validatePath(path: string): boolean {
|
||||
if (!(path.startsWith("g") || path.startsWith("u") || path.startsWith("f"))) {
|
||||
log.info(
|
||||
log.infoStderr(
|
||||
colors.red(
|
||||
"Given remote path looks invalid. Remote paths are typically of the form <u|g|f>/<username|group|folder>/..."
|
||||
)
|
||||
|
||||
@@ -21,11 +21,26 @@ export function info(msg: unknown) {
|
||||
console.log(`\x1b[34m${String(msg)}\x1b[39m`);
|
||||
}
|
||||
|
||||
// Like `info` but written to stderr, for diagnostics (e.g. the workspace-profile
|
||||
// banner printed on every command) that must not pollute stdout when a command's
|
||||
// data output is piped or redirected (e.g. `wmill variable get path > file`).
|
||||
export function infoStderr(msg: unknown) {
|
||||
if (silentMode) return;
|
||||
console.error(`\x1b[34m${String(msg)}\x1b[39m`);
|
||||
}
|
||||
|
||||
export function warn(msg: unknown) {
|
||||
if (silentMode) return;
|
||||
console.log(`\x1b[33m${String(msg)}\x1b[39m`);
|
||||
}
|
||||
|
||||
// Like `warn` but written to stderr; see `infoStderr` for why diagnostics must
|
||||
// not land on stdout.
|
||||
export function warnStderr(msg: unknown) {
|
||||
if (silentMode) return;
|
||||
console.error(`\x1b[33m${String(msg)}\x1b[39m`);
|
||||
}
|
||||
|
||||
export function error(msg: unknown) {
|
||||
console.error(`\x1b[31m${String(msg)}\x1b[39m`);
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@ import * as http from "node:http";
|
||||
export async function loginInteractive(remote: string) {
|
||||
let token: string | undefined;
|
||||
if (!process.stdin.isTTY) {
|
||||
log.info("Not a TTY, can't login interactively.");
|
||||
log.infoStderr("Not a TTY, can't login interactively.");
|
||||
return undefined;
|
||||
}
|
||||
if (
|
||||
@@ -55,7 +55,7 @@ export async function browserLogin(
|
||||
const port = await getPort.default({ port: env });
|
||||
|
||||
if (port == undefined) {
|
||||
log.info(colors.red.underline("failed to aquire port"));
|
||||
log.infoStderr(colors.red.underline("failed to aquire port"));
|
||||
return undefined;
|
||||
}
|
||||
|
||||
@@ -79,7 +79,7 @@ export async function browserLogin(
|
||||
});
|
||||
|
||||
const url = `${baseUrl}user/cli?port=${port}`;
|
||||
log.info(`Login by going to ${url}`);
|
||||
log.infoStderr(`Login by going to ${url}`);
|
||||
|
||||
try {
|
||||
open.default(url).catch((error) => {
|
||||
@@ -88,7 +88,7 @@ export async function browserLogin(
|
||||
);
|
||||
});
|
||||
|
||||
log.info("Opened browser for you");
|
||||
log.infoStderr("Opened browser for you");
|
||||
} catch (error) {
|
||||
console.error(
|
||||
`Failed to open browser, please navigate to ${url}, error: ${error}`
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import { expect, test } from "bun:test";
|
||||
|
||||
import { looksLikeWorkspaceCiphertext } from "../src/commands/variable/variable.ts";
|
||||
|
||||
// =============================================================================
|
||||
// looksLikeWorkspaceCiphertext drives whether single-file `variable push` treats
|
||||
// a secret's value as already-encrypted (store verbatim) or as plaintext to be
|
||||
// encrypted server-side. It must agree with the server guard
|
||||
// (validate_already_encrypted_secret in windmill-store/src/variables.rs): a value
|
||||
// is "ciphertext shaped" iff it is an external-backend marker, or standard base64
|
||||
// decoding to a non-zero multiple of the AES block size (16 bytes).
|
||||
// =============================================================================
|
||||
|
||||
test("treats workspace-ciphertext-shaped values as already-encrypted", () => {
|
||||
const ciphertextShaped = [
|
||||
"MpYeXnSBBF7dzI6K8J89xQ==", // real magic_crypt output: 16 bytes
|
||||
Buffer.alloc(16, 7).toString("base64"), // 16 bytes
|
||||
Buffer.alloc(32, 7).toString("base64"), // 32 bytes
|
||||
"$vault:f/x/cfg",
|
||||
"$aws_sm:f/x/cfg",
|
||||
"$azure_kv:f/x/cfg",
|
||||
];
|
||||
for (const value of ciphertextShaped) {
|
||||
expect(looksLikeWorkspaceCiphertext(value)).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
test("treats hand-authored plaintext as NOT already-encrypted", () => {
|
||||
const plaintext = [
|
||||
"some: plaintext\n", // space, colon, newline
|
||||
"original-secret", // hyphen, not length % 4
|
||||
"hunter2",
|
||||
'{"a": 1}',
|
||||
"", // empty
|
||||
"dGVzdA==", // valid base64 but decodes to 4 bytes (not % 16)
|
||||
Buffer.alloc(17, 7).toString("base64"), // 17 bytes (not % 16)
|
||||
"$omething-plain", // starts with $ but is not a real backend marker
|
||||
];
|
||||
for (const value of plaintext) {
|
||||
expect(looksLikeWorkspaceCiphertext(value)).toBe(false);
|
||||
}
|
||||
});
|
||||
@@ -219,6 +219,103 @@ describe("variable", () => {
|
||||
});
|
||||
});
|
||||
|
||||
test("push encrypts a plaintext secret value (no --plain-secrets) and round-trips", async () => {
|
||||
await withTestBackend(async (backend, tempDir) => {
|
||||
await setupWorkspaceProfile(backend);
|
||||
|
||||
const uniqueId = Date.now();
|
||||
const varPath = `f/test/sec_push_${uniqueId}`;
|
||||
|
||||
// Existing secret variable (server-encrypted).
|
||||
const createResp = await backend.apiRequest!(
|
||||
`/api/w/${backend.workspace}/variables/create`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
path: varPath,
|
||||
value: "original-secret",
|
||||
is_secret: true,
|
||||
description: "",
|
||||
}),
|
||||
}
|
||||
);
|
||||
expect(createResp.status).toBeLessThan(300);
|
||||
await createResp.text();
|
||||
|
||||
// A hand-authored spec file: plaintext value, is_secret: true. Pushing it
|
||||
// without --plain-secrets must encrypt the value server-side, not store the
|
||||
// plaintext verbatim as ciphertext (which would make every read fail).
|
||||
const specPath = join(tempDir, "v.yaml");
|
||||
await writeFile(
|
||||
specPath,
|
||||
`value: |\n some: plaintext\nis_secret: true\ndescription: ""\n`,
|
||||
"utf-8"
|
||||
);
|
||||
|
||||
const pushResult = await backend.runCLICommand(
|
||||
["variable", "push", specPath, varPath],
|
||||
tempDir
|
||||
);
|
||||
expect(pushResult.code).toEqual(0);
|
||||
|
||||
// The value must decrypt cleanly to the pushed plaintext.
|
||||
const apiResp = await backend.apiRequest!(
|
||||
`/api/w/${backend.workspace}/variables/get/${varPath}?decrypt_secret=true`
|
||||
);
|
||||
expect(apiResp.status).toEqual(200);
|
||||
const varData = await apiResp.json();
|
||||
expect(varData.is_secret).toBe(true);
|
||||
expect(varData.value).toBe("some: plaintext\n");
|
||||
});
|
||||
});
|
||||
|
||||
test("push flips is_secret from true to false", async () => {
|
||||
await withTestBackend(async (backend, tempDir) => {
|
||||
await setupWorkspaceProfile(backend);
|
||||
|
||||
const uniqueId = Date.now();
|
||||
const varPath = `f/test/sec_down_${uniqueId}`;
|
||||
|
||||
const createResp = await backend.apiRequest!(
|
||||
`/api/w/${backend.workspace}/variables/create`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
path: varPath,
|
||||
value: "original-secret",
|
||||
is_secret: true,
|
||||
description: "",
|
||||
}),
|
||||
}
|
||||
);
|
||||
expect(createResp.status).toBeLessThan(300);
|
||||
await createResp.text();
|
||||
|
||||
const specPath = join(tempDir, "v_down.yaml");
|
||||
await writeFile(
|
||||
specPath,
|
||||
`value: "now-public"\nis_secret: false\ndescription: ""\n`,
|
||||
"utf-8"
|
||||
);
|
||||
|
||||
const pushResult = await backend.runCLICommand(
|
||||
["variable", "push", specPath, varPath],
|
||||
tempDir
|
||||
);
|
||||
expect(pushResult.code).toEqual(0);
|
||||
|
||||
const apiResp = await backend.apiRequest!(
|
||||
`/api/w/${backend.workspace}/variables/get/${varPath}?decrypt_secret=true`
|
||||
);
|
||||
expect(apiResp.status).toEqual(200);
|
||||
const varData = await apiResp.json();
|
||||
expect(varData.is_secret).toBe(false);
|
||||
expect(varData.value).toBe("now-public");
|
||||
});
|
||||
});
|
||||
|
||||
test("pull retrieves variables into local files", async () => {
|
||||
await withTestBackend(async (backend, tempDir) => {
|
||||
await setupWorkspaceProfile(backend);
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "@windmill-labs/components",
|
||||
"version": "1.735.0",
|
||||
"version": "1.737.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@windmill-labs/components",
|
||||
"version": "1.735.0",
|
||||
"version": "1.737.0",
|
||||
"hasInstallScript": true,
|
||||
"license": "AGPL-3.0",
|
||||
"dependencies": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@windmill-labs/components",
|
||||
"version": "1.735.0",
|
||||
"version": "1.737.0",
|
||||
"scripts": {
|
||||
"dev": "vite dev",
|
||||
"dev:ui-builder": "mv static/ui_builder static/ui_builder.dev-disabled 2>/dev/null || true ; trap 'mv static/ui_builder.dev-disabled static/ui_builder 2>/dev/null || true' EXIT ; vite dev",
|
||||
|
||||
+102
-2
@@ -1,4 +1,4 @@
|
||||
<!DOCTYPE html>
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
@@ -23,6 +23,106 @@
|
||||
}
|
||||
</style>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<script>
|
||||
// WIN-2006: when the app is rendered inside an opaque-origin (sandboxed,
|
||||
// no allow-same-origin) iframe, Web Storage access throws a SecurityError.
|
||||
// Install an in-memory shim before the SvelteKit app boots so the app (and
|
||||
// Windmill SPA code running inside the frame) keeps working within the
|
||||
// session. No-op in normal real-origin contexts, so zero blast radius.
|
||||
;(function () {
|
||||
// `onOp` (localStorage only) relays each mutation up to the embedder,
|
||||
// which backs a single store shared across all apps. sessionStorage
|
||||
// stays in-memory/session-only.
|
||||
function shim(onOp) {
|
||||
var m = {}
|
||||
return {
|
||||
__hydrate: function (obj) {
|
||||
if (obj) for (var k in obj) m['' + k] = '' + obj[k]
|
||||
},
|
||||
getItem: function (k) {
|
||||
k = '' + k
|
||||
return Object.prototype.hasOwnProperty.call(m, k) ? m[k] : null
|
||||
},
|
||||
setItem: function (k, v) {
|
||||
m['' + k] = '' + v
|
||||
if (onOp) onOp({ op: 'set', key: '' + k, value: '' + v })
|
||||
},
|
||||
removeItem: function (k) {
|
||||
delete m['' + k]
|
||||
if (onOp) onOp({ op: 'remove', key: '' + k })
|
||||
},
|
||||
clear: function () {
|
||||
for (var k in m) delete m[k]
|
||||
if (onOp) onOp({ op: 'clear' })
|
||||
},
|
||||
key: function (i) {
|
||||
var ks = Object.keys(m)
|
||||
return i < ks.length ? ks[i] : null
|
||||
},
|
||||
get length() {
|
||||
return Object.keys(m).length
|
||||
}
|
||||
}
|
||||
}
|
||||
try {
|
||||
window.localStorage.getItem('__wm_probe__')
|
||||
} catch (e) {
|
||||
var framed = window.parent !== window
|
||||
function relayOp(o) {
|
||||
if (framed)
|
||||
try {
|
||||
window.parent.postMessage(
|
||||
{ type: 'wm_ls_op', op: o.op, key: o.key, value: o.value },
|
||||
'*'
|
||||
)
|
||||
} catch (_) {}
|
||||
}
|
||||
var ls = shim(framed ? relayOp : null)
|
||||
try {
|
||||
Object.defineProperty(window, 'localStorage', { value: ls, configurable: true })
|
||||
} catch (_) {}
|
||||
try {
|
||||
Object.defineProperty(window, 'sessionStorage', {
|
||||
value: shim(null),
|
||||
configurable: true
|
||||
})
|
||||
} catch (_) {}
|
||||
// Persistence: ask the embedder for the shared store and hydrate the
|
||||
// in-memory localStorage when it arrives.
|
||||
if (framed) {
|
||||
window.addEventListener('message', function (ev) {
|
||||
if (ev.source === window.parent && ev.data && ev.data.type === 'wm_ls_hydrate') {
|
||||
ls.__hydrate(ev.data.data)
|
||||
}
|
||||
})
|
||||
try {
|
||||
window.parent.postMessage({ type: 'wm_ls_req' }, '*')
|
||||
} catch (_) {}
|
||||
}
|
||||
// `document.cookie` also throws in an opaque origin; back it with an
|
||||
// in-memory jar so reads don't crash apps. This is NOT the real
|
||||
// session cookie (unreachable here) — just an isolated client-side store.
|
||||
try {
|
||||
var jar = {}
|
||||
Object.defineProperty(Document.prototype, 'cookie', {
|
||||
configurable: true,
|
||||
get: function () {
|
||||
return Object.keys(jar)
|
||||
.map(function (k) {
|
||||
return k + '=' + jar[k]
|
||||
})
|
||||
.join('; ')
|
||||
},
|
||||
set: function (v) {
|
||||
var p = String(v).split(';')[0]
|
||||
var i = p.indexOf('=')
|
||||
if (i > -1) jar[p.slice(0, i).trim()] = p.slice(i + 1).trim()
|
||||
}
|
||||
})
|
||||
} catch (_) {}
|
||||
}
|
||||
})()
|
||||
</script>
|
||||
%sveltekit.head%
|
||||
</head>
|
||||
<body data-sveltekit-preload-code="viewport" class="outline-none focus:outline-none">
|
||||
@@ -56,7 +156,7 @@
|
||||
/>
|
||||
<polygon
|
||||
class="st3"
|
||||
points="136.93,132.47 116.46,167.93 73.82,241.78 130.71,241.78 144.9,217.2 180.13,156.18 193.82,132.46
|
||||
points="136.93,132.47 116.46,167.93 73.82,241.78 130.71,241.78 144.9,217.2 180.13,156.18 193.82,132.46
|
||||
"
|
||||
/>
|
||||
<polygon
|
||||
|
||||
@@ -368,6 +368,15 @@
|
||||
divEl?.classList.add('hidden')
|
||||
}
|
||||
|
||||
// Mirrors the value Monaco's model currently holds, as last synced through
|
||||
// `code`. The external-`code`→model effect below reflects only when `code`
|
||||
// diverges from this sentinel, i.e. when `code` was set by an outside writer
|
||||
// (draft load, template reset) rather than echoed back from the model. Writes
|
||||
// that go straight to the model (AI chat apply, collab) update `code` via the
|
||||
// debounced change handler, which keeps this sentinel in lockstep — so the
|
||||
// effect never reflects a stale `code` over a model that moved ahead.
|
||||
let lastReflectedCode = code
|
||||
|
||||
export function setCode(ncode: string, noHistory: boolean = false): void {
|
||||
// Track whether the code actually changed before updating.
|
||||
const changed = code != ncode
|
||||
@@ -392,6 +401,9 @@
|
||||
editor.pushUndoStop()
|
||||
}
|
||||
}
|
||||
// The model now holds `ncode`; record it so the reflect effect treats this
|
||||
// as already-synced and doesn't write it back.
|
||||
lastReflectedCode = ncode
|
||||
// Dispatch change immediately when code actually changed. This ensures
|
||||
// callers like the Reset button and copilot trigger on:change handlers.
|
||||
// The debounced onDidChangeModelContent handler will no-op since code
|
||||
@@ -425,6 +437,10 @@
|
||||
return
|
||||
}
|
||||
code = ncode
|
||||
// `code` was just echoed from the model, so keep the sentinel aligned —
|
||||
// this is what prevents the reflect effect from racing the model during a
|
||||
// burst of in-editor edits (e.g. an AI chat apply).
|
||||
lastReflectedCode = ncode
|
||||
dispatch('change', ncode)
|
||||
}
|
||||
|
||||
@@ -2020,6 +2036,12 @@
|
||||
const next = code ?? ''
|
||||
const ed = editor
|
||||
if (!ed) return
|
||||
// Only reflect genuine external `code` changes. When `code` merely echoed a
|
||||
// model edit (typing, AI chat apply, collab), `lastReflectedCode` already
|
||||
// matches and we skip — otherwise a debounced echo could overwrite a model
|
||||
// that has since moved further ahead, reverting the newer edit.
|
||||
if (code === lastReflectedCode) return
|
||||
lastReflectedCode = code
|
||||
untrack(() => {
|
||||
if (ed.getValue() === next) return
|
||||
const model = ed.getModel()
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
import { twMerge } from 'tailwind-merge'
|
||||
import type { Output } from '../../rx'
|
||||
import ResolveNavbarItemPath from './ResolveNavbarItemPath.svelte'
|
||||
import { urlParamsToObject } from '$lib/utils'
|
||||
import { urlParamsToObject, WINDMILL_RESERVED_QUERY_PARAMS } from '$lib/utils'
|
||||
|
||||
interface Props {
|
||||
navbarItem: NavbarItem
|
||||
@@ -54,8 +54,22 @@
|
||||
let resolvedHidden: boolean | undefined = $state(undefined)
|
||||
|
||||
function extractPathDetails() {
|
||||
const url = window.location.pathname + window.location.search + window.location.hash
|
||||
const processedUrl = url.replace('/apps/edit/', '').replace('/apps/get/', '')
|
||||
// Drop Windmill transport params (wm_embed, …) so they don't poison the
|
||||
// comparison against the item's resolved path.
|
||||
const params = new URLSearchParams(window.location.search)
|
||||
const reserved: string[] = []
|
||||
params.forEach((_v, k) => {
|
||||
if (WINDMILL_RESERVED_QUERY_PARAMS.has(k)) reserved.push(k)
|
||||
})
|
||||
reserved.forEach((k) => params.delete(k))
|
||||
const qs = params.toString()
|
||||
const url = window.location.pathname + (qs ? `?${qs}` : '') + window.location.hash
|
||||
// `/app_embed/{workspace}/` is the opaque in-workspace viewer route
|
||||
// (WIN-2006) — same app-path suffix as `/apps/get/`.
|
||||
const processedUrl = url
|
||||
.replace('/apps/edit/', '')
|
||||
.replace('/apps/get/', '')
|
||||
.replace(/^\/app_embed\/[^/]+\//, '')
|
||||
return processedUrl
|
||||
}
|
||||
|
||||
|
||||
@@ -86,7 +86,10 @@
|
||||
}
|
||||
|
||||
const resolvedConfig = $state(
|
||||
initConfig(components['dbexplorercomponent'].initialData.configuration, untrack(() => configuration))
|
||||
initConfig(
|
||||
components['dbexplorercomponent'].initialData.configuration,
|
||||
untrack(() => configuration)
|
||||
)
|
||||
)
|
||||
|
||||
let timeoutInput: number | undefined = undefined
|
||||
@@ -180,18 +183,22 @@
|
||||
)
|
||||
}
|
||||
|
||||
let outputs = initOutput($worldStore, untrack(() => id), {
|
||||
selectedRowIndex: 0,
|
||||
selectedRow: {},
|
||||
selectedRows: [] as any[],
|
||||
result: [] as any[],
|
||||
inputs: {},
|
||||
loading: false,
|
||||
page: 0,
|
||||
newChange: { row: 0, column: '', value: undefined },
|
||||
ready: undefined as boolean | undefined,
|
||||
openedModalRow: {}
|
||||
})
|
||||
let outputs = initOutput(
|
||||
$worldStore,
|
||||
untrack(() => id),
|
||||
{
|
||||
selectedRowIndex: 0,
|
||||
selectedRow: {},
|
||||
selectedRows: [] as any[],
|
||||
result: [] as any[],
|
||||
inputs: {},
|
||||
loading: false,
|
||||
page: 0,
|
||||
newChange: { row: 0, column: '', value: undefined },
|
||||
ready: undefined as boolean | undefined,
|
||||
openedModalRow: {}
|
||||
}
|
||||
)
|
||||
|
||||
let lastResource: string | undefined = undefined
|
||||
|
||||
@@ -260,9 +267,7 @@
|
||||
resolvedConfig.type,
|
||||
{
|
||||
table: {
|
||||
selectOptions: dbSchemas
|
||||
? await getTablesByResource(dbSchemas, dbtype, dbPath, $workspaceStore!)
|
||||
: [],
|
||||
selectOptions: dbSchemas ? await getTablesByResource(dbSchemas, dbtype) : [],
|
||||
loading: false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { JobService, ResourceService } from '$lib/gen'
|
||||
import { JobService } from '$lib/gen'
|
||||
|
||||
import { runScriptAndPollResult } from '$lib/components/jobs/utils'
|
||||
import type { DbInput } from '$lib/components/dbTypes'
|
||||
@@ -39,17 +39,9 @@ export async function loadTableMetaData(
|
||||
const ducklake = input.type === 'ducklake' ? input.ducklake : undefined
|
||||
const dbArg = getDatabaseArg(input)
|
||||
|
||||
// MySQL needs the database name for metadata queries
|
||||
let databaseName: string | undefined
|
||||
if (input.type === 'database' && input.resourceType === 'mysql') {
|
||||
const resourceObj = (await ResourceService.getResourceValue({
|
||||
workspace,
|
||||
path: input.resourcePath
|
||||
})) as any
|
||||
databaseName = resourceObj?.database
|
||||
}
|
||||
|
||||
const content = makeMetadataMarker('LOAD_TABLE_METADATA', { table, databaseName }, ducklake)
|
||||
// MySQL: the metadata query resolves the database name server-side (it falls
|
||||
// back to `DATABASE()`), so we don't read the resource value client-side for it.
|
||||
const content = makeMetadataMarker('LOAD_TABLE_METADATA', { table }, ducklake)
|
||||
|
||||
const job = await JobService.runScriptPreview({
|
||||
workspace,
|
||||
@@ -106,22 +98,10 @@ export async function loadAllTablesMetaData(
|
||||
const dbArg = getDatabaseArg(input)
|
||||
const ducklake = input.type === 'ducklake' ? input.ducklake : undefined
|
||||
|
||||
// MySQL needs the database name for metadata queries
|
||||
let databaseName: string | undefined
|
||||
if (input.type === 'database' && input.resourceType === 'mysql') {
|
||||
const resourceObj = (await ResourceService.getResourceValue({
|
||||
workspace,
|
||||
path: input.resourcePath
|
||||
})) as any
|
||||
databaseName = resourceObj?.database
|
||||
}
|
||||
|
||||
const language = getLanguageByResourceType(dbType)
|
||||
const content = makeMetadataMarker(
|
||||
'LOAD_TABLE_METADATA',
|
||||
{ table: undefined, databaseName },
|
||||
ducklake
|
||||
)
|
||||
// MySQL db name is resolved server-side via `DATABASE()` (see loadTableMetaData);
|
||||
// no client-side resource-value read.
|
||||
const content = makeMetadataMarker('LOAD_TABLE_METADATA', { table: undefined }, ducklake)
|
||||
|
||||
let result = (await runScriptAndPollResult({
|
||||
workspace,
|
||||
@@ -259,7 +239,11 @@ export async function getDbSchemas(
|
||||
const dbSchema = {
|
||||
lang: resourceTypeToLang(resourceType) as SQLSchema['lang'],
|
||||
schema,
|
||||
publicOnly: !!schema.public || !!schema.PUBLIC || !!schema.dbo
|
||||
publicOnly: !!schema.public || !!schema.PUBLIC || !!schema.dbo,
|
||||
// MySQL introspection selects `DATABASE() AS default_db_name`; carry it
|
||||
// so the table picker can tell the default db apart from other visible
|
||||
// schemas. Other dbs don't return it (stays undefined).
|
||||
defaultDb: Array.isArray(result) ? (result[0] as any)?.default_db_name : undefined
|
||||
}
|
||||
return { ...dbSchema, stringified: stringifySchema(dbSchema) }
|
||||
} else {
|
||||
@@ -283,9 +267,7 @@ export async function getDbSchemas(
|
||||
|
||||
export async function getTablesByResource(
|
||||
schema: Partial<Record<string, DBSchema>>,
|
||||
dbType: DbType | undefined,
|
||||
dbPath: string,
|
||||
workspace: string
|
||||
dbType: DbType | undefined
|
||||
): Promise<string[]> {
|
||||
const s = Object.values(schema)?.[0]
|
||||
switch (dbType) {
|
||||
@@ -301,14 +283,15 @@ export async function getTablesByResource(
|
||||
return paths
|
||||
}
|
||||
case 'mysql': {
|
||||
const resourceObj = (await ResourceService.getResourceValue({
|
||||
workspace,
|
||||
path: dbPath.split('$res:')[1]
|
||||
})) as any
|
||||
// MySQL introspection lists DATABASE() plus any other visible non-system
|
||||
// schemas. Show the default db's tables unprefixed and the rest as
|
||||
// `db.table` — matching the pre-removal behavior (which matched the
|
||||
// resource's `database`); `defaultDb` is the connection's DATABASE().
|
||||
const defaultDb = s && 'defaultDb' in s ? s.defaultDb : undefined
|
||||
const paths: string[] = []
|
||||
for (const key in s?.schema) {
|
||||
for (const subKey in s.schema[key]) {
|
||||
if (key === resourceObj?.database) {
|
||||
if (key === defaultDb) {
|
||||
paths.push(`${subKey}`)
|
||||
} else {
|
||||
paths.push(`${key}.${subKey}`)
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import type { AppInput } from '../../inputType'
|
||||
import type { Output } from '../../rx'
|
||||
import type { AppViewerContext, ListContext } from '../../types'
|
||||
import { isScriptByNameDefined, isScriptByPathDefined } from '../../utils'
|
||||
import { appNavigateSameWindow, isScriptByNameDefined, isScriptByPathDefined } from '../../utils'
|
||||
import NonRunnableComponent from './NonRunnableComponent.svelte'
|
||||
import RunnableComponent from './RunnableComponent.svelte'
|
||||
import { sendUserToast } from '$lib/toast'
|
||||
@@ -261,7 +261,9 @@
|
||||
if (newTab) {
|
||||
window.open(gotoUrl, '_blank')
|
||||
} else {
|
||||
window.location.href = gotoUrl
|
||||
// Top-level load; inside the opaque viewer iframe this targets the
|
||||
// top page (pre-sandbox behavior) instead of the cookieless frame.
|
||||
appNavigateSameWindow(gotoUrl)
|
||||
}
|
||||
|
||||
break
|
||||
|
||||
@@ -2,6 +2,8 @@ import type { World } from '../../rx'
|
||||
import { sendUserToast } from '$lib/toast'
|
||||
import { waitJob } from '$lib/components/waitJob'
|
||||
import { base } from '$lib/base'
|
||||
import { appNavigateSameWindow } from '../../utils'
|
||||
import { OpenAPI } from '$lib/gen/core/OpenAPI'
|
||||
|
||||
export function computeGlobalContext(
|
||||
world: World | undefined,
|
||||
@@ -200,7 +202,9 @@ export async function eval_like(
|
||||
}
|
||||
window.open(x, '_blank')
|
||||
} else {
|
||||
window.location.href = x
|
||||
// Top-level load; inside the opaque viewer iframe this targets the
|
||||
// top page (pre-sandbox behavior) instead of the cookieless frame.
|
||||
appNavigateSameWindow(x)
|
||||
}
|
||||
},
|
||||
(id, index) => {
|
||||
@@ -292,10 +296,30 @@ export async function eval_like(
|
||||
|
||||
if (typeof input === 'object' && input.s3) {
|
||||
const workspaceId = ((context ?? {}) as any).ctx?.workspace
|
||||
const s3href = `${base}/api/w/${workspaceId}/job_helpers/download_s3_file?file_key=${encodeURIComponent(
|
||||
input?.s3 ?? ''
|
||||
)}${input?.storage ? `&storage=${input.storage}` : ''}`
|
||||
downloadFile(s3href, filename || input.s3)
|
||||
const appPath = ((context ?? {}) as any).ctx?.app_path
|
||||
let inSandbox = false
|
||||
try {
|
||||
inSandbox =
|
||||
window.parent !== window &&
|
||||
new URLSearchParams(window.location.search).get('wm_embed') === '1'
|
||||
} catch (_) {}
|
||||
if (inSandbox && appPath && typeof OpenAPI.TOKEN === 'string' && OpenAPI.TOKEN) {
|
||||
// Sandboxed viewer: the opaque iframe carries no cookie, so the
|
||||
// cookie-authed job_helpers download fails. Route through the
|
||||
// app-policy-confined apps_u endpoint with the embed token in the
|
||||
// query (like the image/file components), scoped to this app's path.
|
||||
const params = new URLSearchParams()
|
||||
params.append('s3', input.s3 ?? '')
|
||||
if (input.storage) params.append('storage', input.storage)
|
||||
params.append('token', OpenAPI.TOKEN)
|
||||
const s3href = `${base}/api/w/${workspaceId}/apps_u/download_s3_file/${appPath}?${params.toString()}`
|
||||
downloadFile(s3href, filename || input.s3)
|
||||
} else {
|
||||
const s3href = `${base}/api/w/${workspaceId}/job_helpers/download_s3_file?file_key=${encodeURIComponent(
|
||||
input?.s3 ?? ''
|
||||
)}${input?.storage ? `&storage=${input.storage}` : ''}`
|
||||
downloadFile(s3href, filename || input.s3)
|
||||
}
|
||||
} else if (typeof input === 'string') {
|
||||
if (input.startsWith('data:')) {
|
||||
downloadFile(input, filename)
|
||||
|
||||
@@ -395,14 +395,16 @@
|
||||
}
|
||||
}
|
||||
|
||||
async function setPublishState() {
|
||||
async function setPublishState(message?: string) {
|
||||
policy = await updatePolicy($app, policy)
|
||||
await AppService.updateApp({
|
||||
workspace: $workspaceStore!,
|
||||
path: $appPath,
|
||||
requestBody: { policy }
|
||||
})
|
||||
if (policy.execution_mode == 'anonymous') {
|
||||
if (message) {
|
||||
sendUserToast(message)
|
||||
} else if (policy.execution_mode == 'anonymous') {
|
||||
sendUserToast('App require no login to be accessed')
|
||||
} else {
|
||||
sendUserToast('App require login and read-access')
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { Alert } from '$lib/components/common'
|
||||
import Badge from '$lib/components/common/badge/Badge.svelte'
|
||||
import Toggle from '$lib/components/Toggle.svelte'
|
||||
import { enterpriseLicense, userStore, workspaceStore } from '$lib/stores'
|
||||
import { Loader2 } from 'lucide-svelte'
|
||||
@@ -41,7 +42,7 @@
|
||||
newApp = false
|
||||
}: {
|
||||
policy: any
|
||||
setPublishState: () => void
|
||||
setPublishState: (message?: string) => void
|
||||
appPath: string
|
||||
customPath: string | undefined
|
||||
onLatest: boolean
|
||||
@@ -274,6 +275,40 @@
|
||||
|
||||
<div class="mt-10"></div>
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
<h2>Sandbox isolation</h2>
|
||||
<Badge color="yellow">Alpha</Badge>
|
||||
</div>
|
||||
<div class="my-6">
|
||||
<Toggle
|
||||
options={{ right: "Isolate the app from the viewer's browser session" }}
|
||||
checked={policy.sandbox == true}
|
||||
on:change={(e) => {
|
||||
policy.sandbox = e.detail || undefined
|
||||
setPublishState(e.detail ? 'Sandbox isolation enabled' : 'Sandbox isolation disabled')
|
||||
}}
|
||||
disabled={!savedApp}
|
||||
/>
|
||||
<div class="text-xs text-secondary mt-1">
|
||||
Controls what the app's browser-side code can reach in each viewer's browser — distinct from the
|
||||
on-behalf-of model above (which sets who its runnables run as). Off by default, the app's code
|
||||
uses the viewer's own session; enable it to confine the app to a narrowly-scoped token instead,
|
||||
on every surface (public URL and in-workspace). Leave it off if the app needs full browser
|
||||
features (IndexedDB, third-party auth/SDKs, OAuth redirects).
|
||||
</div>
|
||||
{#if !savedApp}
|
||||
<div class="text-xs text-tertiary mt-1">Save the app once to change this setting.</div>
|
||||
{/if}
|
||||
{#if policy.sandbox == true}
|
||||
<div class="mt-2">
|
||||
<Alert type="warning" title="Alpha feature" size="xs">
|
||||
Sandbox isolation is in alpha. After enabling, open the app from its public URL to confirm
|
||||
it still works, and report any broken behavior.
|
||||
</Alert>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if !hideSecretUrl}
|
||||
<h2>Public URL</h2>
|
||||
|
||||
|
||||
@@ -78,7 +78,8 @@
|
||||
workspace: untrack(() => workspace),
|
||||
mode: 'viewer',
|
||||
summary: untrack(() => summary),
|
||||
author: untrack(() => policy).on_behalf_of_email
|
||||
author: untrack(() => policy).on_behalf_of_email,
|
||||
app_path: untrack(() => appPath)
|
||||
}
|
||||
|
||||
function resizeWindow() {
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
<script lang="ts">
|
||||
/*
|
||||
* WIN-2006: shared in-workspace app viewer (low-code AND raw). Renders the app
|
||||
* through the same PublicAppFrame -> PublicApp machinery the public viewer uses,
|
||||
* so the sandbox behavior is identical on every page. PublicAppFrame picks the
|
||||
* rendering: an opaque /app_embed iframe for sandboxed low-code, or inline (with
|
||||
* the bundle isolated in RawAppPreview's own opaque iframe) for raw and for
|
||||
* unsandboxed apps. When the publisher opts an app into sandbox isolation, its
|
||||
* untrusted markup/JS must not run with the member's full session — hence the
|
||||
* scoped embed token / opaque isolation.
|
||||
*/
|
||||
import { base } from '$lib/base'
|
||||
import PublicApp from '$lib/components/apps/editor/PublicApp.svelte'
|
||||
import PublicAppFrame from '$lib/components/apps/editor/PublicAppFrame.svelte'
|
||||
import { Button } from '$lib/components/common'
|
||||
import { AppService, OpenAPI } from '$lib/gen'
|
||||
import { userStore } from '$lib/stores'
|
||||
import { canWrite } from '$lib/utils'
|
||||
import { getUserExt } from '$lib/user'
|
||||
import { Pen } from 'lucide-svelte'
|
||||
import { page } from '$app/state'
|
||||
|
||||
let {
|
||||
workspace,
|
||||
path,
|
||||
editHref
|
||||
}: {
|
||||
workspace: string
|
||||
path: string
|
||||
/** Where the Edit button points (low-code vs raw editor). */
|
||||
editHref: string
|
||||
} = $props()
|
||||
|
||||
let app: any = $state(undefined)
|
||||
let notExists = $state(false)
|
||||
let noPermission = $state(false)
|
||||
let canWriteApp = $state(false)
|
||||
let refresh: (() => void) | undefined
|
||||
|
||||
// The opaque iframe loads the dedicated cookieless, chrome-less viewer route.
|
||||
// The page's query/hash are forwarded so the app sees the same `ctx.query` /
|
||||
// `ctx.hash` as the pre-sandbox viewer did. Captured ONCE (not reactively):
|
||||
// the embedder later mirrors the app's own hash/query changes back onto this
|
||||
// page's URL (wm_embed_hash relay), and re-deriving the iframe src from them
|
||||
// would reload the app on its every navigation.
|
||||
const initialSearchHash = page.url.search + page.url.hash
|
||||
let viewerUrl = $derived(`${base}/app_embed/${workspace}/${path}${initialSearchHash}`)
|
||||
|
||||
const hideEditBtn = page.url.searchParams.get('hideEditBtn') === 'true'
|
||||
const hideRefreshBar = page.url.searchParams.get('hideRefreshBar') === 'true'
|
||||
|
||||
// Embedder side: mint a scoped embed token (by path) from the member's session.
|
||||
async function fetchEmbedToken(): Promise<{ token?: string }> {
|
||||
const headers: Record<string, string> = {}
|
||||
if (typeof OpenAPI.TOKEN === 'string' && OpenAPI.TOKEN) {
|
||||
headers['Authorization'] = `Bearer ${OpenAPI.TOKEN}`
|
||||
}
|
||||
const res = await fetch(`${OpenAPI.BASE}/w/${workspace}/apps/embed_token/p/${path}`, {
|
||||
headers
|
||||
})
|
||||
if (!res.ok) {
|
||||
const err: any = new Error('Failed to fetch embed token')
|
||||
err.status = res.status
|
||||
throw err
|
||||
}
|
||||
return await res.json()
|
||||
}
|
||||
|
||||
// Viewer side — used for the inline renderings (raw, and unsandboxed low-code);
|
||||
// the sandboxed low-code case loads inside the opaque /app_embed iframe instead.
|
||||
// getAppByPath returns bundle_secret + runnables for raw apps, which
|
||||
// PublicApp -> RawAppPreview needs.
|
||||
async function loadApp() {
|
||||
try {
|
||||
userStore.set(await getUserExt(workspace))
|
||||
} catch (e) {
|
||||
console.warn('Anonymous user')
|
||||
}
|
||||
try {
|
||||
const loaded: any = await AppService.getAppByPath({ workspace, path })
|
||||
// Raw apps need the bundle secret to load their bundle. getAppByPath
|
||||
// doesn't compute it (unlike the public handlers), so fetch it here — the
|
||||
// same call the previous raw viewer used — and hand it to PublicApp ->
|
||||
// RawAppPreview via bundle_secret.
|
||||
if (loaded?.raw_app && !loaded.bundle_secret) {
|
||||
try {
|
||||
loaded.bundle_secret = await AppService.getPublicSecretOfLatestVersionOfApp({
|
||||
workspace,
|
||||
path
|
||||
})
|
||||
} catch (e) {
|
||||
console.error('Failed to load raw app bundle secret', e)
|
||||
}
|
||||
}
|
||||
app = loaded
|
||||
noPermission = false
|
||||
notExists = false
|
||||
} catch (e: any) {
|
||||
if (e.status == 401) refresh?.()
|
||||
else if (e.status == 403) noPermission = true
|
||||
else notExists = true
|
||||
}
|
||||
}
|
||||
|
||||
// Edit button: determine write access on this real-origin page (cookie).
|
||||
async function loadPerms() {
|
||||
try {
|
||||
const lite: any = await AppService.getAppLiteByPath({ workspace, path })
|
||||
canWriteApp = canWrite(lite?.path, lite?.extra_perms ?? {}, $userStore)
|
||||
} catch (_) {
|
||||
canWriteApp = false
|
||||
}
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
if (workspace && path) loadPerms()
|
||||
})
|
||||
</script>
|
||||
|
||||
<PublicAppFrame
|
||||
{fetchEmbedToken}
|
||||
{viewerUrl}
|
||||
onViewerReady={(_token, requestTokenRefresh) => {
|
||||
refresh = requestTokenRefresh
|
||||
loadApp()
|
||||
}}
|
||||
>
|
||||
{#snippet viewer()}
|
||||
<PublicApp
|
||||
{app}
|
||||
{workspace}
|
||||
{notExists}
|
||||
{noPermission}
|
||||
jwtError={false}
|
||||
inWorkspace
|
||||
{hideRefreshBar}
|
||||
onLoginSuccess={() => loadApp()}
|
||||
></PublicApp>
|
||||
{/snippet}
|
||||
</PublicAppFrame>
|
||||
|
||||
{#if canWriteApp && !hideEditBtn}
|
||||
<div id="app-edit-btn" class="absolute bottom-4 z-50 right-4">
|
||||
<Button size="sm" startIcon={{ icon: Pen }} variant="subtle" href={editHref}>Edit</Button>
|
||||
</div>
|
||||
{/if}
|
||||
@@ -7,8 +7,13 @@
|
||||
import { isCloudHosted } from '$lib/cloud'
|
||||
import { Alert, Skeleton } from '$lib/components/common'
|
||||
import { WindmillIcon } from '$lib/components/icons'
|
||||
import { onMount, setContext } from 'svelte'
|
||||
import { IS_APP_PUBLIC_CONTEXT_KEY, type EditorBreakpoint } from '../types'
|
||||
import { getContext, onMount, setContext } from 'svelte'
|
||||
import {
|
||||
EMBED_NAV_CONTEXT_KEY,
|
||||
IS_APP_PUBLIC_CONTEXT_KEY,
|
||||
type EditorBreakpoint,
|
||||
type EmbedNav
|
||||
} from '../types'
|
||||
import { UserService, type AppWithLastVersion, type GlobalWhoamiResponse } from '$lib/gen'
|
||||
import { urlParamsToObject } from '$lib/utils'
|
||||
import { goto } from '$app/navigation'
|
||||
@@ -24,7 +29,9 @@
|
||||
jwtError,
|
||||
onLoginSuccess,
|
||||
app,
|
||||
workspace
|
||||
workspace,
|
||||
inWorkspace = false,
|
||||
hideRefreshBar = false
|
||||
}: {
|
||||
notExists: boolean
|
||||
noPermission: boolean
|
||||
@@ -32,12 +39,27 @@
|
||||
onLoginSuccess: () => void
|
||||
app: (AppWithLastVersion & { value: any; workspace_id?: string }) | undefined
|
||||
workspace: string | undefined
|
||||
/**
|
||||
* In-workspace rendering (`/apps/get`, `/app_embed`): keep exact parity
|
||||
* with the pre-sandbox member viewer — no "Powered by Windmill" badge, no
|
||||
* user overlay, no HTML-result approval gate, column flex wrapper.
|
||||
*/
|
||||
inWorkspace?: boolean
|
||||
hideRefreshBar?: boolean
|
||||
} = $props()
|
||||
|
||||
// Use workspace from props or from app.workspace_id (for custom path responses)
|
||||
let effectiveWorkspace = $derived(workspace ?? app?.workspace_id)
|
||||
|
||||
setContext(IS_APP_PUBLIC_CONTEXT_KEY, true)
|
||||
// HTML results from runnables only need viewer approval on the public
|
||||
// surfaces (untrusted distribution); the in-workspace viewer never gated them.
|
||||
setContext(IS_APP_PUBLIC_CONTEXT_KEY, !inWorkspace)
|
||||
|
||||
// WIN-2006: inside the opaque viewer iframe, navigations to other routes
|
||||
// (navbar "app" items) must happen on the TOP page — the iframe is cookieless,
|
||||
// so navigating it would just show a login screen. PublicAppFrame provides the
|
||||
// relay; outside the opaque viewer this is undefined and goto works directly.
|
||||
const embedNav = getContext<EmbedNav | undefined>(EMBED_NAV_CONTEXT_KEY)
|
||||
|
||||
const breakpoint = writable<EditorBreakpoint>('lg')
|
||||
|
||||
@@ -71,27 +93,29 @@
|
||||
})
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="z-50 text-xs fixed bottom-1 right-2 {$enterpriseLicense && !isCloudHosted()
|
||||
? 'transition-opacity delay-1000 duration-1000 opacity-20 hover:delay-0 hover:opacity-100'
|
||||
: ''}"
|
||||
>
|
||||
<a href="https://windmill.dev" class="whitespace-nowrap text-primary inline-flex items-center"
|
||||
>Powered by <WindmillIcon /> Windmill</a
|
||||
{#if !inWorkspace}
|
||||
<div
|
||||
class="z-50 text-xs fixed bottom-1 right-2 {$enterpriseLicense && !isCloudHosted()
|
||||
? 'transition-opacity delay-1000 duration-1000 opacity-20 hover:delay-0 hover:opacity-100'
|
||||
: ''}"
|
||||
>
|
||||
</div>
|
||||
<a href="https://windmill.dev" class="whitespace-nowrap text-primary inline-flex items-center"
|
||||
>Powered by <WindmillIcon /> Windmill</a
|
||||
>
|
||||
</div>
|
||||
|
||||
{#snippet userInfo(child)}
|
||||
<div class="flex gap-1 items-center"><User size={14} />{child}</div>
|
||||
{/snippet}
|
||||
{#snippet userInfo(child)}
|
||||
<div class="flex gap-1 items-center"><User size={14} />{child}</div>
|
||||
{/snippet}
|
||||
|
||||
<div class="z-50 text-2xs text-primary absolute top-3 left-2"
|
||||
>{#if $userStore}
|
||||
{@render userInfo($userStore.username)}
|
||||
{:else if globalUser}
|
||||
{@render userInfo(globalUser.email)}
|
||||
{:else}<UserRoundX size={14} />{/if}
|
||||
</div>
|
||||
<div class="z-50 text-2xs text-primary absolute top-3 left-2"
|
||||
>{#if $userStore}
|
||||
{@render userInfo($userStore.username)}
|
||||
{:else if globalUser}
|
||||
{@render userInfo(globalUser.email)}
|
||||
{:else}<UserRoundX size={14} />{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if notExists}
|
||||
<div class="px-4 mt-20"
|
||||
@@ -132,7 +156,11 @@
|
||||
{:else}
|
||||
<div
|
||||
class={twMerge(
|
||||
'min-h-screen h-full w-full flex',
|
||||
// `flex-col` matches the pre-sandbox in-workspace viewer exactly;
|
||||
// the public viewer always used a plain `flex` wrapper.
|
||||
inWorkspace
|
||||
? 'min-h-screen h-full w-full flex flex-col'
|
||||
: 'min-h-screen h-full w-full flex',
|
||||
app?.value?.['css']?.['app']?.['viewer']?.class,
|
||||
'wm-app-viewer'
|
||||
)}
|
||||
@@ -140,6 +168,7 @@
|
||||
>
|
||||
<AppPreview
|
||||
noBackend={false}
|
||||
{hideRefreshBar}
|
||||
context={{
|
||||
email: $userStore?.email,
|
||||
name: $userStore?.name,
|
||||
@@ -156,7 +185,7 @@
|
||||
policy={app.policy}
|
||||
isEditor={false}
|
||||
replaceStateFn={(path) => goto(path)}
|
||||
gotoFn={(path, opt) => goto(path, opt)}
|
||||
gotoFn={(path, opt) => (embedNav ? embedNav.navigateTop(path) : goto(path, opt))}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@@ -0,0 +1,427 @@
|
||||
<script lang="ts">
|
||||
/*
|
||||
* WIN-2006: published apps render arbitrary user-authored markup/JS. When the
|
||||
* publisher opts an app into sandbox isolation (alpha), that untrusted code (a
|
||||
* malicious author, or an XSS bug in the app) must not run with the viewer's
|
||||
* session. How it's contained depends on the app:
|
||||
*
|
||||
* - Low-code, sandboxed: rendered in an **opaque-origin** (sandboxed, no
|
||||
* `allow-same-origin`) iframe and handed a **narrowly-scoped embed token** —
|
||||
* never the session cookie. This component is the embedder (top window:
|
||||
* authenticates the viewer, mints the token, renders the opaque iframe) and,
|
||||
* inside that iframe (`wm_embed=1`), the viewer (uses the token as its only
|
||||
* credential, then renders the app).
|
||||
* - Raw, sandboxed: rendered directly here as a **single** opaque bundle iframe
|
||||
* (the author bundle is already isolated in its own opaque iframe); no opaque
|
||||
* viewer / embed token needed.
|
||||
* - Unsandboxed (default): the app runs same-origin with the viewer's full
|
||||
* session, the pre-isolation behavior. Rendered directly here.
|
||||
*
|
||||
* No `PUBLIC_APP_DOMAIN` is required: the opaque origin is, for same-origin-policy
|
||||
* purposes, as foreign to the main app as a different domain. The opaque viewer's
|
||||
* API calls are cross-origin and rely on `Access-Control-Allow-Origin: *` + the
|
||||
* bearer token (no cookie); the raw wrapper document always carries `CSP: sandbox`.
|
||||
*/
|
||||
import { BROWSER } from 'esm-env'
|
||||
import { OpenAPI } from '$lib/gen'
|
||||
import { page } from '$app/state'
|
||||
import { onDestroy, onMount, setContext, type Snippet } from 'svelte'
|
||||
import { Alert, Skeleton } from '$lib/components/common'
|
||||
import { base } from '$app/paths'
|
||||
import { goto } from '$app/navigation'
|
||||
import Login from '$lib/components/Login.svelte'
|
||||
import { WINDMILL_RESERVED_QUERY_PARAMS } from '$lib/utils'
|
||||
import { EMBED_NAV_CONTEXT_KEY, type EmbedNav } from '../types'
|
||||
|
||||
type EmbedToken = {
|
||||
token?: string | null
|
||||
raw_app?: boolean
|
||||
sandbox?: boolean
|
||||
app_path?: string | null
|
||||
workspace_id?: string | null
|
||||
}
|
||||
|
||||
let {
|
||||
fetchEmbedToken,
|
||||
onViewerReady,
|
||||
viewer,
|
||||
viewerUrl
|
||||
}: {
|
||||
/** Embedder-side: validate access + mint the scoped token. Throws with a
|
||||
* `.status` of 401 (login required) or 404 (not found). */
|
||||
fetchEmbedToken: () => Promise<EmbedToken>
|
||||
/** Viewer-side: fired (once per received token) when the embed token is
|
||||
* available, before the app renders. Use it to kick off data loading.
|
||||
* `requestTokenRefresh` asks the embedder for a fresh token on a 401. */
|
||||
onViewerReady?: (token: string | undefined, requestTokenRefresh: () => void) => void
|
||||
/** Viewer-side: renders the actual app once the embed token is available. */
|
||||
viewer: Snippet
|
||||
/** Embedder-side: override the opaque iframe src (the route that renders the
|
||||
* viewer). Defaults to the current route + `wm_embed=1` (public routes embed
|
||||
* themselves). The in-workspace viewer sets this because its embedder route
|
||||
* (`/apps/get`, auth-gated, with chrome) differs from the cookieless,
|
||||
* chrome-less viewer route (`/app_embed`). */
|
||||
viewerUrl?: string
|
||||
} = $props()
|
||||
|
||||
const EMBED_PARAM = 'wm_embed'
|
||||
const ORIGIN_PARAM = 'wm_embedder_origin'
|
||||
|
||||
const framed = BROWSER && window.parent !== window
|
||||
const isViewer = BROWSER && page.url.searchParams.get(EMBED_PARAM) === '1' && framed
|
||||
|
||||
// ----------------------------- viewer mode -----------------------------
|
||||
let viewerToken: string | undefined = $state(undefined)
|
||||
let viewerReady = $state(false)
|
||||
// Set when viewer mode never receives a token (see the orphan timer in onMount):
|
||||
// the page carries `wm_embed` but isn't actually framed by a Windmill embedder.
|
||||
let viewerOrphaned = $state(false)
|
||||
const expectedEmbedderOrigin = BROWSER ? page.url.searchParams.get(ORIGIN_PARAM) : null
|
||||
|
||||
// Components that embed the token in a URL (images, PDFs, downloads, SSE) read
|
||||
// it from the `AuthToken` context. In viewer mode that must be the embed token;
|
||||
// the getter keeps it in sync once the token arrives. In direct render
|
||||
// (unsandboxed / raw — the viewer snippet runs on this page, not in the opaque
|
||||
// iframe) expose the page's own bearer credential when there is one: JWT public
|
||||
// URLs put it in `OpenAPI.TOKEN` (set before the app loads) and have no cookie
|
||||
// to fall back on. Cookie sessions have no bearer here and keep using the cookie.
|
||||
setContext<{ token?: string }>('AuthToken', {
|
||||
get token() {
|
||||
if (isViewer) return viewerToken
|
||||
return typeof OpenAPI.TOKEN === 'string' && OpenAPI.TOKEN ? OpenAPI.TOKEN : undefined
|
||||
}
|
||||
})
|
||||
|
||||
function handleViewerMessage(e: MessageEvent) {
|
||||
// The embedder has a real origin, so we can validate both the source and
|
||||
// the origin of messages we receive.
|
||||
if (e.source !== window.parent) return
|
||||
if (expectedEmbedderOrigin && e.origin !== expectedEmbedderOrigin) return
|
||||
if (e.data?.type === 'wm_embed_token') {
|
||||
const token = e.data.token ?? undefined
|
||||
viewerToken = token
|
||||
// The bearer token (when present) is the credential the app uses; no
|
||||
// cookie reaches this opaque origin.
|
||||
OpenAPI.TOKEN = token
|
||||
viewerReady = true
|
||||
viewerOrphaned = false
|
||||
onViewerReady?.(token, requestTokenRefresh)
|
||||
}
|
||||
}
|
||||
|
||||
/** Passed to the viewer: when the rendered app gets a 401 (e.g. the embed
|
||||
* token expired) it calls this to ask the embedder for a fresh token. */
|
||||
function requestTokenRefresh() {
|
||||
if (!isViewer) {
|
||||
// Embedder-side direct render (raw app, or unsandboxed app): there is no
|
||||
// opaque viewer to message — just re-run the access check / app load.
|
||||
initEmbedder()
|
||||
return
|
||||
}
|
||||
viewerReady = false
|
||||
window.parent.postMessage({ type: 'wm_embed_unauthorized' }, expectedEmbedderOrigin ?? '*')
|
||||
}
|
||||
|
||||
// WIN-2006: the app runs inside the (opaque) viewer iframe, so its in-app URL
|
||||
// changes never reach the top address bar — breaking shareable deep links. Relay
|
||||
// the hash + query up to the embedder, which mirrors them onto its own URL
|
||||
// (e.g. the navbar component's same-app links set ?query and #hash). Never the
|
||||
// path: the embedder keeps its own pathname, so a hostile app can't rewrite the
|
||||
// address bar to an unrelated route. Transport params (wm_embed, …) are
|
||||
// stripped before relaying.
|
||||
let origPushState: typeof history.pushState | undefined
|
||||
let origReplaceState: typeof history.replaceState | undefined
|
||||
function relayHash() {
|
||||
try {
|
||||
const params = new URLSearchParams(window.location.search)
|
||||
const reserved: string[] = []
|
||||
params.forEach((_v, k) => {
|
||||
if (WINDMILL_RESERVED_QUERY_PARAMS.has(k)) reserved.push(k)
|
||||
})
|
||||
reserved.forEach((k) => params.delete(k))
|
||||
const qs = params.toString()
|
||||
const search = qs ? `?${qs}` : ''
|
||||
window.parent.postMessage(
|
||||
{ type: 'wm_embed_hash', hash: window.location.hash, search },
|
||||
expectedEmbedderOrigin ?? '*'
|
||||
)
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
// Top-level navigation relay (WIN-2006): in-app links to other apps (navbar
|
||||
// "app" items) must navigate the top page — navigating inside the opaque
|
||||
// iframe would load the SPA cookieless. Consumed by PublicApp via gotoFn.
|
||||
setContext<EmbedNav | undefined>(
|
||||
EMBED_NAV_CONTEXT_KEY,
|
||||
isViewer
|
||||
? {
|
||||
navigateTop: (href: string) => {
|
||||
try {
|
||||
window.parent.postMessage(
|
||||
{ type: 'wm_embed_navigate', href },
|
||||
expectedEmbedderOrigin ?? '*'
|
||||
)
|
||||
} catch (_) {}
|
||||
}
|
||||
}
|
||||
: undefined
|
||||
)
|
||||
function installHashRelay() {
|
||||
origPushState = history.pushState
|
||||
origReplaceState = history.replaceState
|
||||
history.pushState = function (data, unused, url) {
|
||||
origPushState?.call(history, data, unused, url ?? null)
|
||||
relayHash()
|
||||
}
|
||||
history.replaceState = function (data, unused, url) {
|
||||
origReplaceState?.call(history, data, unused, url ?? null)
|
||||
relayHash()
|
||||
}
|
||||
window.addEventListener('hashchange', relayHash)
|
||||
window.addEventListener('popstate', relayHash)
|
||||
}
|
||||
function uninstallHashRelay() {
|
||||
if (origPushState) history.pushState = origPushState
|
||||
if (origReplaceState) history.replaceState = origReplaceState
|
||||
window.removeEventListener('hashchange', relayHash)
|
||||
window.removeEventListener('popstate', relayHash)
|
||||
}
|
||||
|
||||
// ---------------------------- embedder mode ----------------------------
|
||||
let status: 'loading' | 'ready' | 'noPermission' | 'notExists' = $state('loading')
|
||||
let embedToken: string | null = $state(null)
|
||||
let iframeEl: HTMLIFrameElement | undefined = $state(undefined)
|
||||
|
||||
// WIN-2006: publisher opted this app into sandbox isolation (alpha). When false
|
||||
// (the default) the app runs same-origin with the viewer's full session — the
|
||||
// pre-isolation behavior.
|
||||
let sandboxed = $state(false)
|
||||
|
||||
// WIN-2006 Variant A: raw apps render single-iframe. The author's bundle is
|
||||
// already isolated in its own opaque iframe (served + CSP-sandboxed by the
|
||||
// backend), so the viewer skips the opaque-viewer indirection and the embed
|
||||
// token entirely — it renders the app directly on this (real) origin and the
|
||||
// bridge calls the backend with the page credential (cookie / JWT / anonymous),
|
||||
// matching the logged-in raw viewer. Low-code keeps the opaque viewer + token.
|
||||
let isRaw = $state(false)
|
||||
|
||||
// WIN-2006: the resolved app path + workspace, used together to scope this app's
|
||||
// backing localStorage (below) so sandboxed apps don't share one store (even two
|
||||
// apps at the same path in different workspaces).
|
||||
let appPath: string | undefined = $state(undefined)
|
||||
let workspaceId: string | undefined = $state(undefined)
|
||||
|
||||
// Same-origin (full session) execution: every app that wasn't opted into
|
||||
// sandbox isolation. RawAppPreview reads this to drop the bundle's opaque
|
||||
// sandbox; an unsandboxed low-code app renders directly here.
|
||||
let unsandboxed = $derived(!sandboxed)
|
||||
|
||||
// Read by RawAppPreview (and any app component) to render same-origin (full
|
||||
// access) instead of the sandboxed bundle iframe.
|
||||
setContext('IS_APP_UNSANDBOXED', {
|
||||
get value() {
|
||||
return unsandboxed
|
||||
}
|
||||
})
|
||||
|
||||
function buildViewerUrl(): string {
|
||||
// Default: embed the current route. The in-workspace viewer overrides this
|
||||
// with a dedicated cookieless, chrome-less viewer route (`/app_embed`).
|
||||
const url = new URL(viewerUrl ?? window.location.href, window.location.origin)
|
||||
url.searchParams.set(EMBED_PARAM, '1')
|
||||
url.searchParams.set(ORIGIN_PARAM, window.location.origin)
|
||||
// Same origin (no separate domain); the sandbox makes it opaque.
|
||||
return url.pathname + url.search + url.hash
|
||||
}
|
||||
|
||||
async function initEmbedder() {
|
||||
status = 'loading'
|
||||
try {
|
||||
const resp = await fetchEmbedToken()
|
||||
embedToken = resp.token ?? null
|
||||
sandboxed = resp.sandbox ?? false
|
||||
isRaw = resp.raw_app ?? false
|
||||
appPath = resp.app_path ?? undefined
|
||||
workspaceId = resp.workspace_id ?? undefined
|
||||
status = 'ready'
|
||||
|
||||
if (unsandboxed || isRaw) {
|
||||
// Render the app directly on this origin: same-origin when unsandboxed
|
||||
// (the default), or a single opaque bundle iframe when it's a sandboxed
|
||||
// raw app.
|
||||
onViewerReady?.(undefined, requestTokenRefresh)
|
||||
} else {
|
||||
// Sandboxed low-code: hand the scoped token to the opaque viewer iframe.
|
||||
postTokenToIframe()
|
||||
}
|
||||
} catch (e: any) {
|
||||
status = e?.status === 401 ? 'noPermission' : 'notExists'
|
||||
}
|
||||
}
|
||||
|
||||
function postTokenToIframe() {
|
||||
// The iframe is an opaque origin ("null"), which cannot be named as a
|
||||
// targetOrigin, so we use '*'. This only relaxes the receiver-origin
|
||||
// check; the message is still delivered solely to our own iframe's
|
||||
// contentWindow, whose content we control.
|
||||
iframeEl?.contentWindow?.postMessage({ type: 'wm_embed_token', token: embedToken }, '*')
|
||||
}
|
||||
|
||||
// Persistence authority (WIN-2006): opaque app frames (the viewer SPA) have no
|
||||
// real Web Storage, so the embedder — on the real origin — backs their
|
||||
// `localStorage` here. The store is scoped PER APP (keyed by workspace + app path)
|
||||
// so one sandboxed app cannot read or clobber another's storage. Updated with
|
||||
// per-key ops so concurrent frames of the same app merge instead of clobbering.
|
||||
function lsKey(): string {
|
||||
return `wm_apps_localstorage:${workspaceId ?? ''}:${appPath ?? ''}`
|
||||
}
|
||||
function readSharedLs(): Record<string, string> {
|
||||
try {
|
||||
return JSON.parse(localStorage.getItem(lsKey()) || '{}')
|
||||
} catch (_) {
|
||||
return {}
|
||||
}
|
||||
}
|
||||
function applyLsOp(d: any) {
|
||||
const s = readSharedLs()
|
||||
if (d.op === 'set') s[d.key] = String(d.value)
|
||||
else if (d.op === 'remove') delete s[d.key]
|
||||
else if (d.op === 'clear') for (const k in s) delete s[k]
|
||||
try {
|
||||
localStorage.setItem(lsKey(), JSON.stringify(s))
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
function handleEmbedderMessage(e: MessageEvent) {
|
||||
// The viewer is opaque-origin (e.origin === 'null'), so we authenticate
|
||||
// the message by source identity only. Storage messages come from the opaque
|
||||
// low-code viewer SPA's shim and are backed in this app's per-app store.
|
||||
if (e.source !== iframeEl?.contentWindow) return
|
||||
if (e.data?.type === 'wm_embed_ready') {
|
||||
postTokenToIframe()
|
||||
} else if (e.data?.type === 'wm_embed_unauthorized') {
|
||||
initEmbedder()
|
||||
} else if (e.data?.type === 'wm_ls_req') {
|
||||
iframeEl?.contentWindow?.postMessage({ type: 'wm_ls_hydrate', data: readSharedLs() }, '*')
|
||||
} else if (e.data?.type === 'wm_ls_op') {
|
||||
applyLsOp(e.data)
|
||||
} else if (e.data?.type === 'wm_embed_hash') {
|
||||
// Mirror the viewer's in-app hash + query onto our own URL (shareable
|
||||
// deep links). Keep our pathname (an app can't rewrite the address bar
|
||||
// to another route) and our own transport params (e.g. wm_coep).
|
||||
const hash = typeof e.data.hash === 'string' ? e.data.hash : ''
|
||||
const relayed = new URLSearchParams(
|
||||
typeof e.data.search === 'string' ? e.data.search : window.location.search
|
||||
)
|
||||
for (const k of WINDMILL_RESERVED_QUERY_PARAMS) {
|
||||
relayed.delete(k)
|
||||
const own = new URLSearchParams(window.location.search).get(k)
|
||||
if (own !== null) relayed.set(k, own)
|
||||
}
|
||||
const qs = relayed.toString()
|
||||
const search = qs ? `?${qs}` : ''
|
||||
if (window.location.hash !== hash || window.location.search !== search) {
|
||||
history.replaceState(null, '', window.location.pathname + search + hash)
|
||||
}
|
||||
} else if (e.data?.type === 'wm_embed_navigate') {
|
||||
// App-initiated same-window navigation (navbar app items, frontend-script
|
||||
// `goto`, button `gotoUrl`): this page navigates itself, exactly like when
|
||||
// the app ran on it pre-sandbox. Same-origin paths use SPA navigation;
|
||||
// full http(s) URLs do a real load (pre-sandbox apps could already do
|
||||
// this via window.location, so it grants nothing new). Everything else —
|
||||
// javascript:, data:, protocol-relative `//host` — is rejected.
|
||||
const href = typeof e.data.href === 'string' ? e.data.href : ''
|
||||
if (href.startsWith('/') && !href.startsWith('//')) {
|
||||
goto(href)
|
||||
} else if (/^https?:\/\//i.test(href)) {
|
||||
window.location.href = href
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
if (isViewer) {
|
||||
window.addEventListener('message', handleViewerMessage)
|
||||
installHashRelay()
|
||||
// Announce readiness so the embedder sends us the token.
|
||||
window.parent.postMessage({ type: 'wm_embed_ready' }, expectedEmbedderOrigin ?? '*')
|
||||
// This page is only ever loaded as the embedder's opaque iframe, which
|
||||
// replies with the token within milliseconds. If none arrives, it was
|
||||
// opened with `wm_embed` outside a Windmill embedder (e.g. its iframe src
|
||||
// embedded directly in a third-party page); show a diagnostic instead of an
|
||||
// indefinite skeleton.
|
||||
const orphanTimer = setTimeout(() => {
|
||||
if (!viewerReady) viewerOrphaned = true
|
||||
}, 3000)
|
||||
return () => clearTimeout(orphanTimer)
|
||||
} else {
|
||||
window.addEventListener('message', handleEmbedderMessage)
|
||||
initEmbedder()
|
||||
}
|
||||
})
|
||||
|
||||
onDestroy(() => {
|
||||
if (!BROWSER) return
|
||||
window.removeEventListener('message', handleViewerMessage)
|
||||
window.removeEventListener('message', handleEmbedderMessage)
|
||||
if (isViewer) uninstallHashRelay()
|
||||
})
|
||||
</script>
|
||||
|
||||
{#if isViewer}
|
||||
{#if viewerReady}
|
||||
{@render viewer()}
|
||||
{:else if viewerOrphaned}
|
||||
<div class="px-4 mt-20 max-w-xl mx-auto">
|
||||
<Alert type="info" title="Open this app from Windmill">
|
||||
This is a Windmill app viewer and must be loaded by Windmill. If you embedded it in your own
|
||||
page, use the app's public URL without the <code>wm_embed</code> parameter.
|
||||
</Alert>
|
||||
</div>
|
||||
{:else}
|
||||
<Skeleton layout={[[4], 0.5, [50]]} />
|
||||
{/if}
|
||||
{:else if status === 'loading'}
|
||||
<Skeleton layout={[[4], 0.5, [50]]} />
|
||||
{:else if status === 'notExists'}
|
||||
<div class="px-4 mt-20">
|
||||
<Alert type="error" title="Not found">
|
||||
There was an error loading the app, is the url correct?
|
||||
<a href={base}>Go to Windmill</a>
|
||||
</Alert>
|
||||
</div>
|
||||
{:else if status === 'noPermission'}
|
||||
<!-- Login happens here, on the embedder (main) window, so the session cookie
|
||||
is set on the main origin only and never reaches the opaque iframe. -->
|
||||
<div class="px-4 mt-20 w-full text-center font-bold text-xl">This app requires read access</div>
|
||||
<div class="px-2 mx-auto mt-20 max-w-xl w-full">
|
||||
<Login
|
||||
onLoginSuccess={() => initEmbedder()}
|
||||
popup
|
||||
rd={page.url.pathname + page.url.search + page.url.hash}
|
||||
/>
|
||||
</div>
|
||||
{:else if unsandboxed}
|
||||
<!-- Same-origin (full session): the app was not opted into sandbox isolation
|
||||
(the default). Rendered directly here; RawAppPreview reads
|
||||
IS_APP_UNSANDBOXED to drop the bundle's opaque sandbox. -->
|
||||
{@render viewer()}
|
||||
{:else if isRaw}
|
||||
<!-- Variant A: sandboxed raw app rendered directly on the real origin. The
|
||||
untrusted author bundle stays isolated in its own opaque iframe (inside
|
||||
RawAppPreview); no opaque viewer and no embed token are needed. -->
|
||||
{@render viewer()}
|
||||
{:else}
|
||||
<!-- referrerpolicy: the embedder page URL can carry a viewer credential (the
|
||||
JWT path segment of share links); without this, the same-origin iframe
|
||||
navigation would expose it to app-authored code via document.referrer. -->
|
||||
<iframe
|
||||
bind:this={iframeEl}
|
||||
src={buildViewerUrl()}
|
||||
title="App"
|
||||
class="w-full h-screen border-0 block"
|
||||
sandbox="allow-scripts allow-forms allow-popups allow-popups-to-escape-sandbox allow-downloads allow-modals allow-top-navigation"
|
||||
allow="clipboard-read; clipboard-write; fullscreen"
|
||||
referrerpolicy="no-referrer"
|
||||
></iframe>
|
||||
{/if}
|
||||
@@ -180,12 +180,13 @@ export async function updatePolicy(app: App, currentPolicy: Policy | undefined):
|
||||
})
|
||||
.filter(Boolean) as { s3_path: string; storage?: string | undefined }[]
|
||||
|
||||
return {
|
||||
const next = {
|
||||
...(currentPolicy ?? {}),
|
||||
allowed_s3_keys: s3FileKeys,
|
||||
s3_inputs,
|
||||
triggerables_v2: ntriggerables
|
||||
}
|
||||
return next
|
||||
}
|
||||
|
||||
export async function processRunnable(
|
||||
|
||||
@@ -370,6 +370,12 @@ export type EditorBreakpoint = 'sm' | 'lg'
|
||||
|
||||
export const IS_APP_PUBLIC_CONTEXT_KEY = 'isAppPublicContext' as const
|
||||
|
||||
// Set by PublicAppFrame in opaque-viewer mode (WIN-2006). Lets the app relay
|
||||
// top-level navigations (e.g. navbar links to another app) to the embedder,
|
||||
// since navigating inside the opaque iframe would load the SPA cookieless.
|
||||
export const EMBED_NAV_CONTEXT_KEY = 'appEmbedNav' as const
|
||||
export type EmbedNav = { navigateTop: (href: string) => void }
|
||||
|
||||
type ComponentID = string
|
||||
|
||||
export type ContextPanelContext = {
|
||||
|
||||
@@ -20,6 +20,31 @@ import type {
|
||||
} from './types'
|
||||
import { allItems, BG_PREFIX } from './editor/appUtilsCore'
|
||||
|
||||
/**
|
||||
* Same-window navigation for app code (frontend-script `goto`, button
|
||||
* `onSuccess: gotoUrl`). Inside the opaque viewer iframe (WIN-2006,
|
||||
* `wm_embed=1`), navigating the current window would load the target inside
|
||||
* the cookieless frame — so the navigation is relayed to the embedder page,
|
||||
* which navigates itself (`wm_embed_navigate` in PublicAppFrame). That matches
|
||||
* the pre-sandbox behavior exactly: the app used to run ON the embedder page,
|
||||
* including when that page is itself inside a third-party iframe (where the
|
||||
* embedder — not the third party's top — was what `window.location` changed).
|
||||
* Outside the opaque viewer it keeps navigating the current window as before.
|
||||
*/
|
||||
export function appNavigateSameWindow(url: string) {
|
||||
try {
|
||||
const params = new URLSearchParams(window.location.search)
|
||||
if (window.parent !== window && params.get('wm_embed') === '1') {
|
||||
window.parent.postMessage(
|
||||
{ type: 'wm_embed_navigate', href: url },
|
||||
params.get('wm_embedder_origin') ?? '*'
|
||||
)
|
||||
return
|
||||
}
|
||||
} catch (_) {}
|
||||
window.location.href = url
|
||||
}
|
||||
|
||||
// `migrateApp` moved to its own light module so non-editor callers can reuse it
|
||||
// without pulling the whole `apps/utils` graph; re-exported here for existing
|
||||
// `from '../utils'` importers.
|
||||
|
||||
@@ -33,7 +33,7 @@
|
||||
</script>
|
||||
|
||||
<Row
|
||||
href="{base}/apps/get_raw/{app.version}/{app.path}"
|
||||
href="{base}/apps_raw/get/{app.path}"
|
||||
kind="raw_app"
|
||||
{marked}
|
||||
path={app.path}
|
||||
|
||||
@@ -78,8 +78,10 @@ import {
|
||||
import type { WorkspaceMutationTarget } from './workspaceTools'
|
||||
import {
|
||||
globalToolsFor,
|
||||
loadWorkspaceSkills,
|
||||
prepareGlobalSystemMessage,
|
||||
prepareGlobalUserMessage,
|
||||
type AiSkillListItem,
|
||||
type GlobalToolHelpers
|
||||
} from './global/core'
|
||||
import { isGlobalAiEnabled } from './global/gate'
|
||||
@@ -322,6 +324,12 @@ export class AIChatManager {
|
||||
// session rather than the UI-active one — keeps backgrounded sessions isolated.
|
||||
sessionId: string | undefined = undefined
|
||||
|
||||
// Workspace AI skills (name + description) advertised in the GLOBAL system
|
||||
// prompt. Loaded asynchronously when entering GLOBAL mode; the system message
|
||||
// is rebuilt once they resolve.
|
||||
private globalSkills: AiSkillListItem[] = []
|
||||
private globalSkillsRefreshId = 0
|
||||
|
||||
allowedModes: Record<AIMode, boolean> = $derived({
|
||||
script:
|
||||
this.flowAiChatHelpers === undefined &&
|
||||
@@ -814,7 +822,8 @@ export class AIChatManager {
|
||||
} else if (mode === AIMode.GLOBAL) {
|
||||
const customPrompt = getCombinedCustomPrompt(mode)
|
||||
this.systemMessage = prepareGlobalSystemMessage(customPrompt, {
|
||||
previewTools: this.isSessionChat
|
||||
previewTools: this.isSessionChat,
|
||||
skills: this.globalSkills
|
||||
})
|
||||
this.tools = globalToolsFor({ sessionPreview: this.isSessionChat })
|
||||
this.helpers = {
|
||||
@@ -823,6 +832,7 @@ export class AIChatManager {
|
||||
this.flowAiChatHelpers?.testFlow(args),
|
||||
attachedFiles: this.attachedFiles
|
||||
} satisfies GlobalToolHelpers
|
||||
void this.refreshGlobalSkills()
|
||||
} else if (mode === AIMode.APP) {
|
||||
const customPrompt = getCombinedCustomPrompt(mode)
|
||||
this.systemMessage = prepareAppSystemMessage(customPrompt)
|
||||
@@ -831,6 +841,24 @@ export class AIChatManager {
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch the workspace's AI skills and, if GLOBAL mode is still active, rebuild
|
||||
// the system message so the next chat-loop iteration advertises them. Ignore
|
||||
// stale resolves so workspace changes cannot overwrite newer skills.
|
||||
private refreshGlobalSkills = async (workspace = get(workspaceStore) ?? '') => {
|
||||
const refreshId = ++this.globalSkillsRefreshId
|
||||
const skills = await loadWorkspaceSkills(workspace)
|
||||
if (refreshId !== this.globalSkillsRefreshId) {
|
||||
return
|
||||
}
|
||||
this.globalSkills = skills
|
||||
if (this.mode === AIMode.GLOBAL) {
|
||||
this.systemMessage = prepareGlobalSystemMessage(getCombinedCustomPrompt(AIMode.GLOBAL), {
|
||||
previewTools: this.isSessionChat,
|
||||
skills
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
canApplyCode = $derived(this.allowedModes.script && this.mode === AIMode.SCRIPT)
|
||||
|
||||
private changeModeTool = {
|
||||
@@ -1249,6 +1277,11 @@ export class AIChatManager {
|
||||
return false
|
||||
}
|
||||
}
|
||||
// Session chats commit their workspace in beforeSend; skills must match the
|
||||
// committed workspace before the system prompt is sent.
|
||||
if (this.mode === AIMode.GLOBAL) {
|
||||
await this.refreshGlobalSkills(get(workspaceStore) ?? '')
|
||||
}
|
||||
const isFirstUserTurn = !this.displayMessages.some((message) => message.role === 'user')
|
||||
// Declared outside `try` so the catch can recover what the loop produced
|
||||
// before a failure: the structured messages and the latest streamed text
|
||||
|
||||
@@ -15,7 +15,9 @@ const mocks = vi.hoisted(() => ({
|
||||
getOpenaiClient: vi.fn(),
|
||||
getAnthropicClient: vi.fn(),
|
||||
getNonStreamingCompletion: vi.fn(),
|
||||
runChatLoop: vi.fn()
|
||||
runChatLoop: vi.fn(),
|
||||
listAiSkills: vi.fn(),
|
||||
workspace: 'test_workspace' as string | undefined
|
||||
}))
|
||||
|
||||
vi.mock('monaco-editor', () => ({
|
||||
@@ -24,7 +26,8 @@ vi.mock('monaco-editor', () => ({
|
||||
|
||||
vi.mock('$lib/gen', () => ({
|
||||
WorkspaceService: {
|
||||
logAiChat: mocks.logAiChat
|
||||
logAiChat: mocks.logAiChat,
|
||||
listAiSkills: mocks.listAiSkills
|
||||
},
|
||||
ScriptService: {},
|
||||
FlowService: {},
|
||||
@@ -36,7 +39,12 @@ vi.mock('$lib/gen', () => ({
|
||||
const TEST_EMAIL = 'admin@test'
|
||||
|
||||
vi.mock('$lib/stores', () => ({
|
||||
workspaceStore: { subscribe: () => () => undefined },
|
||||
workspaceStore: {
|
||||
subscribe: (run: (value: string | undefined) => void) => {
|
||||
run(mocks.workspace)
|
||||
return () => undefined
|
||||
}
|
||||
},
|
||||
userStore: {
|
||||
subscribe: (run: (value: { username: string; email: string }) => void) => {
|
||||
run({ username: 'admin', email: 'admin@test' })
|
||||
@@ -95,6 +103,8 @@ beforeEach(() => {
|
||||
mocks.logAiChat.mockResolvedValue(undefined)
|
||||
mocks.getOpenaiClient.mockReturnValue({})
|
||||
mocks.getAnthropicClient.mockReturnValue({})
|
||||
mocks.listAiSkills.mockResolvedValue([])
|
||||
mocks.workspace = 'test_workspace'
|
||||
mocks.runChatLoop.mockResolvedValue({
|
||||
addedMessages: [],
|
||||
tokenUsage: { prompt: 0, completion: 0, total: 0 },
|
||||
@@ -185,6 +195,57 @@ describe('AIChatManager request errors', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('AIChatManager global skills', () => {
|
||||
const model = { provider: 'openai', model: 'gpt-4o' }
|
||||
|
||||
beforeEach(() => {
|
||||
localStorage.clear()
|
||||
mocks.getCurrentModel.mockReturnValue(model)
|
||||
mocks.tryGetCurrentModel.mockReturnValue(model)
|
||||
})
|
||||
|
||||
it('loads skills after beforeSend commits the session workspace', async () => {
|
||||
let resolveParentSkills: ((skills: { name: string; description: string }[]) => void) | undefined
|
||||
const parentSkills = new Promise<{ name: string; description: string }[]>((resolve) => {
|
||||
resolveParentSkills = resolve
|
||||
})
|
||||
mocks.workspace = 'parent'
|
||||
mocks.listAiSkills.mockImplementation(({ workspace }: { workspace: string }) => {
|
||||
if (workspace === 'parent') {
|
||||
return parentSkills
|
||||
}
|
||||
return Promise.resolve([{ name: 'child-skill', description: 'child workspace skill' }])
|
||||
})
|
||||
mocks.runChatLoop.mockImplementation(async (config: any) => {
|
||||
expect(config.workspace).toBe('child')
|
||||
expect(config.systemMessage.content).toContain('child-skill')
|
||||
expect(config.systemMessage.content).not.toContain('parent-skill')
|
||||
const message = { role: 'assistant' as const, content: 'done' }
|
||||
config.addedMessages?.push(message)
|
||||
return {
|
||||
addedMessages: [message],
|
||||
tokenUsage: { prompt: 0, completion: 0, total: 0 },
|
||||
hitMaxIterations: false
|
||||
}
|
||||
})
|
||||
|
||||
const manager = new AIChatManager()
|
||||
manager.isSessionChat = true
|
||||
manager.beforeSend = () => {
|
||||
mocks.workspace = 'child'
|
||||
}
|
||||
|
||||
await manager.sendRequest({ instructions: 'first', mode: AIMode.GLOBAL })
|
||||
resolveParentSkills?.([{ name: 'parent-skill', description: 'parent workspace skill' }])
|
||||
await Promise.resolve()
|
||||
|
||||
expect(mocks.listAiSkills).toHaveBeenCalledWith({ workspace: 'parent' })
|
||||
expect(mocks.listAiSkills).toHaveBeenCalledWith({ workspace: 'child' })
|
||||
expect(manager.systemMessage.content).toContain('child-skill')
|
||||
expect(manager.systemMessage.content).not.toContain('parent-skill')
|
||||
})
|
||||
})
|
||||
|
||||
describe('AIChatManager autonomy mode', () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear()
|
||||
@@ -916,12 +977,10 @@ describe('AIChatManager context compaction', () => {
|
||||
mocks.tryGetCurrentModel.mockReturnValue(gpt4oModel)
|
||||
// The user hits Stop while the summary request is in flight: it aborts the
|
||||
// turn's controller and rejects.
|
||||
mocks.getNonStreamingCompletion.mockImplementation(
|
||||
async (_msgs: any, ac: AbortController) => {
|
||||
ac.abort('user_cancelled')
|
||||
throw new Error('aborted')
|
||||
}
|
||||
)
|
||||
mocks.getNonStreamingCompletion.mockImplementation(async (_msgs: any, ac: AbortController) => {
|
||||
ac.abort('user_cancelled')
|
||||
throw new Error('aborted')
|
||||
})
|
||||
// With the controller already aborted, the real request returns nothing;
|
||||
// mirror that so the turn takes the cancel/rollback path.
|
||||
mocks.runChatLoop.mockImplementation(async () => ({
|
||||
|
||||
@@ -135,7 +135,8 @@
|
||||
await acceptPendingFlowEditsIfEnabled()
|
||||
},
|
||||
getFlowInputsSchema: async () => {
|
||||
return flowStore.val.schema ?? {}
|
||||
const s = flowStore.val.schema ?? {}
|
||||
return { type: 'object', properties: {}, required: [], ...s }
|
||||
},
|
||||
|
||||
updateExprsToSet: (id: string, inputTransforms: Record<string, InputTransform>) => {
|
||||
|
||||
@@ -14,7 +14,8 @@ import {
|
||||
ScriptService,
|
||||
SqsTriggerService,
|
||||
VariableService,
|
||||
WebsocketTriggerService
|
||||
WebsocketTriggerService,
|
||||
WorkspaceService
|
||||
} from '$lib/gen'
|
||||
import { $ScriptLang } from '$lib/gen/schemas.gen'
|
||||
import type {
|
||||
@@ -732,7 +733,8 @@ function buildFolderGuidance(username: string, ctx?: FolderPromptContext): strin
|
||||
const buildGlobalSystemPrompt = (
|
||||
username: string,
|
||||
previewTools: boolean,
|
||||
folderCtx?: FolderPromptContext
|
||||
folderCtx?: FolderPromptContext,
|
||||
skills: AiSkillListItem[] = []
|
||||
) => {
|
||||
const folderGuidance = buildFolderGuidance(username, folderCtx)
|
||||
const folderGuidanceBlock = folderGuidance ? `\n${folderGuidance}` : ''
|
||||
@@ -793,7 +795,16 @@ Data Tables:
|
||||
- Use list_datatables to discover the available datatables and their tables. Reuse an existing table rather than creating a duplicate. If list_datatables reports none, this is a blocking prerequisite — tell the user to set up a datatable in their workspace settings and stop; do not assume a "main" datatable exists or call exec_datatable_sql.
|
||||
- Use get_datatable_table_schema only when you need a table's column names/types; list_datatables is enough for table-list or availability summaries.
|
||||
- Use exec_datatable_sql to explore data, run queries, mutate rows, or change schema (CREATE/ALTER/DROP). Creating a table is a normal CREATE TABLE statement — it appears in list_datatables afterward, with no registration step.
|
||||
- When writing runnable code (inline app runnables, scripts, flow modules) that reads or writes datatable data at runtime, it accesses a datatable via wmill.datatable(). Default to TypeScript (bun) unless the user asked for another language. Call get_instructions with subject "datatable" and language "bun" for the TypeScript SQL SDK reference (or language "python3" for Python) — it returns only that language so you get just what you need.`
|
||||
- When writing runnable code (inline app runnables, scripts, flow modules) that reads or writes datatable data at runtime, it accesses a datatable via wmill.datatable(). Default to TypeScript (bun) unless the user asked for another language. Call get_instructions with subject "datatable" and language "bun" for the TypeScript SQL SDK reference (or language "python3" for Python) — it returns only that language so you get just what you need.${
|
||||
skills.length > 0
|
||||
? `
|
||||
|
||||
Skills:
|
||||
- Skills are reusable instruction sets curated for this workspace, each covering a specific kind of task. The available skills are listed below by name and description.
|
||||
- When a user's request matches a skill's description, call read_skill with its exact name to load the full instructions BEFORE acting, then follow them.
|
||||
${skills.map((s) => `- ${s.name}: ${s.description}`).join('\n')}`
|
||||
: ''
|
||||
}`
|
||||
}
|
||||
|
||||
const DEFAULT_LIST_TYPES = ['script', 'flow'] as const satisfies readonly WorkspaceItemType[]
|
||||
@@ -1545,7 +1556,51 @@ function getInstructions(subject: InstructionSubject, language?: ScriptLang): st
|
||||
}
|
||||
}
|
||||
|
||||
export type AiSkillListItem = { name: string; description: string }
|
||||
|
||||
/** Fetch the workspace's AI skills (name + description) for the global system prompt. */
|
||||
export async function loadWorkspaceSkills(workspace: string): Promise<AiSkillListItem[]> {
|
||||
if (!workspace) return []
|
||||
try {
|
||||
return await WorkspaceService.listAiSkills({ workspace })
|
||||
} catch (e) {
|
||||
console.error('Failed to load AI skills', e)
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
const readSkillSchema = z.object({
|
||||
name: z
|
||||
.string()
|
||||
.describe('The exact skill name as listed in the Skills section of the system prompt.')
|
||||
})
|
||||
|
||||
export const readSkillTool: Tool<{}> = {
|
||||
def: createToolDef(
|
||||
readSkillSchema,
|
||||
'read_skill',
|
||||
'Load the full instructions for a workspace AI skill by name. Skills are listed in the system prompt under "Skills"; call this before acting on a task a skill covers, then follow its instructions.'
|
||||
),
|
||||
fn: async ({ args, workspace, toolId, toolCallbacks }) => {
|
||||
const parsed = readSkillSchema.parse(args)
|
||||
toolCallbacks.setToolStatus(toolId, { content: `Reading skill "${parsed.name}"...` })
|
||||
try {
|
||||
const skill = await WorkspaceService.getAiSkill({ workspace, name: parsed.name })
|
||||
toolCallbacks.setToolStatus(toolId, { content: `Read skill "${parsed.name}"` })
|
||||
return `Skill: ${skill.name}\nDescription: ${skill.description}\n\nInstructions:\n${skill.instructions}`
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : String(e)
|
||||
toolCallbacks.setToolStatus(toolId, {
|
||||
content: `Error reading skill "${parsed.name}"`,
|
||||
error: msg
|
||||
})
|
||||
return `Failed to read skill "${parsed.name}": ${msg}. Check the name against the Skills list in the system prompt.`
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const globalTools: Tool<{}>[] = [
|
||||
readSkillTool,
|
||||
{
|
||||
def: createToolDef(
|
||||
getInstructionsSchema,
|
||||
@@ -4001,6 +4056,7 @@ export function prepareGlobalSystemMessage(
|
||||
// (read from userStore); callers that must not touch the process-global
|
||||
// store (the eval harness) pass it explicitly instead.
|
||||
user?: { username: string; is_admin?: boolean; folders?: string[]; folders_read?: string[] }
|
||||
skills?: AiSkillListItem[]
|
||||
}
|
||||
): ChatCompletionSystemMessageParam {
|
||||
const user = opts?.user ?? get(userStore)
|
||||
@@ -4012,7 +4068,12 @@ export function prepareGlobalSystemMessage(
|
||||
isAdmin: user.is_admin ?? false
|
||||
}
|
||||
: undefined
|
||||
let content = buildGlobalSystemPrompt(username, opts?.previewTools ?? false, folderCtx)
|
||||
let content = buildGlobalSystemPrompt(
|
||||
username,
|
||||
opts?.previewTools ?? false,
|
||||
folderCtx,
|
||||
opts?.skills ?? []
|
||||
)
|
||||
if (customPrompt?.trim()) {
|
||||
content = `${content}\n\nUSER GIVEN INSTRUCTIONS:\n${customPrompt.trim()}`
|
||||
}
|
||||
|
||||
@@ -936,7 +936,9 @@ export async function buildSchemaForTool(
|
||||
throw new Error(`Invalid flow inputs schema: ${invalidProperties.join(', ')}`)
|
||||
}
|
||||
|
||||
toolDef.function.parameters = { ...schema, additionalProperties: false }
|
||||
// Anthropic requires input_schema.type to be present; flows with no inputs
|
||||
// can produce a sparse schema (e.g. { order: [] }) lacking it.
|
||||
toolDef.function.parameters = { type: 'object', ...schema, additionalProperties: false }
|
||||
|
||||
// recursively normalize provider-incompatible schema fragments
|
||||
normalizeToolParameterSchema(toolDef.function.parameters)
|
||||
|
||||
@@ -15,6 +15,16 @@
|
||||
jobsById?: Record<string, JobById>
|
||||
editor: boolean
|
||||
workspace: string
|
||||
/**
|
||||
* Restrict waitJob/getJob/streamJob to job ids launched by this app
|
||||
* instance (WIN-2006): a SANDBOXED bundle must not read arbitrary
|
||||
* workspace jobs through the credentialed bridge. Off for unsandboxed
|
||||
* renders (the default, and editor preview) — there the bundle holds
|
||||
* the same credential as the bridge, so gating adds nothing and would
|
||||
* only break unsandboxed apps that poll persisted or runnable-returned
|
||||
* job ids.
|
||||
*/
|
||||
gateJobIds?: boolean
|
||||
}
|
||||
|
||||
let {
|
||||
@@ -24,10 +34,18 @@
|
||||
jobs = $bindable([]),
|
||||
jobsById = $bindable({}),
|
||||
editor,
|
||||
workspace
|
||||
workspace,
|
||||
gateJobIds = true
|
||||
}: Props = $props()
|
||||
|
||||
// Job ids launched by this app instance — see `gateJobIds`.
|
||||
const launchedJobs = new Set<string>()
|
||||
|
||||
let listener = async (event) => {
|
||||
// Only accept messages from the bundle iframe (opaque origin) so other
|
||||
// frames/extensions can't drive the runnable bridge (WIN-2006). Reject
|
||||
// unconditionally until the iframe is bound — never process a message from
|
||||
// an unknown source.
|
||||
if (!iframe || event.source !== iframe.contentWindow) return
|
||||
|
||||
const data = event.data
|
||||
@@ -115,6 +133,7 @@
|
||||
},
|
||||
undefined
|
||||
)
|
||||
launchedJobs.add(uuid)
|
||||
let job: JobById = { component: runnable_id, created_at: Date.now(), job: uuid }
|
||||
if (event.data.type == 'backendAsync') {
|
||||
let result = uuid
|
||||
@@ -134,14 +153,29 @@
|
||||
console.error('No runnable found for', runnable_id)
|
||||
}
|
||||
} else if (event.data.type == 'waitJob') {
|
||||
if (gateJobIds && !launchedJobs.has(data.jobId)) {
|
||||
respond({ result: { message: 'Unknown job' }, error: true })
|
||||
return
|
||||
}
|
||||
await respondWithResult(data.jobId)
|
||||
} else if (event.data.type == 'getJob') {
|
||||
if (gateJobIds && !launchedJobs.has(data.jobId)) {
|
||||
respond({ result: { message: 'Unknown job' }, error: true })
|
||||
return
|
||||
}
|
||||
const job = await JobService.getJob({ workspace, id: data.jobId })
|
||||
respond({ result: job })
|
||||
} else if (event.data.type == 'streamJob') {
|
||||
// Stream job results using SSE
|
||||
const jobId = data.jobId
|
||||
const reqId = data.reqId
|
||||
if (gateJobIds && !launchedJobs.has(jobId)) {
|
||||
iframe?.contentWindow?.postMessage(
|
||||
{ type: 'streamJobRes', reqId, error: true, result: { message: 'Unknown job' } },
|
||||
'*'
|
||||
)
|
||||
return
|
||||
}
|
||||
const params = new URLSearchParams()
|
||||
params.set('fast', 'true')
|
||||
params.set('only_result', 'true')
|
||||
|
||||
@@ -1496,6 +1496,7 @@
|
||||
bind:jobsById
|
||||
{runnables}
|
||||
{path}
|
||||
gateJobIds={false}
|
||||
/>
|
||||
<div class="max-h-screen overflow-hidden h-screen min-h-0 flex flex-col">
|
||||
<RawAppEditorHeader
|
||||
|
||||
@@ -473,14 +473,16 @@
|
||||
onDeploy?.({ path: npath })
|
||||
}
|
||||
|
||||
async function setPublishState() {
|
||||
async function setPublishState(message?: string) {
|
||||
await computeTriggerables()
|
||||
await AppService.updateApp({
|
||||
workspace: $workspaceStore!,
|
||||
path: appPath,
|
||||
requestBody: { policy }
|
||||
})
|
||||
if (policy.execution_mode == 'anonymous') {
|
||||
if (message) {
|
||||
sendUserToast(message)
|
||||
} else if (policy.execution_mode == 'anonymous') {
|
||||
sendUserToast('App require no login to be accessed')
|
||||
} else {
|
||||
sendUserToast('App require login and read-access')
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
import { type UserExt } from '$lib/stores'
|
||||
import RawAppBackgroundRunner from './RawAppBackgroundRunner.svelte'
|
||||
import type { Runnable } from './rawAppPolicy'
|
||||
import { htmlContent } from './utils'
|
||||
import { onMount, untrack } from 'svelte'
|
||||
import { getContext, onMount, untrack } from 'svelte'
|
||||
import { unsandboxedRawAppHtml } from './utils'
|
||||
|
||||
interface Props {
|
||||
workspace: string
|
||||
@@ -17,43 +17,190 @@
|
||||
|
||||
let iframe = $state() as HTMLIFrameElement | undefined
|
||||
|
||||
// Get initial hash from parent URL to pass to iframe
|
||||
let initialHash = $state('')
|
||||
// Get initial hash from parent URL to pass to the iframe
|
||||
let initialHash = ''
|
||||
|
||||
onMount(() => {
|
||||
initialHash = window.location.hash || ''
|
||||
// WIN-2006: unless the publisher opted into sandbox isolation, run the bundle
|
||||
// same-origin with full access (the default); otherwise the opaque-origin sandbox.
|
||||
const unsandboxedCtx = getContext<{ value: boolean }>('IS_APP_UNSANDBOXED')
|
||||
let unsandboxed = $derived(unsandboxedCtx?.value ?? false)
|
||||
// Unsandboxed (the default) must match the pre-isolation viewer exactly: NO
|
||||
// sandbox attribute (a same-origin blob with full session — an attribute would
|
||||
// only break leftover features like unsandboxed popups for OAuth flows, while
|
||||
// adding no isolation). The sandboxed path keeps the restrictive attribute; the
|
||||
// wrapper document's `CSP: sandbox` response header enforces the opaque origin
|
||||
// regardless.
|
||||
let sandboxAttr = $derived(
|
||||
unsandboxed
|
||||
? undefined
|
||||
: 'allow-scripts allow-forms allow-popups allow-popups-to-escape-sandbox allow-downloads allow-modals allow-top-navigation'
|
||||
)
|
||||
|
||||
// WIN-2006: source of the bundle iframe.
|
||||
// - DEFAULT (isolated): a real API URL serving a sandboxed, opaque-origin
|
||||
// document (`CSP: sandbox` response header + the iframe sandbox attribute),
|
||||
// so a malicious bundle can never reach the authenticated Windmill origin
|
||||
// (no cookie, no window.parent, no token). Root-relative so it resolves
|
||||
// against the real host even when this component itself runs inside an opaque
|
||||
// viewer (where `location.origin` is "null"). Context is handed over via
|
||||
// postMessage — never baked into the document, never a credential.
|
||||
// - UNSANDBOXED (the default — publisher did not opt into isolation): a
|
||||
// client-built blob: wrapper (same-origin with the SPA) loaded with `allow-same-origin`,
|
||||
// so relative `fetch('/api/...')` and the session cookie work. The backend
|
||||
// `.html` is ALWAYS sandboxed, so we must build the same-origin wrapper here
|
||||
// rather than relax a real-origin endpoint a victim could be linked to.
|
||||
let iframeSrc = $derived.by(() => {
|
||||
if (!secret || typeof window === 'undefined') return undefined
|
||||
if (unsandboxed) {
|
||||
// untrack(user) so userStore refreshes don't regenerate the blob URL and
|
||||
// reload the iframe (losing state); ctx is only needed for initial render.
|
||||
// Always pass the wrapper object — pre-sandbox bundles rely on
|
||||
// `window.ctx.workspace` even for anonymous viewers (ctx.ctx undefined).
|
||||
const u = untrack(() => user)
|
||||
const html = unsandboxedRawAppHtml(
|
||||
workspace,
|
||||
secret,
|
||||
{ ctx: u, workspace },
|
||||
window.location.origin,
|
||||
window.location.hash || ''
|
||||
)
|
||||
return URL.createObjectURL(new Blob([html], { type: 'text/html' }))
|
||||
}
|
||||
// `wm_coep` (embed-in-cross-origin-isolated-page opt-in) must be propagated
|
||||
// to the wrapper document: under a COEP `require-corp` embedder, a nested
|
||||
// document is only allowed to load if it asserts COEP itself, so the
|
||||
// backend adds the header when the flag is present.
|
||||
const coep = new URLSearchParams(window.location.search).has('wm_coep') ? '?wm_coep=1' : ''
|
||||
return `/api/w/${workspace}/apps_u/get_data/v/${secret}.html${coep}`
|
||||
})
|
||||
|
||||
// Use blob URL instead of srcDoc to give the iframe a proper origin.
|
||||
// srcDoc iframes have "null" origin which breaks URL constructor in routers.
|
||||
// untrack(user) so that userStore refreshes don't regenerate the blob URL
|
||||
// and cause the iframe to fully reload (losing all state).
|
||||
// The user context is only needed for initial render.
|
||||
let blobUrl = $derived.by(() => {
|
||||
if (!secret) return undefined
|
||||
const u = untrack(() => user)
|
||||
const baseUrl = typeof window !== 'undefined' ? window.location.origin : ''
|
||||
const html = htmlContent(workspace, secret, { ctx: u, workspace }, baseUrl, initialHash)
|
||||
const blob = new Blob([html], { type: 'text/html' })
|
||||
return URL.createObjectURL(blob)
|
||||
})
|
||||
|
||||
// Cleanup blob URL when it changes or component unmounts
|
||||
// Revoke blob: URLs (unsandboxed path) when they change or on unmount.
|
||||
$effect(() => {
|
||||
const url = blobUrl
|
||||
const url = iframeSrc
|
||||
return () => {
|
||||
if (url) URL.revokeObjectURL(url)
|
||||
if (url && url.startsWith('blob:')) URL.revokeObjectURL(url)
|
||||
}
|
||||
})
|
||||
|
||||
// Persistence for the bundle's (opaque-origin) localStorage, backed by a store
|
||||
// scoped PER APP (keyed by workspace + app path) so one sandboxed app can't read
|
||||
// or clobber another's (even two apps at the same path in different workspaces). On a real origin (workspace viewer, public page — even when
|
||||
// that page sits inside someone else's iframe) it reads/writes real localStorage
|
||||
// directly. Only inside an opaque frame (the Windmill embed viewer), where Web
|
||||
// Storage throws, does it relay per-key ops up to the embedder, the persistence
|
||||
// authority. `framed` therefore probes storage rather than just `window.parent`:
|
||||
// an externally-embedded public page is framed too, but its parent is not the
|
||||
// Windmill embedder and would never answer the relay (leaving the bundle without
|
||||
// ctx). The snapshot is handed to the bundle before it evaluates so its
|
||||
// localStorage is hydrated synchronously.
|
||||
const SHARED_LS_KEY = `wm_apps_localstorage:${workspace}:${path}`
|
||||
function storageAccessible(): boolean {
|
||||
try {
|
||||
localStorage.getItem(SHARED_LS_KEY)
|
||||
return true
|
||||
} catch (_) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
const framed = typeof window !== 'undefined' && window.parent !== window && !storageAccessible()
|
||||
let bundleStorage: Record<string, string> | undefined = undefined
|
||||
let pendingReady = false
|
||||
|
||||
function readDirect(): Record<string, string> {
|
||||
try {
|
||||
return JSON.parse(localStorage.getItem(SHARED_LS_KEY) || '{}')
|
||||
} catch (_) {
|
||||
return {}
|
||||
}
|
||||
}
|
||||
|
||||
function applyDirectOp(d: any) {
|
||||
try {
|
||||
const s = readDirect()
|
||||
if (d.op === 'set') s[d.key] = String(d.value)
|
||||
else if (d.op === 'remove') delete s[d.key]
|
||||
else if (d.op === 'clear') for (const k in s) delete s[k]
|
||||
localStorage.setItem(SHARED_LS_KEY, JSON.stringify(s))
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
function respondCtx() {
|
||||
iframe?.contentWindow?.postMessage(
|
||||
{
|
||||
type: 'windmill:ctx',
|
||||
// Same shape as the unsandboxed wrapper: always the object, so
|
||||
// `window.ctx.workspace` works for anonymous viewers too.
|
||||
ctx: { ctx: user, workspace },
|
||||
initialHash,
|
||||
storage: { local: bundleStorage ?? {}, session: {} }
|
||||
},
|
||||
'*'
|
||||
)
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
initialHash = window.location.hash || ''
|
||||
if (framed) {
|
||||
// Pre-fetch the shared store from the embedder.
|
||||
try {
|
||||
window.parent.postMessage({ type: 'wm_ls_req' }, '*')
|
||||
} catch (_) {}
|
||||
// If the parent never answers (it isn't the Windmill embedder, e.g. an
|
||||
// opaque context created by a third party), don't hold the bundle's ctx
|
||||
// hostage: proceed with empty storage. Must beat the backend wrapper's
|
||||
// own 1.5s no-ctx fallback.
|
||||
const fallback = setTimeout(() => {
|
||||
if (bundleStorage === undefined) {
|
||||
bundleStorage = {}
|
||||
if (pendingReady) {
|
||||
pendingReady = false
|
||||
respondCtx()
|
||||
}
|
||||
}
|
||||
}, 750)
|
||||
return () => clearTimeout(fallback)
|
||||
}
|
||||
})
|
||||
|
||||
// Listen for hash changes from iframe and update parent URL
|
||||
$effect(() => {
|
||||
function handleMessage(event: MessageEvent) {
|
||||
console.log('[Parent] Received message:', event.data)
|
||||
if (event.data?.type === 'windmill:hashchange') {
|
||||
const newHash = event.data.hash || ''
|
||||
console.log('[Parent] Updating hash to:', newHash)
|
||||
// Update parent URL without triggering navigation
|
||||
const data = event.data
|
||||
// Shared-store hydration from the embedder (public mode only).
|
||||
if (framed && event.source === window.parent && data?.type === 'wm_ls_hydrate') {
|
||||
bundleStorage = data.data || {}
|
||||
if (pendingReady) {
|
||||
pendingReady = false
|
||||
respondCtx()
|
||||
}
|
||||
return
|
||||
}
|
||||
// Everything else must come from the bundle iframe.
|
||||
if (event.source !== iframe?.contentWindow) return
|
||||
if (data?.type === 'windmill:ready') {
|
||||
// Hand the bundle its context + shared storage before it evaluates.
|
||||
if (!framed) {
|
||||
bundleStorage = readDirect()
|
||||
respondCtx()
|
||||
} else if (bundleStorage !== undefined) {
|
||||
respondCtx()
|
||||
} else {
|
||||
pendingReady = true
|
||||
}
|
||||
} else if (data?.type === 'wm_ls_op') {
|
||||
// The bundle mutated localStorage — apply it to the shared store.
|
||||
if (!framed) {
|
||||
applyDirectOp(data)
|
||||
} else {
|
||||
try {
|
||||
window.parent.postMessage(
|
||||
{ type: 'wm_ls_op', op: data.op, key: data.key, value: data.value },
|
||||
'*'
|
||||
)
|
||||
} catch (_) {}
|
||||
}
|
||||
} else if (data?.type === 'windmill:hashchange') {
|
||||
// Keep the parent URL hash in sync for shareable URLs.
|
||||
const newHash = data.hash || ''
|
||||
if (window.location.hash !== newHash) {
|
||||
history.replaceState(null, '', newHash || window.location.pathname)
|
||||
}
|
||||
@@ -65,13 +212,29 @@
|
||||
})
|
||||
</script>
|
||||
|
||||
<RawAppBackgroundRunner {workspace} editor={false} {iframe} {runnables} {path} />
|
||||
<RawAppBackgroundRunner
|
||||
{workspace}
|
||||
editor={false}
|
||||
{iframe}
|
||||
{runnables}
|
||||
{path}
|
||||
gateJobIds={!unsandboxed}
|
||||
/>
|
||||
|
||||
{#if blobUrl}
|
||||
{#if iframeSrc}
|
||||
<!-- `unsandboxed` (the default — publisher did not opt into isolation) adds
|
||||
allow-same-origin and loads a same-origin blob: wrapper, so the bundle runs
|
||||
with full access. The sandboxed path loads the always-CSP-sandboxed backend
|
||||
wrapper, which stays opaque even on direct navigation. -->
|
||||
<!-- referrerpolicy (sandboxed only, for exact legacy parity): the hosting page
|
||||
URL can carry a viewer credential (the JWT path segment of share links);
|
||||
without this, the bundle document would see it via document.referrer. -->
|
||||
<iframe
|
||||
bind:this={iframe}
|
||||
title="raw-app"
|
||||
src={blobUrl}
|
||||
src={iframeSrc}
|
||||
sandbox={sandboxAttr}
|
||||
referrerpolicy={unsandboxed ? undefined : 'no-referrer'}
|
||||
class="w-full h-full min-h-screen bg-white border-none"
|
||||
></iframe>
|
||||
{/if}
|
||||
|
||||
@@ -19,10 +19,11 @@ export async function updateRawAppPolicy(
|
||||
)
|
||||
).filter((entry): entry is [string, TriggerableV2] => entry != null)
|
||||
const triggerables_v2 = Object.fromEntries(entries)
|
||||
return {
|
||||
const next: Policy = {
|
||||
...currentPolicy,
|
||||
triggerables_v2
|
||||
}
|
||||
return next
|
||||
}
|
||||
|
||||
type RunnableWithInlineScript = RunnableWithFields & {
|
||||
|
||||
@@ -141,65 +141,50 @@ export function formatAppRunsForChat(runs: RawAppRunSummary[]): string {
|
||||
return JSON.stringify(runs, null, 2)
|
||||
}
|
||||
|
||||
export function htmlContent(
|
||||
// The sandboxed (isolated) raw-app wrapper is generated server-side and served as
|
||||
// a sandboxed, opaque-origin document (see `get_raw_app_data` in the backend
|
||||
// `apps.rs`, WIN-2006) — a blob: URL cannot carry the `CSP: sandbox` response
|
||||
// header that enforces isolation, so the wrapper must come from the backend.
|
||||
//
|
||||
// The function below is used ONLY for the unsandboxed path (the default — the
|
||||
// publisher did not opt into sandbox isolation). It is loaded as a blob: URL —
|
||||
// same-origin with the SPA — so, with `allow-same-origin`, the bundle runs with
|
||||
// the viewer's full session. Crucially this is an in-memory blob, not a
|
||||
// real-origin endpoint, so it is not a URL an attacker can navigate a logged-in
|
||||
// victim to in order to gain isolation-bypassing access — the backend `.html`
|
||||
// document stays sandboxed whenever the publisher did opt in.
|
||||
export function unsandboxedRawAppHtml(
|
||||
workspace: string,
|
||||
secret: string | undefined,
|
||||
secret: string,
|
||||
ctx: any,
|
||||
baseUrl: string = '',
|
||||
initialHash: string = ''
|
||||
baseUrl: string,
|
||||
initialHash: string
|
||||
) {
|
||||
return `<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<title>App Preview</title>
|
||||
<title>App</title>
|
||||
<link rel="stylesheet" href="${baseUrl}/api/w/${workspace}/apps_u/get_data/v/${secret}.css" />
|
||||
<script>
|
||||
window.ctx = ${ctx ? JSON.stringify(ctx) : 'undefined'};
|
||||
|
||||
// Sync hash with parent window for shareable URLs
|
||||
(function() {
|
||||
// Set initial hash from parent URL
|
||||
(function () {
|
||||
// Keep the parent URL hash in sync for shareable URLs.
|
||||
function notifyParent() {
|
||||
try {
|
||||
if (window.parent !== window) {
|
||||
window.parent.postMessage({ type: 'windmill:hashchange', hash: window.location.hash }, '*');
|
||||
}
|
||||
} catch (_) {}
|
||||
}
|
||||
var initialHash = ${JSON.stringify(initialHash)};
|
||||
if (initialHash && initialHash !== '#' && !window.location.hash) {
|
||||
history.replaceState(null, '', initialHash);
|
||||
try { history.replaceState(null, '', initialHash); } catch (_) {}
|
||||
}
|
||||
|
||||
// Notify parent when hash changes
|
||||
function notifyParent() {
|
||||
var hash = window.location.hash;
|
||||
console.log('[HashSync] notifyParent called, hash:', hash);
|
||||
if (window.parent !== window) {
|
||||
window.parent.postMessage({
|
||||
type: 'windmill:hashchange',
|
||||
hash: hash
|
||||
}, '*');
|
||||
}
|
||||
}
|
||||
|
||||
// Listen for hash changes
|
||||
window.addEventListener('hashchange', function() {
|
||||
console.log('[HashSync] hashchange event');
|
||||
notifyParent();
|
||||
});
|
||||
|
||||
// Also notify on pushState/replaceState
|
||||
var originalPushState = history.pushState;
|
||||
var originalReplaceState = history.replaceState;
|
||||
|
||||
history.pushState = function() {
|
||||
console.log('[HashSync] pushState called with:', arguments[2]);
|
||||
originalPushState.apply(this, arguments);
|
||||
notifyParent();
|
||||
};
|
||||
|
||||
history.replaceState = function() {
|
||||
console.log('[HashSync] replaceState called with:', arguments[2]);
|
||||
originalReplaceState.apply(this, arguments);
|
||||
notifyParent();
|
||||
};
|
||||
|
||||
// Notify parent of initial hash after load
|
||||
window.addEventListener('hashchange', notifyParent);
|
||||
var _ps = history.pushState, _rs = history.replaceState;
|
||||
history.pushState = function () { _ps.apply(this, arguments); notifyParent(); };
|
||||
history.replaceState = function () { _rs.apply(this, arguments); notifyParent(); };
|
||||
setTimeout(notifyParent, 0);
|
||||
})();
|
||||
</script>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { run } from 'svelte/legacy'
|
||||
import { onDestroy } from 'svelte'
|
||||
|
||||
import { enterpriseLicense, userStore, workspaceStore, awarenessStore } from '$lib/stores'
|
||||
|
||||
@@ -23,7 +24,17 @@
|
||||
url: $page.url.pathname
|
||||
})
|
||||
}
|
||||
function disconnectWorkspace() {
|
||||
if (wsProvider) {
|
||||
wsProvider.destroy()
|
||||
wsProvider = undefined
|
||||
}
|
||||
connected = false
|
||||
awareness = undefined
|
||||
}
|
||||
async function connectWorkspace(workspace: string) {
|
||||
disconnectWorkspace()
|
||||
|
||||
let token: string | undefined
|
||||
try {
|
||||
token = await signMultiplayerRequest(workspace)
|
||||
@@ -73,6 +84,10 @@
|
||||
$enterpriseLicense && $workspaceStore && connectWorkspace($workspaceStore)
|
||||
})
|
||||
|
||||
onDestroy(() => {
|
||||
disconnectWorkspace()
|
||||
})
|
||||
|
||||
let peers = $derived(
|
||||
Object.entries($awarenessStore ?? {}).filter(
|
||||
([user]) => user && user !== 'undefined' && user !== 'null'
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
import { supportsAutocomplete } from '../copilot/utils'
|
||||
import TestAiKey from '../copilot/TestAIKey.svelte'
|
||||
import Label from '../Label.svelte'
|
||||
import AiSkillsSettings from './AiSkillsSettings.svelte'
|
||||
import SettingsPageHeader from '../settings/SettingsPageHeader.svelte'
|
||||
import ResourcePicker from '../ResourcePicker.svelte'
|
||||
import Toggle from '../Toggle.svelte'
|
||||
@@ -587,6 +588,10 @@
|
||||
</div>
|
||||
</SettingCard>
|
||||
{/if}
|
||||
|
||||
{#if promptScope === 'workspace'}
|
||||
<AiSkillsSettings />
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<AIPromptsModal
|
||||
|
||||
@@ -0,0 +1,396 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte'
|
||||
import YAML from 'yaml'
|
||||
import Button from '../common/button/Button.svelte'
|
||||
import ConfirmationModal from '../common/confirmationModal/ConfirmationModal.svelte'
|
||||
import SettingCard from '../instanceSettings/SettingCard.svelte'
|
||||
import Label from '../Label.svelte'
|
||||
import autosize from '$lib/autosize'
|
||||
import { workspaceStore } from '$lib/stores'
|
||||
import { sendUserToast } from '$lib/toast'
|
||||
import { WorkspaceService } from '$lib/gen'
|
||||
import { FolderUp, Plus, Trash2 } from 'lucide-svelte'
|
||||
|
||||
type SkillListItem = { name: string; description: string }
|
||||
type SkillUpload = { name: string; description: string; instructions: string }
|
||||
|
||||
// `<root>/<skill>/SKILL.md` is 3 path segments; SKILL.md files nested deeper
|
||||
// are likely vendored/incidental and are skipped so importing a parent dir
|
||||
// doesn't sweep in unrelated skills.
|
||||
const MAX_SKILL_DEPTH = 3
|
||||
const MAX_SKILLS_PER_IMPORT = 50
|
||||
const MAX_SKILLS_PER_WORKSPACE = 100
|
||||
// `name` + `description` mirror the Claude SKILL.md spec (counted in
|
||||
// characters); the body is a byte-bounded payload. Keep these in sync with
|
||||
// backend `validate_skill`.
|
||||
const MAX_SKILL_NAME_LENGTH = 64
|
||||
const MAX_SKILL_DESCRIPTION_LENGTH = 1_024
|
||||
const MAX_SKILL_INSTRUCTIONS_LENGTH = 64 * 1024
|
||||
const SKILL_NAME_PATTERN = /^[a-z0-9-]+$/
|
||||
const textEncoder = new TextEncoder()
|
||||
const SAMPLE_SKILL_PLACEHOLDER =
|
||||
'---\nname: my-skill\ndescription: what this skill helps with\n---\n\n# My skill\n\nInstructions for the assistant…'
|
||||
|
||||
let skills: SkillListItem[] = $state([])
|
||||
let uploading: boolean = $state(false)
|
||||
let pasteContent: string = $state('')
|
||||
let dirInput: HTMLInputElement | undefined = $state(undefined)
|
||||
let toDelete: string | undefined = $state(undefined)
|
||||
let pendingImport: SkillUpload[] | undefined = $state(undefined)
|
||||
let pendingSkipped: string[] = $state([])
|
||||
let listRequestId = 0
|
||||
|
||||
let pendingNamesPreview = $derived.by(() => {
|
||||
const p = pendingImport ?? []
|
||||
const shown = p
|
||||
.slice(0, 12)
|
||||
.map((s) => s.name)
|
||||
.join(', ')
|
||||
return p.length > 12 ? `${shown}, … (+${p.length - 12} more)` : shown
|
||||
})
|
||||
|
||||
async function loadList(workspace: string | undefined) {
|
||||
const requestId = ++listRequestId
|
||||
if (!workspace) {
|
||||
skills = []
|
||||
return
|
||||
}
|
||||
try {
|
||||
const loaded = await WorkspaceService.listAiSkills({ workspace })
|
||||
if (requestId === listRequestId && workspace === $workspaceStore) {
|
||||
skills = loaded
|
||||
}
|
||||
} catch (e) {
|
||||
if (requestId === listRequestId && workspace === $workspaceStore) {
|
||||
sendUserToast(`Failed to load skills: ${e}`, true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Split a SKILL.md into its frontmatter `name`/`description` and the markdown body. */
|
||||
function parseSkillMd(raw: string): {
|
||||
name: string | undefined
|
||||
description: string | undefined
|
||||
instructions: string
|
||||
} {
|
||||
const text = raw.replace(/^/, '')
|
||||
const fm = /^---\s*\r?\n([\s\S]*?)\r?\n---\s*\r?\n?/.exec(text)
|
||||
if (!fm) {
|
||||
return { name: undefined, description: undefined, instructions: text.trim() }
|
||||
}
|
||||
let name: string | undefined
|
||||
let description: string | undefined
|
||||
try {
|
||||
const data = YAML.parse(fm[1]) ?? {}
|
||||
if (typeof data?.name === 'string') name = data.name.trim()
|
||||
if (typeof data?.description === 'string') description = data.description.trim()
|
||||
} catch {
|
||||
// Malformed frontmatter — fall through so the skill is reported as
|
||||
// invalid rather than silently dropped.
|
||||
}
|
||||
return { name, description, instructions: text.slice(fm[0].length).trim() }
|
||||
}
|
||||
|
||||
function validateParsedSkill(skill: SkillUpload): string | undefined {
|
||||
if ([...skill.name].length > MAX_SKILL_NAME_LENGTH) {
|
||||
return `name is longer than ${MAX_SKILL_NAME_LENGTH} characters`
|
||||
}
|
||||
if (!SKILL_NAME_PATTERN.test(skill.name)) {
|
||||
return `name ${JSON.stringify(skill.name)} must only contain lowercase letters, digits or '-'`
|
||||
}
|
||||
if ([...skill.description].length > MAX_SKILL_DESCRIPTION_LENGTH) {
|
||||
return `description is longer than ${MAX_SKILL_DESCRIPTION_LENGTH} characters`
|
||||
}
|
||||
if (textEncoder.encode(skill.instructions).byteLength > MAX_SKILL_INSTRUCTIONS_LENGTH) {
|
||||
return `body is longer than ${MAX_SKILL_INSTRUCTIONS_LENGTH} bytes`
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Turn a map of `relativePath -> content` (from an imported folder) into skills.
|
||||
* A skill is any `SKILL.md`; its id is the name of the folder holding it.
|
||||
*/
|
||||
function collectSkills(files: Record<string, string>): {
|
||||
skills: SkillUpload[]
|
||||
skipped: string[]
|
||||
} {
|
||||
const collected: SkillUpload[] = []
|
||||
const skipped: string[] = []
|
||||
for (const [path, content] of Object.entries(files)) {
|
||||
const segments = path.split('/')
|
||||
if (segments[segments.length - 1]?.toLowerCase() !== 'skill.md') continue
|
||||
const name = segments.length >= 2 ? segments[segments.length - 2] : ''
|
||||
const { description, instructions } = parseSkillMd(content)
|
||||
if (!name) {
|
||||
skipped.push(`${path} (SKILL.md must live in a named folder)`)
|
||||
} else if (!description) {
|
||||
skipped.push(`${name} (missing frontmatter description)`)
|
||||
} else if (!instructions) {
|
||||
skipped.push(`${name} (empty body)`)
|
||||
} else {
|
||||
const parsed = { name, description, instructions }
|
||||
const validationError = validateParsedSkill(parsed)
|
||||
if (validationError) {
|
||||
skipped.push(`${name} (${validationError})`)
|
||||
} else {
|
||||
collected.push(parsed)
|
||||
}
|
||||
}
|
||||
}
|
||||
return { skills: collected, skipped }
|
||||
}
|
||||
|
||||
async function uploadSkills(parsed: SkillUpload[], skipped: string[] = []) {
|
||||
const workspace = $workspaceStore
|
||||
if (!workspace || parsed.length === 0) {
|
||||
sendUserToast(
|
||||
`No valid skill found.${skipped.length ? ` Skipped: ${skipped.join(', ')}` : ''}`,
|
||||
true
|
||||
)
|
||||
return false
|
||||
}
|
||||
if (parsed.length > MAX_SKILLS_PER_IMPORT) {
|
||||
sendUserToast(`Cannot add more than ${MAX_SKILLS_PER_IMPORT} skills at a time.`, true)
|
||||
return false
|
||||
}
|
||||
// Uploads upsert, so only names not already stored count toward the cap.
|
||||
const existingNames = new Set(skills.map((s) => s.name))
|
||||
const newCount = parsed.filter((s) => !existingNames.has(s.name)).length
|
||||
if (skills.length + newCount > MAX_SKILLS_PER_WORKSPACE) {
|
||||
sendUserToast(`This workspace can store at most ${MAX_SKILLS_PER_WORKSPACE} skills.`, true)
|
||||
return false
|
||||
}
|
||||
uploading = true
|
||||
try {
|
||||
await WorkspaceService.uploadAiSkills({
|
||||
workspace,
|
||||
requestBody: { skills: parsed }
|
||||
})
|
||||
let message = `Added ${parsed.length} skill(s)`
|
||||
if (skipped.length) message += `; skipped ${skipped.length}: ${skipped.join(', ')}`
|
||||
sendUserToast(message)
|
||||
await loadList(workspace)
|
||||
return true
|
||||
} catch (e) {
|
||||
sendUserToast(`Failed to add skills: ${e}`, true)
|
||||
return false
|
||||
} finally {
|
||||
uploading = false
|
||||
}
|
||||
}
|
||||
|
||||
async function addPastedSkill() {
|
||||
const { name, description, instructions } = parseSkillMd(pasteContent)
|
||||
if (!name) {
|
||||
sendUserToast('The pasted SKILL.md needs a `name` in its frontmatter.', true)
|
||||
return
|
||||
}
|
||||
if (!description) {
|
||||
sendUserToast('The pasted SKILL.md needs a `description` in its frontmatter.', true)
|
||||
return
|
||||
}
|
||||
if (!instructions) {
|
||||
sendUserToast('The pasted SKILL.md has an empty body.', true)
|
||||
return
|
||||
}
|
||||
const parsed = { name, description, instructions }
|
||||
const validationError = validateParsedSkill(parsed)
|
||||
if (validationError) {
|
||||
sendUserToast(`The pasted SKILL.md ${validationError}.`, true)
|
||||
return
|
||||
}
|
||||
if (await uploadSkills([parsed])) {
|
||||
pasteContent = ''
|
||||
}
|
||||
}
|
||||
|
||||
async function onDirSelected(event: Event) {
|
||||
const target = event.target as HTMLInputElement
|
||||
const files = Array.from(target.files ?? [])
|
||||
// Reset early so re-selecting the same folder re-fires `change`.
|
||||
if (dirInput) dirInput.value = ''
|
||||
|
||||
// Pick SKILL.md files within the depth limit BEFORE reading any content,
|
||||
// so a huge tree never gets read in full.
|
||||
const skipped: string[] = []
|
||||
const eligible: File[] = []
|
||||
for (const f of files) {
|
||||
const path = f.webkitRelativePath || f.name
|
||||
const segments = path.split('/')
|
||||
if (segments[segments.length - 1]?.toLowerCase() !== 'skill.md') continue
|
||||
if (segments.length > MAX_SKILL_DEPTH) {
|
||||
skipped.push(`${path} (nested deeper than ${MAX_SKILL_DEPTH} folder levels)`)
|
||||
continue
|
||||
}
|
||||
eligible.push(f)
|
||||
}
|
||||
|
||||
if (eligible.length === 0) {
|
||||
sendUserToast(
|
||||
`No SKILL.md found within ${MAX_SKILL_DEPTH} folder levels.${
|
||||
skipped.length ? ` Skipped ${skipped.length} deeper file(s).` : ''
|
||||
}`,
|
||||
true
|
||||
)
|
||||
return
|
||||
}
|
||||
if (eligible.length > MAX_SKILLS_PER_IMPORT) {
|
||||
sendUserToast(
|
||||
`Found ${eligible.length} skills in this folder; imports are limited to ${MAX_SKILLS_PER_IMPORT} at a time.`,
|
||||
true
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
const map: Record<string, string> = {}
|
||||
for (const f of eligible) {
|
||||
map[f.webkitRelativePath || f.name] = await f.text()
|
||||
}
|
||||
const { skills: parsed, skipped: parseSkipped } = collectSkills(map)
|
||||
const allSkipped = [...skipped, ...parseSkipped]
|
||||
if (parsed.length === 0) {
|
||||
sendUserToast(
|
||||
`No valid skill found.${allSkipped.length ? ` Skipped: ${allSkipped.join(', ')}` : ''}`,
|
||||
true
|
||||
)
|
||||
return
|
||||
}
|
||||
// Confirm before writing — the import can pull in several skills at once.
|
||||
pendingSkipped = allSkipped
|
||||
pendingImport = parsed
|
||||
}
|
||||
|
||||
async function deleteSkill(name: string) {
|
||||
const workspace = $workspaceStore
|
||||
if (!workspace) return
|
||||
try {
|
||||
await WorkspaceService.deleteAiSkill({ workspace, name })
|
||||
sendUserToast(`Deleted skill ${name}`)
|
||||
await loadList(workspace)
|
||||
} catch (e) {
|
||||
sendUserToast(`Failed to delete skill: ${e}`, true)
|
||||
}
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
return workspaceStore.subscribe((workspace) => {
|
||||
toDelete = undefined
|
||||
pendingImport = undefined
|
||||
pendingSkipped = []
|
||||
void loadList(workspace)
|
||||
})
|
||||
})
|
||||
</script>
|
||||
|
||||
<SettingCard
|
||||
label="Custom skills"
|
||||
description="Add your own skills to the AI Chat. The expected format is the same as Claude or Codex."
|
||||
>
|
||||
<div class="flex flex-col gap-3 pt-1">
|
||||
<Label label="Paste a SKILL.md file">
|
||||
<textarea
|
||||
bind:value={pasteContent}
|
||||
placeholder={SAMPLE_SKILL_PLACEHOLDER}
|
||||
class="w-full min-h-24 p-2 border border-gray-200 dark:border-gray-700 rounded-md bg-surface text-primary font-mono text-xs resize-y"
|
||||
rows="5"
|
||||
use:autosize
|
||||
></textarea>
|
||||
<div class="flex justify-end mt-2">
|
||||
<Button
|
||||
onclick={addPastedSkill}
|
||||
variant="default"
|
||||
unifiedSize="sm"
|
||||
startIcon={{ icon: Plus }}
|
||||
disabled={!pasteContent.trim() || uploading}
|
||||
>
|
||||
Add skill
|
||||
</Button>
|
||||
</div>
|
||||
</Label>
|
||||
|
||||
<Label label="Import a folder of skills">
|
||||
<div class="flex mt-1">
|
||||
<Button
|
||||
onclick={() => dirInput?.click()}
|
||||
variant="default"
|
||||
unifiedSize="sm"
|
||||
startIcon={{ icon: FolderUp }}
|
||||
disabled={uploading}
|
||||
>
|
||||
{uploading ? 'Importing…' : 'Import folder'}
|
||||
</Button>
|
||||
</div>
|
||||
<input
|
||||
bind:this={dirInput}
|
||||
type="file"
|
||||
style="display: none;"
|
||||
onchange={onDirSelected}
|
||||
{...{ webkitdirectory: true, directory: true }}
|
||||
/>
|
||||
</Label>
|
||||
|
||||
{#if skills.length > 0}
|
||||
<div class="rounded-md border divide-y">
|
||||
{#each skills as skill (skill.name)}
|
||||
<div class="flex items-center justify-between gap-4 px-3 py-2">
|
||||
<div class="min-w-0">
|
||||
<div class="text-xs font-semibold font-mono truncate">{skill.name}</div>
|
||||
<div class="text-2xs text-secondary truncate">{skill.description}</div>
|
||||
</div>
|
||||
<Button
|
||||
onclick={() => (toDelete = skill.name)}
|
||||
variant="default"
|
||||
color="red"
|
||||
unifiedSize="sm"
|
||||
startIcon={{ icon: Trash2 }}
|
||||
iconOnly
|
||||
/>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</SettingCard>
|
||||
|
||||
<ConfirmationModal
|
||||
open={pendingImport !== undefined}
|
||||
title="Import skills"
|
||||
confirmationText="Import"
|
||||
onConfirmed={async () => {
|
||||
const toImport = pendingImport
|
||||
const skipped = pendingSkipped
|
||||
pendingImport = undefined
|
||||
pendingSkipped = []
|
||||
if (toImport) await uploadSkills(toImport, skipped)
|
||||
}}
|
||||
onCanceled={() => {
|
||||
pendingImport = undefined
|
||||
pendingSkipped = []
|
||||
}}
|
||||
>
|
||||
<span>
|
||||
Add {pendingImport?.length} skill(s) to the AI chat?
|
||||
<span class="font-mono text-xs">{pendingNamesPreview}</span>
|
||||
{#if pendingSkipped.length}
|
||||
<br /><span class="text-xs text-secondary"
|
||||
>{pendingSkipped.length} file(s) will be skipped.</span
|
||||
>
|
||||
{/if}
|
||||
</span>
|
||||
</ConfirmationModal>
|
||||
|
||||
<ConfirmationModal
|
||||
open={toDelete !== undefined}
|
||||
title="Delete skill"
|
||||
confirmationText="Delete"
|
||||
onConfirmed={async () => {
|
||||
const name = toDelete
|
||||
toDelete = undefined
|
||||
if (name) await deleteSkill(name)
|
||||
}}
|
||||
onCanceled={() => (toDelete = undefined)}
|
||||
>
|
||||
<span>
|
||||
Delete the skill <code>{toDelete}</code>? The AI chat will no longer be able to use it.
|
||||
</span>
|
||||
</ConfirmationModal>
|
||||
@@ -186,6 +186,10 @@ export interface SQLSchema {
|
||||
schema: SQLBaseSchema
|
||||
publicOnly: boolean | undefined
|
||||
stringified: string
|
||||
/** MySQL only: the connection's default database (`DATABASE()`), surfaced by the
|
||||
* introspection script. Lets the table picker render the default db's tables
|
||||
* unprefixed even when the connection can also see other (non-system) schemas. */
|
||||
defaultDb?: string
|
||||
}
|
||||
|
||||
export interface GraphqlSchema {
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
/**
|
||||
* One-off migration from the localStorage UserDraft autosave to the
|
||||
* DB-backed `draft` table. Runs after `migrateLegacyUserDrafts` (which
|
||||
* produces the `userdraft/w/{workspace}/{kind}/{path}` keys this reads),
|
||||
* POSTing each to `/drafts/update` and clearing the source key only on
|
||||
* success — so it's idempotent without a sentinel; failed entries retry next
|
||||
* mount. Not workspace-gated: keys embed their own workspace and the token
|
||||
* covers all of them, so gating would orphan other-workspace entries.
|
||||
* DB-backed `draft` table. Reads the workspace-scoped
|
||||
* `userdraft/w/{workspace}/{kind}/{path}` keys (written by the editor during
|
||||
* the interim LS-backed phase, so the embedded workspace is correct), POSTing
|
||||
* each to `/drafts/update` and clearing the source key only on success — so
|
||||
* it's idempotent without a sentinel; failed entries retry next mount. Not
|
||||
* workspace-gated: keys embed their own workspace and the token covers all of
|
||||
* them, so gating would orphan other-workspace entries.
|
||||
*
|
||||
* Before uploading, each draft is compared against its deployed version
|
||||
* (script / flow / app); a draft that's deep-equal to what's deployed carries
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest'
|
||||
import {
|
||||
migrateLegacyUserDrafts,
|
||||
purgeLegacyUserDrafts,
|
||||
__resetUserDraftLegacyMigrationForTesting
|
||||
} from './userDraftLegacyMigration'
|
||||
|
||||
@@ -8,18 +8,12 @@ function encodeLegacy(value: unknown): string {
|
||||
return btoa(encodeURIComponent(JSON.stringify(value)))
|
||||
}
|
||||
|
||||
function wrapped<V>(value: V): string {
|
||||
return JSON.stringify({ value })
|
||||
}
|
||||
|
||||
// Read a migrated entry, strip the GC `lastWrittenAt` stamp so assertions
|
||||
// can match the `{ value }` shape regardless of when the migration ran.
|
||||
function storedShape(key: string): string | null {
|
||||
const raw = localStorage.getItem(key)
|
||||
if (raw == null) return null
|
||||
const parsed = JSON.parse(raw)
|
||||
delete parsed.lastWrittenAt
|
||||
return JSON.stringify(parsed)
|
||||
const legacyApp = {
|
||||
grid: [],
|
||||
fullscreen: false,
|
||||
theme: undefined,
|
||||
unusedInlineScripts: [],
|
||||
hiddenInlineScripts: []
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -27,197 +21,110 @@ beforeEach(() => {
|
||||
__resetUserDraftLegacyMigrationForTesting()
|
||||
})
|
||||
|
||||
describe('migrateLegacyUserDrafts', () => {
|
||||
it('migrates a legacy app draft to the workspace-scoped key with a { value } wrapper', () => {
|
||||
// Shape mirrors what the legacy AppEditor wrote: `encodeState($appStore)`,
|
||||
// i.e. the inner App value, not the wrapping AppWithLastVersion.
|
||||
const legacyApp = {
|
||||
grid: [],
|
||||
fullscreen: false,
|
||||
theme: undefined,
|
||||
unusedInlineScripts: [],
|
||||
hiddenInlineScripts: []
|
||||
}
|
||||
describe('purgeLegacyUserDrafts', () => {
|
||||
it('drops a recognised legacy app draft without re-creating it under any key', () => {
|
||||
localStorage.setItem('app-u/me/dashboard', encodeLegacy(legacyApp))
|
||||
|
||||
migrateLegacyUserDrafts('main')
|
||||
purgeLegacyUserDrafts()
|
||||
|
||||
expect(localStorage.getItem('app-u/me/dashboard')).toBeNull()
|
||||
expect(storedShape('userdraft/w/main/app/u/me/dashboard')).toBe(wrapped(legacyApp))
|
||||
// The workspace-blind key is gone, NOT promoted to a guessed workspace.
|
||||
expect(localStorage.getItem('userdraft/w/main/app/u/me/dashboard')).toBeNull()
|
||||
})
|
||||
|
||||
it('migrates a legacy empty-path app draft (the `app` literal key)', () => {
|
||||
const legacyApp = {
|
||||
grid: [],
|
||||
fullscreen: false,
|
||||
unusedInlineScripts: [],
|
||||
hiddenInlineScripts: []
|
||||
}
|
||||
it('drops the empty-path legacy keys (`app` / `flow` / `rawapp` literals)', () => {
|
||||
localStorage.setItem('app', encodeLegacy(legacyApp))
|
||||
localStorage.setItem('flow', encodeLegacy({ flow: { summary: '', value: { modules: [] } } }))
|
||||
localStorage.setItem('rawapp', encodeLegacy({ files: {}, runnables: {}, data: {} }))
|
||||
|
||||
migrateLegacyUserDrafts('main')
|
||||
purgeLegacyUserDrafts()
|
||||
|
||||
expect(localStorage.getItem('app')).toBeNull()
|
||||
expect(storedShape('userdraft/w/main/app/')).toBe(wrapped(legacyApp))
|
||||
expect(localStorage.getItem('flow')).toBeNull()
|
||||
expect(localStorage.getItem('rawapp')).toBeNull()
|
||||
})
|
||||
|
||||
it('migrates a legacy flow draft and strips the view-state envelope', () => {
|
||||
const flow = { summary: 'f', value: { modules: [] }, path: 'u/me/myflow' }
|
||||
const legacyBundle = {
|
||||
flow,
|
||||
path: 'u/me/myflow',
|
||||
selectedId: 'settings',
|
||||
draft_triggers: [{ id: 't1' }],
|
||||
selected_trigger: null,
|
||||
loadedFromHistory: undefined
|
||||
}
|
||||
localStorage.setItem('flow-u/me/myflow', encodeLegacy(legacyBundle))
|
||||
it('drops recognised legacy flow and raw-app drafts', () => {
|
||||
localStorage.setItem(
|
||||
'flow-u/me/myflow',
|
||||
encodeLegacy({ flow: { summary: 'f', value: { modules: [] } }, selectedId: 'settings' })
|
||||
)
|
||||
localStorage.setItem(
|
||||
'rawapp-u/me/site',
|
||||
encodeLegacy({ files: { 'index.tsx': 'x' }, runnables: {}, data: {} })
|
||||
)
|
||||
|
||||
migrateLegacyUserDrafts('main')
|
||||
purgeLegacyUserDrafts()
|
||||
|
||||
expect(localStorage.getItem('flow-u/me/myflow')).toBeNull()
|
||||
// Only the inner Flow survives; the view-state envelope is dropped.
|
||||
expect(storedShape('userdraft/w/main/flow/u/me/myflow')).toBe(wrapped(flow))
|
||||
})
|
||||
|
||||
it('migrates a legacy raw-app draft, defaulting the new `summary` field', () => {
|
||||
const legacy = {
|
||||
files: { 'index.tsx': 'export default () => null' },
|
||||
runnables: {},
|
||||
data: { tables: [] }
|
||||
}
|
||||
localStorage.setItem('rawapp-u/me/site', encodeLegacy(legacy))
|
||||
|
||||
migrateLegacyUserDrafts('main')
|
||||
|
||||
expect(localStorage.getItem('rawapp-u/me/site')).toBeNull()
|
||||
expect(storedShape('userdraft/w/main/raw_app/u/me/site')).toBe(
|
||||
wrapped({ ...legacy, summary: '' })
|
||||
)
|
||||
})
|
||||
|
||||
it('preserves an existing new-format entry instead of overwriting it', () => {
|
||||
// Old and new both exist for the same item — the new one is presumed
|
||||
// fresher.
|
||||
localStorage.setItem(
|
||||
'app-u/me/dash',
|
||||
encodeLegacy({
|
||||
grid: [],
|
||||
fullscreen: false,
|
||||
unusedInlineScripts: [],
|
||||
hiddenInlineScripts: []
|
||||
})
|
||||
)
|
||||
const existingNew = wrapped({ value: 'new' })
|
||||
localStorage.setItem('userdraft/w/main/app/u/me/dash', existingNew)
|
||||
it('leaves the workspace-scoped interim keys untouched (migrateUserDraftsToDb owns those)', () => {
|
||||
const interim = JSON.stringify({ value: { modules: [] } })
|
||||
localStorage.setItem('userdraft/w/main/flow/u/me/keep', interim)
|
||||
|
||||
migrateLegacyUserDrafts('main')
|
||||
purgeLegacyUserDrafts()
|
||||
|
||||
expect(localStorage.getItem('app-u/me/dash')).toBeNull()
|
||||
expect(localStorage.getItem('userdraft/w/main/app/u/me/dash')).toBe(existingNew)
|
||||
})
|
||||
|
||||
it('is idempotent — the second invocation is a no-op', () => {
|
||||
localStorage.setItem(
|
||||
'app-u/me/dash',
|
||||
encodeLegacy({
|
||||
grid: [],
|
||||
fullscreen: false,
|
||||
unusedInlineScripts: [],
|
||||
hiddenInlineScripts: []
|
||||
})
|
||||
)
|
||||
migrateLegacyUserDrafts('main')
|
||||
expect(localStorage.getItem('userdraft/w/main/app/u/me/dash')).not.toBeNull()
|
||||
|
||||
// Drop the migrated entry to detect any re-migration attempt.
|
||||
localStorage.removeItem('userdraft/w/main/app/u/me/dash')
|
||||
// Drop the source too, so re-running couldn't even find a source.
|
||||
// (The sentinel alone should be enough; this just clarifies the intent.)
|
||||
migrateLegacyUserDrafts('main')
|
||||
expect(localStorage.getItem('userdraft/w/main/app/u/me/dash')).toBeNull()
|
||||
})
|
||||
|
||||
it('skips entirely when no workspace is available', () => {
|
||||
localStorage.setItem(
|
||||
'app-u/me/dash',
|
||||
encodeLegacy({
|
||||
grid: [],
|
||||
fullscreen: false,
|
||||
unusedInlineScripts: [],
|
||||
hiddenInlineScripts: []
|
||||
})
|
||||
)
|
||||
migrateLegacyUserDrafts('')
|
||||
|
||||
expect(localStorage.getItem('app-u/me/dash')).not.toBeNull()
|
||||
})
|
||||
|
||||
it('handles malformed legacy payloads without throwing', () => {
|
||||
localStorage.setItem('app-u/me/garbled', 'not-base64!!!')
|
||||
expect(() => migrateLegacyUserDrafts('main')).not.toThrow()
|
||||
// Migration didn't migrate, didn't crash — leaves the entry alone.
|
||||
expect(localStorage.getItem('app-u/me/garbled')).toBe('not-base64!!!')
|
||||
expect(localStorage.getItem('userdraft/w/main/flow/u/me/keep')).toBe(interim)
|
||||
})
|
||||
|
||||
it('leaves keys whose path does not match the legacy `u|f/owner/name` shape alone', () => {
|
||||
// A future feature or neighbouring code might pick a key like
|
||||
// `app-recent` for its own purposes. The path doesn't look like a
|
||||
// Windmill item path, so the migration must skip it.
|
||||
// `app-recent` / `app-some_other_app` look like the legacy prefix but the
|
||||
// suffix isn't a Windmill item path — a future feature might own them.
|
||||
localStorage.setItem('app-recent', 'whatever')
|
||||
localStorage.setItem('app-some_other_app', 'whatever')
|
||||
// `flow-u/me/foo` matches the shape and would be migrated, but the
|
||||
// payload also needs to look like a Windmill draft (asserted below).
|
||||
localStorage.setItem('flow-u/me/foo', encodeLegacy({ flow: { value: { modules: [] } } }))
|
||||
|
||||
migrateLegacyUserDrafts('main')
|
||||
purgeLegacyUserDrafts()
|
||||
|
||||
expect(localStorage.getItem('app-recent')).toBe('whatever')
|
||||
expect(localStorage.getItem('app-some_other_app')).toBe('whatever')
|
||||
expect(localStorage.getItem('userdraft/w/main/flow/u/me/foo')).not.toBeNull()
|
||||
})
|
||||
|
||||
it('skips legacy-shaped keys whose payload does not look like a Windmill draft', () => {
|
||||
// `app-u/me/dash` matches LEGACY_PATH_SHAPE and decodes to valid JSON,
|
||||
// but none of the App-shape fields (grid/fullscreen/theme/
|
||||
// unusedInlineScripts/hiddenInlineScripts) are present. Treat it as
|
||||
// unrelated and leave it untouched.
|
||||
const unrelated = encodeLegacy({ random: 'data', count: 7 })
|
||||
localStorage.setItem('app-u/me/dash', unrelated)
|
||||
it('leaves legacy-shaped keys whose payload does not look like a Windmill draft', () => {
|
||||
// Matches LEGACY_PATH_SHAPE and decodes to valid JSON, but carries none of
|
||||
// the App/flow draft fields — treat as unrelated, do not delete.
|
||||
const unrelatedApp = encodeLegacy({ random: 'data', count: 7 })
|
||||
localStorage.setItem('app-u/me/dash', unrelatedApp)
|
||||
const unrelatedFlow = encodeLegacy({ stepsState: {} })
|
||||
localStorage.setItem('flow-u/me/bar', unrelatedFlow)
|
||||
|
||||
migrateLegacyUserDrafts('main')
|
||||
purgeLegacyUserDrafts()
|
||||
|
||||
expect(localStorage.getItem('app-u/me/dash')).toBe(unrelated)
|
||||
expect(localStorage.getItem('userdraft/w/main/app/u/me/dash')).toBeNull()
|
||||
expect(localStorage.getItem('app-u/me/dash')).toBe(unrelatedApp)
|
||||
expect(localStorage.getItem('flow-u/me/bar')).toBe(unrelatedFlow)
|
||||
expect(localStorage.getItem('userdraft/w/main/flow/u/me/bar')).toBeNull()
|
||||
})
|
||||
|
||||
it('migrates multiple legacy entries in a single invocation', () => {
|
||||
localStorage.setItem(
|
||||
'app-u/me/a',
|
||||
encodeLegacy({
|
||||
grid: [],
|
||||
fullscreen: false,
|
||||
unusedInlineScripts: [],
|
||||
hiddenInlineScripts: []
|
||||
})
|
||||
)
|
||||
it('leaves a malformed (non-base64) legacy payload alone and does not throw', () => {
|
||||
localStorage.setItem('app-u/me/garbled', 'not-base64!!!')
|
||||
|
||||
expect(() => purgeLegacyUserDrafts()).not.toThrow()
|
||||
expect(localStorage.getItem('app-u/me/garbled')).toBe('not-base64!!!')
|
||||
})
|
||||
|
||||
it('is idempotent — once the sentinel is set, a later legacy key survives', () => {
|
||||
localStorage.setItem('app-u/me/a', encodeLegacy(legacyApp))
|
||||
purgeLegacyUserDrafts()
|
||||
expect(localStorage.getItem('app-u/me/a')).toBeNull()
|
||||
|
||||
// A key written after the first run is NOT swept (the sentinel short-circuits).
|
||||
localStorage.setItem('app-u/me/b', encodeLegacy(legacyApp))
|
||||
purgeLegacyUserDrafts()
|
||||
expect(localStorage.getItem('app-u/me/b')).not.toBeNull()
|
||||
})
|
||||
|
||||
it('purges multiple legacy entries in a single invocation', () => {
|
||||
localStorage.setItem('app-u/me/a', encodeLegacy(legacyApp))
|
||||
localStorage.setItem(
|
||||
'flow-u/me/b',
|
||||
encodeLegacy({ flow: { summary: '', value: { modules: [] }, path: 'u/me/b' } })
|
||||
)
|
||||
localStorage.setItem(
|
||||
'rawapp-u/me/c',
|
||||
encodeLegacy({ files: {}, runnables: {}, data: { tables: [] } })
|
||||
encodeLegacy({ flow: { summary: '', value: { modules: [] } } })
|
||||
)
|
||||
localStorage.setItem('rawapp-u/me/c', encodeLegacy({ files: {}, runnables: {}, data: {} }))
|
||||
|
||||
migrateLegacyUserDrafts('main')
|
||||
purgeLegacyUserDrafts()
|
||||
|
||||
expect(localStorage.getItem('userdraft/w/main/app/u/me/a')).not.toBeNull()
|
||||
expect(localStorage.getItem('userdraft/w/main/flow/u/me/b')).not.toBeNull()
|
||||
expect(localStorage.getItem('userdraft/w/main/raw_app/u/me/c')).not.toBeNull()
|
||||
expect(localStorage.getItem('app-u/me/a')).toBeNull()
|
||||
expect(localStorage.getItem('flow-u/me/b')).toBeNull()
|
||||
expect(localStorage.getItem('rawapp-u/me/c')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,22 +1,29 @@
|
||||
/**
|
||||
* One-off migration from the pre-UserDraft localStorage autosave entries to
|
||||
* the workspace-scoped `userdraft/w/{ws}/{kind}/{path}` format.
|
||||
* One-shot purge of the pre-UserDraft browser-local autosave keys.
|
||||
*
|
||||
* Legacy keys (global, not workspace-scoped — assumed to belong to the user's
|
||||
* current workspace at migration time):
|
||||
* The original autosave (pre-#9121) wrote workspace-BLIND keys:
|
||||
*
|
||||
* `flow` / `flow-{path}` base64 of `encodeState({ flow, path, selectedId, draft_triggers, ... })`
|
||||
* `app` / `app-{path}` base64 of `encodeState(App)`
|
||||
* `rawapp` / `rawapp-{path}` base64 of `encodeState({ files, runnables, data })`
|
||||
* `flow` / `flow-{path}` base64 of `encodeState({ flow, path, selectedId, draft_triggers, ... })`
|
||||
* `app` / `app-{path}` base64 of `encodeState(App)`
|
||||
* `rawapp` / `rawapp-{path}` base64 of `encodeState({ files, runnables, data })`
|
||||
*
|
||||
* Target keys: `userdraft/w/{workspace}/{flow|app|raw_app}/{path}` storing
|
||||
* `JSON.stringify({ value: <transformed legacy value> })`.
|
||||
* Neither the key nor the decoded value records a workspace (the value carries
|
||||
* only workspace-agnostic item paths like `u/me/x`), so these drafts cannot be
|
||||
* attributed to the workspace they were edited in. The current editors are
|
||||
* DB-backed and never read these keys, so they are dead data with one dangerous
|
||||
* property: promoting them to the DB would force a GUESS of the workspace, which
|
||||
* mis-files drafts into whatever workspace happened to be active when the
|
||||
* migration first ran (a single global sentinel gates it). We therefore drop
|
||||
* them instead of migrating them.
|
||||
*
|
||||
* Only keys that BOTH match the legacy path shape AND decode to a plausible
|
||||
* legacy draft are removed; unrelated look-alikes (`app-recent`, garbage,
|
||||
* non-Windmill payloads) are left untouched. The workspace-scoped interim keys
|
||||
* (`userdraft/w/{ws}/...`, written by the editor with the correct workspace)
|
||||
* are NOT touched here — `migrateUserDraftsToDb` still pushes those to the DB.
|
||||
*
|
||||
* Idempotent: writes a sentinel under `MIGRATION_FLAG` after the first run so
|
||||
* subsequent invocations are no-ops. Existing new-format entries are never
|
||||
* overwritten — when both an old and a new entry exist for the same item, the
|
||||
* old one is simply dropped on the assumption that the new entry is the more
|
||||
* recent edit.
|
||||
* subsequent invocations are no-ops.
|
||||
*
|
||||
* This file is intentionally standalone — it does not import from
|
||||
* `userDraft.svelte.ts` so the new code stays uncluttered by the legacy
|
||||
@@ -71,10 +78,9 @@ function decodeLegacyState(raw: string): unknown {
|
||||
* Per-kind shape gate. The legacy keys (`app-foo`, `flow-foo`, ...) are
|
||||
* unusual enough that nothing else in the codebase has used them, but
|
||||
* matching `LEGACY_PATH_SHAPE` doesn't prove the payload is actually a
|
||||
* Windmill draft (any base64-of-JSON could pass). Promoting a stray payload
|
||||
* would silently surface as a phantom "Restored from local storage" toast
|
||||
* on the next edit, so we reject anything that doesn't carry the fields the
|
||||
* legacy writers actually produced.
|
||||
* Windmill draft (any base64-of-JSON could pass). We only delete keys we can
|
||||
* positively recognise as legacy drafts, so a stray look-alike that happens to
|
||||
* use this key shape is left untouched rather than silently dropped.
|
||||
*/
|
||||
function isPlausibleLegacyValue(kind: LegacyKind, decoded: unknown): boolean {
|
||||
if (decoded == null || typeof decoded !== 'object') return false
|
||||
@@ -103,32 +109,6 @@ function isPlausibleLegacyValue(kind: LegacyKind, decoded: unknown): boolean {
|
||||
}
|
||||
}
|
||||
|
||||
function transformLegacyValue(kind: LegacyKind, decoded: unknown): unknown {
|
||||
const obj = decoded as Record<string, unknown>
|
||||
switch (kind) {
|
||||
case 'flow':
|
||||
// The legacy bundle wrapped the Flow alongside view-state fields
|
||||
// (selectedId, draft_triggers, ...). The new entry stores only the
|
||||
// Flow — the view-state lives elsewhere or is re-derived.
|
||||
return obj.flow
|
||||
case 'app':
|
||||
// Legacy stored the App directly.
|
||||
return obj
|
||||
case 'raw_app':
|
||||
// Legacy bundle missed the `summary` field that the new editor adds.
|
||||
return {
|
||||
files: obj.files ?? {},
|
||||
runnables: obj.runnables ?? {},
|
||||
data: obj.data ?? {},
|
||||
summary: typeof obj.summary === 'string' ? obj.summary : ''
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function newKey(workspace: string, kind: LegacyKind, path: string): string {
|
||||
return `userdraft/w/${workspace}/${kind}/${path}`
|
||||
}
|
||||
|
||||
function listLocalStorageKeys(): string[] {
|
||||
const out: string[] = []
|
||||
for (let i = 0; i < localStorage.length; i++) {
|
||||
@@ -139,16 +119,11 @@ function listLocalStorageKeys(): string[] {
|
||||
}
|
||||
|
||||
/**
|
||||
* Run the legacy → new-format migration. Idempotent: returns immediately if a
|
||||
* previous run completed (signalled by `MIGRATION_FLAG`).
|
||||
*
|
||||
* The migration is workspace-scoped because the legacy keys had no notion of
|
||||
* workspace — we treat the caller's current workspace as the owner of any
|
||||
* surviving legacy entries.
|
||||
* Remove the workspace-blind legacy autosave keys (see file header). Idempotent:
|
||||
* returns immediately if a previous run completed (signalled by `MIGRATION_FLAG`).
|
||||
*/
|
||||
export function migrateLegacyUserDrafts(workspace: string): void {
|
||||
export function purgeLegacyUserDrafts(): void {
|
||||
if (typeof localStorage === 'undefined') return
|
||||
if (!workspace) return
|
||||
if (localStorage.getItem(MIGRATION_FLAG) !== null) return
|
||||
|
||||
try {
|
||||
@@ -157,32 +132,18 @@ export function migrateLegacyUserDrafts(workspace: string): void {
|
||||
if (!match) continue
|
||||
const raw = localStorage.getItem(key)
|
||||
if (raw == null) continue
|
||||
|
||||
try {
|
||||
const decoded = decodeLegacyState(raw)
|
||||
if (!isPlausibleLegacyValue(match.newKind, decoded)) continue
|
||||
const value = transformLegacyValue(match.newKind, decoded)
|
||||
const target = newKey(workspace, match.newKind, match.path)
|
||||
if (value !== undefined && localStorage.getItem(target) == null) {
|
||||
// `lastWrittenAt` makes the migrated entry visible to
|
||||
// `gcUserDrafts`. We stamp it as "now" so a freshly-migrated
|
||||
// autosave gets the full retention window — sweeping it
|
||||
// immediately on the first GC pass would lose work the
|
||||
// legacy migration just rescued.
|
||||
localStorage.setItem(target, JSON.stringify({ value, lastWrittenAt: Date.now() }))
|
||||
}
|
||||
localStorage.removeItem(key)
|
||||
} catch (e) {
|
||||
console.error('UserDraft legacy migration: failed to migrate', key, e)
|
||||
}
|
||||
// Only drop keys we can positively recognise as legacy Windmill
|
||||
// drafts; leave unrelated or unparseable look-alikes in place.
|
||||
if (!isPlausibleLegacyValue(match.newKind, decodeLegacyState(raw))) continue
|
||||
localStorage.removeItem(key)
|
||||
}
|
||||
localStorage.setItem(MIGRATION_FLAG, new Date().toISOString())
|
||||
} catch (e) {
|
||||
console.error('UserDraft legacy migration: aborted', e)
|
||||
console.error('UserDraft legacy purge: aborted', e)
|
||||
}
|
||||
}
|
||||
|
||||
/** Test-only: clear the sentinel so the migration can re-run. */
|
||||
/** Test-only: clear the sentinel so the purge can re-run. */
|
||||
export function __resetUserDraftLegacyMigrationForTesting(): void {
|
||||
try {
|
||||
localStorage.removeItem(MIGRATION_FLAG)
|
||||
|
||||
@@ -1190,8 +1190,10 @@ export function isCodeInjection(expr: string | undefined): boolean {
|
||||
// app logic via the `query` context. Only params we actually own are listed
|
||||
// here — the `wm_` prefix is a naming convention, not a reserved namespace, so
|
||||
// we don't strip it wholesale (that would break apps reading their own `wm_*`
|
||||
// params). `wm_coep` is a transport flag for cross-origin isolation headers.
|
||||
export const WINDMILL_RESERVED_QUERY_PARAMS = new Set(['wm_coep'])
|
||||
// params). `wm_coep` is a transport flag for cross-origin isolation headers;
|
||||
// `wm_embed`/`wm_embedder_origin` are the opaque app viewer transport params
|
||||
// (see PublicAppFrame).
|
||||
export const WINDMILL_RESERVED_QUERY_PARAMS = new Set(['wm_coep', 'wm_embed', 'wm_embedder_origin'])
|
||||
|
||||
export function urlParamsToObject(
|
||||
params: URLSearchParams,
|
||||
|
||||
@@ -58,7 +58,7 @@
|
||||
import GlobalSearchModal from '$lib/components/search/GlobalSearchModal.svelte'
|
||||
import MenuButton from '$lib/components/sidebar/MenuButton.svelte'
|
||||
import { loadProtectionRules } from '$lib/workspaceProtectionRules.svelte'
|
||||
import { migrateLegacyUserDrafts } from '$lib/userDraftLegacyMigration'
|
||||
import { purgeLegacyUserDrafts } from '$lib/userDraftLegacyMigration'
|
||||
import { migrateUserDraftsToDb } from '$lib/userDraftDbMigration'
|
||||
import DraftMigrationErrorModal from '$lib/components/DraftMigrationErrorModal.svelte'
|
||||
import { setContext, untrack } from 'svelte'
|
||||
@@ -164,8 +164,9 @@
|
||||
const toPath = navigation.to?.url.pathname
|
||||
if (toPath && (toPath.startsWith('/apps_raw/add') || toPath.startsWith('/apps_raw/edit'))) {
|
||||
const currentPath = navigation.from?.url.pathname
|
||||
// Reload if we're not on an apps_raw path, or if we're on /apps/get_raw/ (viewing a raw app)
|
||||
// The /apps/get_raw/ path doesn't have cross-origin isolation headers, so we need to reload
|
||||
// Reload if we're not on an apps_raw path, or if we're on the raw app viewer
|
||||
// (/apps_raw/get/): the viewer doesn't have cross-origin isolation headers, so
|
||||
// we need a full reload to fetch them for the editor.
|
||||
if (!currentPath?.startsWith('/apps_raw/') || currentPath?.startsWith('/apps_raw/get/')) {
|
||||
navigation.cancel()
|
||||
window.location.href = navigation.to!.url.href
|
||||
@@ -435,16 +436,17 @@
|
||||
$effect(() => {
|
||||
$workspaceStore && untrack(() => onLoad())
|
||||
})
|
||||
// One-shot UserDraft migration chain. `migrateLegacyUserDrafts` folds
|
||||
// the legacy `flow` / `app-…` / `rawapp-…` LS keys into the
|
||||
// `userdraft/w/{ws}/{kind}/{path}` format; `migrateUserDraftsToDb`
|
||||
// then pushes those onto the server-side draft table and clears LS
|
||||
// on success. The order matters — the second step only sees what
|
||||
// the first one normalized.
|
||||
// One-shot UserDraft migration. `purgeLegacyUserDrafts` drops the oldest
|
||||
// workspace-blind `flow` / `app-…` / `rawapp-…` LS autosave keys (they
|
||||
// can't be attributed to a workspace, so promoting them would mis-file
|
||||
// drafts). `migrateUserDraftsToDb` then pushes the workspace-scoped
|
||||
// `userdraft/w/{ws}/{kind}/{path}` keys — written by the editor with the
|
||||
// correct workspace — onto the server-side draft table, clearing LS on
|
||||
// success.
|
||||
$effect(() => {
|
||||
if ($workspaceStore && $userStore) {
|
||||
untrack(() => {
|
||||
migrateLegacyUserDrafts($workspaceStore!)
|
||||
purgeLegacyUserDrafts()
|
||||
void migrateUserDraftsToDb()
|
||||
})
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user