mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-09-14 16:02:27 +00:00
Compare commits
10
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
00392ba548 | ||
|
|
ba4b368706 | ||
|
|
723a65920f | ||
|
|
c644311eca | ||
|
|
31d9215e5a | ||
|
|
75bafabeee | ||
|
|
fa3596885b | ||
|
|
6d94865109 | ||
|
|
8dea38383f | ||
|
|
2879cbb65a |
@@ -1,5 +1,26 @@
|
||||
# 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)
|
||||
|
||||
|
||||
|
||||
+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"
|
||||
}
|
||||
-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"
|
||||
}
|
||||
-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"
|
||||
}
|
||||
+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"
|
||||
}
|
||||
+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
+80
-78
@@ -13735,7 +13735,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill"
|
||||
version = "1.736.0"
|
||||
version = "1.737.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-nats",
|
||||
@@ -13817,7 +13817,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-ai"
|
||||
version = "1.736.0"
|
||||
version = "1.737.0"
|
||||
dependencies = [
|
||||
"async-stream",
|
||||
"async-trait",
|
||||
@@ -13850,7 +13850,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-alerting"
|
||||
version = "1.736.0"
|
||||
version = "1.737.0"
|
||||
dependencies = [
|
||||
"axum 0.8.9",
|
||||
"chrono",
|
||||
@@ -13863,7 +13863,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-api"
|
||||
version = "1.736.0"
|
||||
version = "1.737.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"argon2",
|
||||
@@ -14001,7 +14001,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-api-agent-workers"
|
||||
version = "1.736.0"
|
||||
version = "1.737.0"
|
||||
dependencies = [
|
||||
"axum 0.8.9",
|
||||
"chrono",
|
||||
@@ -14024,7 +14024,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-api-assets"
|
||||
version = "1.736.0"
|
||||
version = "1.737.0"
|
||||
dependencies = [
|
||||
"axum 0.8.9",
|
||||
"chrono",
|
||||
@@ -14037,7 +14037,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-api-auth"
|
||||
version = "1.736.0"
|
||||
version = "1.737.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"axum 0.8.9",
|
||||
@@ -14063,7 +14063,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-api-client"
|
||||
version = "1.736.0"
|
||||
version = "1.737.0"
|
||||
dependencies = [
|
||||
"reqwest 0.12.28",
|
||||
"serde",
|
||||
@@ -14073,7 +14073,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-api-configs"
|
||||
version = "1.736.0"
|
||||
version = "1.737.0"
|
||||
dependencies = [
|
||||
"axum 0.8.9",
|
||||
"chrono",
|
||||
@@ -14090,7 +14090,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-api-debug"
|
||||
version = "1.736.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.736.0"
|
||||
version = "1.737.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"axum 0.8.9",
|
||||
@@ -14135,7 +14135,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-api-flow-conversations"
|
||||
version = "1.736.0"
|
||||
version = "1.737.0"
|
||||
dependencies = [
|
||||
"axum 0.8.9",
|
||||
"chrono",
|
||||
@@ -14151,7 +14151,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-api-flows"
|
||||
version = "1.736.0"
|
||||
version = "1.737.0"
|
||||
dependencies = [
|
||||
"axum 0.8.9",
|
||||
"chrono",
|
||||
@@ -14172,7 +14172,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-api-groups"
|
||||
version = "1.736.0"
|
||||
version = "1.737.0"
|
||||
dependencies = [
|
||||
"axum 0.8.9",
|
||||
"chrono",
|
||||
@@ -14193,7 +14193,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-api-inputs"
|
||||
version = "1.736.0"
|
||||
version = "1.737.0"
|
||||
dependencies = [
|
||||
"axum 0.8.9",
|
||||
"chrono",
|
||||
@@ -14207,7 +14207,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-api-integration-tests"
|
||||
version = "1.736.0"
|
||||
version = "1.737.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-nats",
|
||||
@@ -14242,7 +14242,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-api-jobs"
|
||||
version = "1.736.0"
|
||||
version = "1.737.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"axum 0.8.9",
|
||||
@@ -14267,7 +14267,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-api-npm-proxy"
|
||||
version = "1.736.0"
|
||||
version = "1.737.0"
|
||||
dependencies = [
|
||||
"axum 0.8.9",
|
||||
"flate2",
|
||||
@@ -14285,7 +14285,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-api-openapi"
|
||||
version = "1.736.0"
|
||||
version = "1.737.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"axum 0.8.9",
|
||||
@@ -14307,7 +14307,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-api-schedule"
|
||||
version = "1.736.0"
|
||||
version = "1.737.0"
|
||||
dependencies = [
|
||||
"axum 0.8.9",
|
||||
"chrono",
|
||||
@@ -14327,7 +14327,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-api-scripts"
|
||||
version = "1.736.0"
|
||||
version = "1.737.0"
|
||||
dependencies = [
|
||||
"axum 0.8.9",
|
||||
"chrono",
|
||||
@@ -14364,7 +14364,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-api-settings"
|
||||
version = "1.736.0"
|
||||
version = "1.737.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"axum 0.8.9",
|
||||
@@ -14392,7 +14392,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-api-sse"
|
||||
version = "1.736.0"
|
||||
version = "1.737.0"
|
||||
dependencies = [
|
||||
"lazy_static",
|
||||
"serde",
|
||||
@@ -14404,7 +14404,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-api-users"
|
||||
version = "1.736.0"
|
||||
version = "1.737.0"
|
||||
dependencies = [
|
||||
"argon2",
|
||||
"axum 0.8.9",
|
||||
@@ -14429,7 +14429,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-api-workers"
|
||||
version = "1.736.0"
|
||||
version = "1.737.0"
|
||||
dependencies = [
|
||||
"axum 0.8.9",
|
||||
"chrono",
|
||||
@@ -14443,7 +14443,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-api-workspaces"
|
||||
version = "1.736.0"
|
||||
version = "1.737.0"
|
||||
dependencies = [
|
||||
"axum 0.8.9",
|
||||
"chrono",
|
||||
@@ -14476,7 +14476,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-audit"
|
||||
version = "1.736.0"
|
||||
version = "1.737.0"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"lazy_static",
|
||||
@@ -14490,7 +14490,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-autoscaling"
|
||||
version = "1.736.0"
|
||||
version = "1.737.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"axum 0.8.9",
|
||||
@@ -14509,7 +14509,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-common"
|
||||
version = "1.736.0"
|
||||
version = "1.737.0"
|
||||
dependencies = [
|
||||
"aes-gcm",
|
||||
"aho-corasick",
|
||||
@@ -14611,7 +14611,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-dep-map"
|
||||
version = "1.736.0"
|
||||
version = "1.737.0"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"itertools 0.14.0",
|
||||
@@ -14630,7 +14630,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-git-sync"
|
||||
version = "1.736.0"
|
||||
version = "1.737.0"
|
||||
dependencies = [
|
||||
"regex",
|
||||
"serde",
|
||||
@@ -14645,7 +14645,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-indexer"
|
||||
version = "1.736.0"
|
||||
version = "1.737.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"astral-tokio-tar",
|
||||
@@ -14669,7 +14669,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-jseval"
|
||||
version = "1.736.0"
|
||||
version = "1.737.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"futures",
|
||||
@@ -14686,7 +14686,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-macros"
|
||||
version = "1.736.0"
|
||||
version = "1.737.0"
|
||||
dependencies = [
|
||||
"itertools 0.14.0",
|
||||
"lazy_static",
|
||||
@@ -14702,7 +14702,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-mcp"
|
||||
version = "1.736.0"
|
||||
version = "1.737.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
@@ -14723,7 +14723,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-native-triggers"
|
||||
version = "1.736.0"
|
||||
version = "1.737.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
@@ -14754,7 +14754,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-oauth"
|
||||
version = "1.736.0"
|
||||
version = "1.737.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"arc-swap",
|
||||
@@ -14779,7 +14779,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-object-store"
|
||||
version = "1.736.0"
|
||||
version = "1.737.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-stream",
|
||||
@@ -14813,7 +14813,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-operator"
|
||||
version = "1.736.0"
|
||||
version = "1.737.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"futures",
|
||||
@@ -14831,7 +14831,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser"
|
||||
version = "1.736.0"
|
||||
version = "1.737.0"
|
||||
dependencies = [
|
||||
"convert_case 0.6.0",
|
||||
"serde",
|
||||
@@ -14840,7 +14840,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-bash"
|
||||
version = "1.736.0"
|
||||
version = "1.737.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"lazy_static",
|
||||
@@ -14852,7 +14852,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-csharp"
|
||||
version = "1.736.0"
|
||||
version = "1.737.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"serde_json",
|
||||
@@ -14864,7 +14864,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-go"
|
||||
version = "1.736.0"
|
||||
version = "1.737.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"gosyn",
|
||||
@@ -14876,7 +14876,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-graphql"
|
||||
version = "1.736.0"
|
||||
version = "1.737.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"lazy_static",
|
||||
@@ -14888,7 +14888,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-java"
|
||||
version = "1.736.0"
|
||||
version = "1.737.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"serde_json",
|
||||
@@ -14900,7 +14900,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-nu"
|
||||
version = "1.736.0"
|
||||
version = "1.737.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"nu-parser",
|
||||
@@ -14911,7 +14911,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-php"
|
||||
version = "1.736.0"
|
||||
version = "1.737.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"itertools 0.14.0",
|
||||
@@ -14922,7 +14922,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-py"
|
||||
version = "1.736.0"
|
||||
version = "1.737.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"itertools 0.14.0",
|
||||
@@ -14934,7 +14934,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-py-asset"
|
||||
version = "1.736.0"
|
||||
version = "1.737.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"rustpython-ast",
|
||||
@@ -14945,7 +14945,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-py-imports"
|
||||
version = "1.736.0"
|
||||
version = "1.737.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-recursion",
|
||||
@@ -14967,7 +14967,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-r"
|
||||
version = "1.736.0"
|
||||
version = "1.737.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"serde_json",
|
||||
@@ -14979,7 +14979,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-ruby"
|
||||
version = "1.736.0"
|
||||
version = "1.737.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"lazy_static",
|
||||
@@ -14993,7 +14993,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-rust"
|
||||
version = "1.736.0"
|
||||
version = "1.737.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"convert_case 0.6.0",
|
||||
@@ -15010,7 +15010,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-sql"
|
||||
version = "1.736.0"
|
||||
version = "1.737.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"lazy_static",
|
||||
@@ -15023,7 +15023,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-sql-asset"
|
||||
version = "1.736.0"
|
||||
version = "1.737.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"serde",
|
||||
@@ -15035,7 +15035,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-ts"
|
||||
version = "1.736.0"
|
||||
version = "1.737.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"lazy_static",
|
||||
@@ -15053,7 +15053,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-ts-asset"
|
||||
version = "1.736.0"
|
||||
version = "1.737.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"serde-wasm-bindgen",
|
||||
@@ -15069,7 +15069,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-wac"
|
||||
version = "1.736.0"
|
||||
version = "1.737.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"rustpython-ast",
|
||||
@@ -15085,7 +15085,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-yaml"
|
||||
version = "1.736.0"
|
||||
version = "1.737.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"serde",
|
||||
@@ -15096,7 +15096,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-queue"
|
||||
version = "1.736.0"
|
||||
version = "1.737.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-recursion",
|
||||
@@ -15134,7 +15134,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-runtime-nativets"
|
||||
version = "1.736.0"
|
||||
version = "1.737.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"const_format",
|
||||
@@ -15173,7 +15173,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-sql-datatype-parser-wasm"
|
||||
version = "1.736.0"
|
||||
version = "1.737.0"
|
||||
dependencies = [
|
||||
"getrandom 0.3.4",
|
||||
"wasm-bindgen",
|
||||
@@ -15184,17 +15184,19 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-store"
|
||||
version = "1.736.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.736.0"
|
||||
version = "1.737.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
@@ -15240,7 +15242,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-trigger"
|
||||
version = "1.736.0"
|
||||
version = "1.737.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
@@ -15273,7 +15275,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-trigger-azure"
|
||||
version = "1.736.0"
|
||||
version = "1.737.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
@@ -15306,7 +15308,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-trigger-email"
|
||||
version = "1.736.0"
|
||||
version = "1.737.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
@@ -15326,7 +15328,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-trigger-gcp"
|
||||
version = "1.736.0"
|
||||
version = "1.737.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
@@ -15360,7 +15362,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-trigger-http"
|
||||
version = "1.736.0"
|
||||
version = "1.737.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
@@ -15396,7 +15398,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-trigger-kafka"
|
||||
version = "1.736.0"
|
||||
version = "1.737.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
@@ -15419,7 +15421,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-trigger-mqtt"
|
||||
version = "1.736.0"
|
||||
version = "1.737.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
@@ -15443,7 +15445,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-trigger-nats"
|
||||
version = "1.736.0"
|
||||
version = "1.737.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-nats",
|
||||
@@ -15467,7 +15469,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-trigger-postgres"
|
||||
version = "1.736.0"
|
||||
version = "1.737.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
@@ -15502,7 +15504,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-trigger-sqs"
|
||||
version = "1.736.0"
|
||||
version = "1.737.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
@@ -15530,7 +15532,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-trigger-websocket"
|
||||
version = "1.736.0"
|
||||
version = "1.737.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
@@ -15555,7 +15557,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-types"
|
||||
version = "1.736.0"
|
||||
version = "1.737.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"bitflags 2.13.0",
|
||||
@@ -15574,7 +15576,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-worker"
|
||||
version = "1.736.0"
|
||||
version = "1.737.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-once-cell",
|
||||
@@ -15684,7 +15686,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-worker-volumes"
|
||||
version = "1.736.0"
|
||||
version = "1.737.0"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"futures",
|
||||
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "windmill"
|
||||
version = "1.736.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.736.0"
|
||||
version = "1.737.0"
|
||||
authors = ["Ruben Fiszel <ruben@windmill.dev>"]
|
||||
edition = "2021"
|
||||
|
||||
|
||||
@@ -1 +1 @@
|
||||
de49fda2320504ad9e7d2d31c7033d71dbf6ca43
|
||||
ac1f6f666f36141cb6ba6f8eaa614821a90464ad
|
||||
|
||||
+24
-24
@@ -6191,7 +6191,7 @@ checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
|
||||
|
||||
[[package]]
|
||||
name = "windmill-common"
|
||||
version = "1.736.0"
|
||||
version = "1.737.0"
|
||||
dependencies = [
|
||||
"aho-corasick",
|
||||
"anyhow",
|
||||
@@ -6272,7 +6272,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-macros"
|
||||
version = "1.736.0"
|
||||
version = "1.737.0"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
@@ -6284,7 +6284,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser"
|
||||
version = "1.736.0"
|
||||
version = "1.737.0"
|
||||
dependencies = [
|
||||
"convert_case",
|
||||
"serde",
|
||||
@@ -6293,7 +6293,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-bash"
|
||||
version = "1.736.0"
|
||||
version = "1.737.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"lazy_static",
|
||||
@@ -6305,7 +6305,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-csharp"
|
||||
version = "1.736.0"
|
||||
version = "1.737.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"serde_json",
|
||||
@@ -6317,7 +6317,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-go"
|
||||
version = "1.736.0"
|
||||
version = "1.737.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"gosyn",
|
||||
@@ -6329,7 +6329,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-graphql"
|
||||
version = "1.736.0"
|
||||
version = "1.737.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"lazy_static",
|
||||
@@ -6341,7 +6341,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-java"
|
||||
version = "1.736.0"
|
||||
version = "1.737.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"serde_json",
|
||||
@@ -6353,7 +6353,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-nu"
|
||||
version = "1.736.0"
|
||||
version = "1.737.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"nu-parser",
|
||||
@@ -6364,7 +6364,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-php"
|
||||
version = "1.736.0"
|
||||
version = "1.737.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"itertools 0.14.0",
|
||||
@@ -6375,7 +6375,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-py"
|
||||
version = "1.736.0"
|
||||
version = "1.737.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"itertools 0.14.0",
|
||||
@@ -6387,7 +6387,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-py-asset"
|
||||
version = "1.736.0"
|
||||
version = "1.737.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"rustpython-ast",
|
||||
@@ -6398,7 +6398,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-py-imports"
|
||||
version = "1.736.0"
|
||||
version = "1.737.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-recursion",
|
||||
@@ -6420,7 +6420,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-r"
|
||||
version = "1.736.0"
|
||||
version = "1.737.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"serde_json",
|
||||
@@ -6432,7 +6432,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-ruby"
|
||||
version = "1.736.0"
|
||||
version = "1.737.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"lazy_static",
|
||||
@@ -6446,7 +6446,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-rust"
|
||||
version = "1.736.0"
|
||||
version = "1.737.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"convert_case",
|
||||
@@ -6463,7 +6463,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-sql"
|
||||
version = "1.736.0"
|
||||
version = "1.737.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"lazy_static",
|
||||
@@ -6476,7 +6476,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-sql-asset"
|
||||
version = "1.736.0"
|
||||
version = "1.737.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"serde",
|
||||
@@ -6488,7 +6488,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-ts"
|
||||
version = "1.736.0"
|
||||
version = "1.737.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"lazy_static",
|
||||
@@ -6506,7 +6506,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-ts-asset"
|
||||
version = "1.736.0"
|
||||
version = "1.737.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"serde-wasm-bindgen",
|
||||
@@ -6522,7 +6522,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-wac"
|
||||
version = "1.736.0"
|
||||
version = "1.737.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"rustpython-ast",
|
||||
@@ -6538,7 +6538,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-wasm"
|
||||
version = "1.736.0"
|
||||
version = "1.737.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"getrandom 0.2.17",
|
||||
@@ -6570,7 +6570,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-yaml"
|
||||
version = "1.736.0"
|
||||
version = "1.737.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"serde",
|
||||
@@ -6581,7 +6581,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-types"
|
||||
version = "1.736.0"
|
||||
version = "1.737.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"bitflags",
|
||||
|
||||
@@ -12,7 +12,7 @@ resolver = "2"
|
||||
members = ["."]
|
||||
|
||||
[workspace.package]
|
||||
version = "1.736.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;
|
||||
|
||||
@@ -451,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.
|
||||
@@ -537,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);
|
||||
@@ -640,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,
|
||||
@@ -659,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
|
||||
|
||||
@@ -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.736.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
|
||||
@@ -10569,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:
|
||||
@@ -10578,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:
|
||||
@@ -10829,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
|
||||
@@ -10946,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
|
||||
@@ -27833,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
|
||||
@@ -28052,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:
|
||||
|
||||
@@ -75,6 +75,7 @@ use windmill_common::{
|
||||
use windmill_object_store::object_store_reexports::{Attribute, Attributes};
|
||||
use windmill_store::resources::get_resource_value_interpolated_internal;
|
||||
|
||||
use windmill_api_auth::{create_token_internal, ensure_scopes_within_caller, NewToken};
|
||||
use windmill_git_sync::{handle_deployment_metadata, DeployedObject};
|
||||
use windmill_queue::{push, PushArgs, PushArgsOwned, PushIsolationLevel};
|
||||
|
||||
@@ -90,6 +91,7 @@ pub fn workspaced_service(raw_app_body_limit: usize) -> Router {
|
||||
.route("/list", get(list_apps))
|
||||
.route("/list_search", get(list_search_apps))
|
||||
.route("/get/p/{*path}", get(get_app))
|
||||
.route("/embed_token/p/{*path}", get(get_app_embed_token_for_path))
|
||||
.route("/get/lite/{*path}", get(get_app_lite))
|
||||
.route("/secret_of/{*path}", get(get_secret_id))
|
||||
.route(
|
||||
@@ -134,6 +136,7 @@ pub fn unauthed_service() -> Router {
|
||||
.route("/delete_s3_file", delete(delete_s3_file_from_app))
|
||||
.route("/download_s3_file/{*path}", get(download_s3_file_from_app))
|
||||
.route("/public_app/{secret}", get(get_public_app_by_secret))
|
||||
.route("/embed_token/{secret}", get(get_app_embed_token))
|
||||
.route("/public_resource/{*path}", get(get_public_resource))
|
||||
.route("/get_data/v/{*id}", get(get_raw_app_data))
|
||||
}
|
||||
@@ -300,6 +303,13 @@ pub struct Policy {
|
||||
pub execution_mode: ExecutionMode,
|
||||
pub s3_inputs: Option<Vec<S3Input>>,
|
||||
pub allowed_s3_keys: Option<Vec<S3Key>>,
|
||||
// WIN-2006: publisher opt-in to iframe sandbox isolation (alpha). When true the
|
||||
// app is isolated from each viewer's Windmill session: low-code renders in an
|
||||
// opaque-origin iframe with a scoped embed token, raw renders its bundle in an
|
||||
// opaque iframe. Default/absent means unsandboxed — the app runs same-origin
|
||||
// with the viewer's full session, the pre-isolation behavior.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub sandbox: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
@@ -348,6 +358,12 @@ async fn list_search_apps(
|
||||
Path(w_id): Path<String>,
|
||||
Extension(user_db): Extension<UserDB>,
|
||||
) -> JsonResult<Vec<SearchApp>> {
|
||||
// Require domain-level read: this returns every visible app's full value (code).
|
||||
// The route layer treats `apps:run` as satisfying read, so without this handler
|
||||
// check a scoped embed token (apps:run + apps:read:<one path>) could read all
|
||||
// apps' definitions. `check_scopes` uses ScopeDefinition::includes, where run
|
||||
// does NOT include read, so it correctly denies such tokens.
|
||||
check_scopes(&authed, || "apps:read".to_string())?;
|
||||
#[cfg(feature = "enterprise")]
|
||||
let n = 1000;
|
||||
|
||||
@@ -379,6 +395,9 @@ async fn list_apps(
|
||||
Query(pagination): Query<Pagination>,
|
||||
Query(lq): Query<ListAppQuery>,
|
||||
) -> JsonResult<Vec<ListableApp>> {
|
||||
// Domain-level read (see list_search_apps): keeps a scoped embed token, whose
|
||||
// `apps:run` only satisfies read at the route layer, from listing all apps.
|
||||
check_scopes(&authed, || "apps:read".to_string())?;
|
||||
let (per_page, offset) = paginate(pagination);
|
||||
|
||||
let mut sqlb = SqlBuilder::select_from("app")
|
||||
@@ -553,6 +572,7 @@ async fn list_apps(
|
||||
|
||||
async fn get_raw_app_data(
|
||||
Path((w_id, secret_with_ext)): Path<(String, String)>,
|
||||
Query(query): Query<std::collections::HashMap<String, String>>,
|
||||
Extension(db): Extension<DB>,
|
||||
) -> Result<Response> {
|
||||
#[cfg(all(feature = "enterprise", feature = "parquet"))]
|
||||
@@ -575,13 +595,52 @@ async fn get_raw_app_data(
|
||||
.await?;
|
||||
|
||||
let file_type = splitted.next().unwrap_or("");
|
||||
|
||||
// Sandboxed wrapper document that hosts the bundle. Served from a real URL
|
||||
// (not blob:/srcdoc) so we can attach `CSP: sandbox` as a response header,
|
||||
// which forces an opaque origin even on direct navigation — a raw-app
|
||||
// bundle can then never reach the authenticated Windmill origin (WIN-2006).
|
||||
// The `.js`/`.css` are loaded as same-path subresources by this document.
|
||||
if file_type == "html" {
|
||||
// ALWAYS served with `CSP: sandbox`, which forces an opaque origin even on
|
||||
// direct top-level navigation — so this real-origin URL can never be used
|
||||
// to run a raw-app bundle with the viewer's session (WIN-2006). The
|
||||
// unsandboxed (default) render is NOT applied here: it is handled entirely
|
||||
// on the viewer side, which builds its own same-origin wrapper. Relaxing
|
||||
// this header from a policy flag would let anyone with the share secret
|
||||
// hand a logged-in victim a same-origin URL that runs the bundle with
|
||||
// their session — so the standalone document stays sandboxed no matter how
|
||||
// it is reached.
|
||||
let html = raw_app_wrapper_html(secret_id);
|
||||
let mut builder = Response::builder()
|
||||
.header(http::header::CONTENT_TYPE, "text/html; charset=utf-8")
|
||||
.header("X-Content-Type-Options", "nosniff")
|
||||
.header("Cross-Origin-Resource-Policy", "cross-origin")
|
||||
.header(
|
||||
http::header::CONTENT_SECURITY_POLICY,
|
||||
"sandbox allow-scripts allow-forms allow-popups \
|
||||
allow-popups-to-escape-sandbox allow-downloads allow-modals \
|
||||
allow-top-navigation",
|
||||
);
|
||||
// When the public app page is embedded in a cross-origin-isolated page
|
||||
// (`wm_coep` opt-in, COEP `require-corp`), this nested wrapper document
|
||||
// must itself assert COEP to be allowed to load. Opt-in only — COEP
|
||||
// restricts the bundle's own subresources to CORP'd/same-origin ones
|
||||
// (e.g. external images would break), so it must not be always-on. The
|
||||
// viewer propagates the flag from the page URL (see RawAppPreview).
|
||||
if query.contains_key("wm_coep") {
|
||||
builder = builder.header("Cross-Origin-Embedder-Policy", "require-corp");
|
||||
}
|
||||
return Ok(builder.body(Body::from(html)).unwrap());
|
||||
}
|
||||
|
||||
let file_type = if file_type == "css" {
|
||||
"css"
|
||||
} else if file_type == "js" {
|
||||
"js"
|
||||
} else {
|
||||
return Err(Error::BadRequest(
|
||||
"Invalid file type, only .css and .js are supported".to_string(),
|
||||
"Invalid file type, only .css, .js and .html are supported".to_string(),
|
||||
));
|
||||
};
|
||||
// tracing::info!("file_type: {}", file_type);
|
||||
@@ -632,20 +691,128 @@ async fn get_raw_app_data(
|
||||
|
||||
if let Some(body) = body {
|
||||
// let stream = tokio_util::io::ReaderStream::new(file);
|
||||
let res = Response::builder().header(
|
||||
http::header::CONTENT_TYPE,
|
||||
if file_type == "css" {
|
||||
"text/css"
|
||||
} else {
|
||||
"text/javascript"
|
||||
},
|
||||
);
|
||||
let res = Response::builder()
|
||||
.header(
|
||||
http::header::CONTENT_TYPE,
|
||||
if file_type == "css" {
|
||||
"text/css"
|
||||
} else {
|
||||
"text/javascript"
|
||||
},
|
||||
)
|
||||
// nosniff + CORP so the bundle loads correctly as a subresource of
|
||||
// the opaque, sandboxed wrapper (incl. under a cross-origin-isolated
|
||||
// / COEP `require-corp` embedder).
|
||||
.header("X-Content-Type-Options", "nosniff")
|
||||
.header("Cross-Origin-Resource-Policy", "cross-origin");
|
||||
Ok(res.body(body).unwrap())
|
||||
} else {
|
||||
return Err(Error::NotFound("File not found".to_string()));
|
||||
}
|
||||
}
|
||||
|
||||
/// HTML wrapper that hosts a raw-app bundle inside a sandboxed, opaque-origin
|
||||
/// iframe. Served by [`get_raw_app_data`] for the `.html` "file type". It loads
|
||||
/// the bundle `.js`/`.css` as same-path subresources, shims web storage (which
|
||||
/// an opaque origin disallows), and waits for the embedder to hand it the user
|
||||
/// context via `postMessage` before evaluating the bundle — so the bundle never
|
||||
/// receives a credential and `window.ctx` is set synchronously when it runs.
|
||||
fn raw_app_wrapper_html(secret: &str) -> String {
|
||||
const TEMPLATE: &str = r##"<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<title>App</title>
|
||||
<link rel="stylesheet" href="./__SECRET__.css" />
|
||||
<script>
|
||||
(function () {
|
||||
// Storage shim: an opaque-origin (sandboxed) document has no localStorage and
|
||||
// accessing it throws. Provide an in-memory implementation so apps that use
|
||||
// web storage keep working within the session.
|
||||
try {
|
||||
window.localStorage.getItem('__wm_probe__');
|
||||
} catch (e) {
|
||||
function makeShim(onOp) {
|
||||
var mem = {};
|
||||
return {
|
||||
getItem: function (k) { k = String(k); return Object.prototype.hasOwnProperty.call(mem, k) ? mem[k] : null; },
|
||||
setItem: function (k, v) { mem[String(k)] = String(v); if (onOp) onOp({ op: 'set', key: String(k), value: String(v) }); },
|
||||
removeItem: function (k) { delete mem[String(k)]; if (onOp) onOp({ op: 'remove', key: String(k) }); },
|
||||
clear: function () { for (var k in mem) { delete mem[k]; } if (onOp) onOp({ op: 'clear' }); },
|
||||
key: function (i) { var ks = Object.keys(mem); return i < ks.length ? ks[i] : null; },
|
||||
get length() { return Object.keys(mem).length; },
|
||||
__hydrate: function (obj) { if (obj) { for (var k in obj) { mem[k] = String(obj[k]); } } }
|
||||
};
|
||||
}
|
||||
// localStorage relays each mutation up to the parent (RawAppPreview), which
|
||||
// backs a single store shared across all apps; sessionStorage stays session-only.
|
||||
function relayOp(o) { try { window.parent.postMessage({ type: 'wm_ls_op', op: o.op, key: o.key, value: o.value }, '*'); } catch (_) {} }
|
||||
var ls = makeShim(relayOp);
|
||||
var ss = makeShim(null);
|
||||
try { Object.defineProperty(window, 'localStorage', { value: ls, configurable: true }); } catch (_) {}
|
||||
try { Object.defineProperty(window, 'sessionStorage', { value: ss, configurable: true }); } catch (_) {}
|
||||
window.__wmStorageShim = { local: ls, session: ss };
|
||||
// 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 (which
|
||||
// is 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 (_) {}
|
||||
}
|
||||
|
||||
// Keep the iframe hash in sync with the parent so app URLs stay shareable.
|
||||
function notifyParent() {
|
||||
try { if (window.parent !== window) { window.parent.postMessage({ type: 'windmill:hashchange', hash: window.location.hash }, '*'); } } catch (_) {}
|
||||
}
|
||||
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(); };
|
||||
|
||||
// ctx handshake: the embedding parent hands us the user context (and any
|
||||
// persisted storage) before we evaluate the bundle, so `window.ctx` is set
|
||||
// synchronously when the bundle runs. The bundle <script> is injected only
|
||||
// after this, never inline, so it always observes a ready context.
|
||||
var loaded = false;
|
||||
function loadBundle() {
|
||||
if (loaded) return; loaded = true;
|
||||
var s = document.createElement('script');
|
||||
s.src = './__SECRET__.js';
|
||||
document.body.appendChild(s);
|
||||
}
|
||||
window.addEventListener('message', function (e) {
|
||||
var d = e.data || {};
|
||||
if (d.type === 'windmill:ctx') {
|
||||
window.ctx = d.ctx;
|
||||
if (window.__wmStorageShim && d.storage) {
|
||||
window.__wmStorageShim.local.__hydrate(d.storage.local);
|
||||
window.__wmStorageShim.session.__hydrate(d.storage.session);
|
||||
}
|
||||
if (d.initialHash && d.initialHash !== '#' && !window.location.hash) {
|
||||
try { history.replaceState(null, '', d.initialHash); } catch (_) {}
|
||||
}
|
||||
loadBundle();
|
||||
}
|
||||
});
|
||||
try { window.parent.postMessage({ type: 'windmill:ready' }, '*'); } catch (_) {}
|
||||
// Fallback for contexts that never send ctx (e.g. ctx-less rendering).
|
||||
setTimeout(loadBundle, 1500);
|
||||
})();
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
</body>
|
||||
</html>
|
||||
"##;
|
||||
TEMPLATE.replace("__SECRET__", secret)
|
||||
}
|
||||
|
||||
// async fn get_app_version(
|
||||
// authed: ApiAuthed,
|
||||
// Extension(user_db): Extension<UserDB>,
|
||||
@@ -937,6 +1104,17 @@ async fn get_public_app_by_secret(
|
||||
|
||||
let mut app = not_found_if_none(app_o, "App", id.to_string())?;
|
||||
|
||||
// Confine the app embed token (the only credential handed to untrusted app JS,
|
||||
// carrying the viewer's identity + `apps:read:<own path>`) to the app the secret
|
||||
// resolves to: without this, app JS could reuse the viewer's identity to read any
|
||||
// app it can see by secret via the RLS check below. Scoped to embed tokens only —
|
||||
// other callers (anonymous, cookie, plain external JWT) keep their existing access.
|
||||
if let Some(authed) = opt_authed.as_ref() {
|
||||
if windmill_api_auth::scopes::has_app_embed_sentinel(authed.scopes.as_deref()) {
|
||||
check_scopes(authed, || format!("apps:read:{}", app.path))?;
|
||||
}
|
||||
}
|
||||
|
||||
let policy = serde_json::from_str::<Policy>(app.policy.0.get()).map_err(to_anyhow)?;
|
||||
|
||||
if !matches!(policy.execution_mode, ExecutionMode::Anonymous) {
|
||||
@@ -971,6 +1149,300 @@ async fn get_public_app_by_secret(
|
||||
Ok(Json(app))
|
||||
}
|
||||
|
||||
/// Scopes granted to a short-lived "app embed token". This is the token the
|
||||
/// app-embedder page hands the (opaque-origin) app iframe at startup so the app
|
||||
/// never receives the viewer's session cookie. Instead of restricting which
|
||||
/// routes a *domain* may hit, we restrict which routes the *token* may hit, so
|
||||
/// that even a malicious or compromised app document can only reach the
|
||||
/// endpoints an app legitimately needs. The `app_embed` sentinel turns each of
|
||||
/// these into a strict route allowlist (`app_embed_route_denied`):
|
||||
/// - `jobs:read` → by-id job poll/cancel only; enumeration, counts, exports,
|
||||
/// and `job_signature`/`resume_urls` are denied, and by-id
|
||||
/// reads are confined to the app's own runs.
|
||||
/// - `app_embed` → sentinel tagging this as an app embed token (grants nothing).
|
||||
/// - `resources:run` → resource metadata only (pickers, type schemas), never values.
|
||||
/// - `users:read` → `users/whoami` only.
|
||||
/// - `folders:read` → `folders/listnames` only.
|
||||
/// Plus two path-scoped scopes minted per app (see `mint_app_embed_token`):
|
||||
/// - `apps:read:<path>` → the app's own definition (`apps/get/p/<path>`); no
|
||||
/// `apps:write`, so management routes are unreachable.
|
||||
/// - `apps:run:<path>` → run THIS app's components (`execute_component`, which
|
||||
/// re-checks the path); `apps_u/*` public-serving routes.
|
||||
pub const APP_EMBED_SCOPES: [&str; 5] = [
|
||||
"jobs:read",
|
||||
windmill_api_auth::scopes::APP_EMBED_SENTINEL,
|
||||
"resources:run",
|
||||
"users:read",
|
||||
"folders:read",
|
||||
];
|
||||
|
||||
/// How long an app embed token stays valid. The embedder re-mints on demand
|
||||
/// (e.g. after a `401` from the iframe) so this can stay short.
|
||||
const APP_EMBED_TOKEN_VALIDITY_HOURS: i64 = 12;
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct EmbedTokenResponse {
|
||||
/// Narrowly-scoped token for the iframe. `None` for fully anonymous access
|
||||
/// (the iframe then calls the public endpoints anonymously).
|
||||
pub token: Option<String>,
|
||||
pub expiration: Option<chrono::DateTime<chrono::Utc>>,
|
||||
/// WIN-2006: raw apps render single-iframe (the bundle is already isolated in
|
||||
/// its own opaque iframe), so the viewer skips the opaque-viewer indirection
|
||||
/// and the embed token entirely — it loads the app with the page credential.
|
||||
#[serde(default)]
|
||||
pub raw_app: bool,
|
||||
/// WIN-2006: publisher opted this app into sandbox isolation. When false the
|
||||
/// viewer runs the app same-origin with its full session (the default,
|
||||
/// pre-isolation behavior).
|
||||
#[serde(default)]
|
||||
pub sandbox: bool,
|
||||
/// WIN-2006: the resolved app path. The embedder uses it (together with
|
||||
/// `workspace_id`) to scope the app's backing `localStorage` per app (so
|
||||
/// sandboxed apps don't share one store). Not a new disclosure — the viewer
|
||||
/// already receives `path` when it loads the app (e.g. `get_public_app_by_secret`).
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub app_path: Option<String>,
|
||||
/// WIN-2006: the resolved workspace. Pairs with `app_path` for the per-app
|
||||
/// `localStorage` key so two apps at the same path in different workspaces don't
|
||||
/// share a store. For custom-path apps the viewer can't derive this itself.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub workspace_id: Option<String>,
|
||||
}
|
||||
|
||||
/// Mint a short-lived, narrowly-scoped embed token for `app_path` when a caller
|
||||
/// is authenticated. When `opt_authed` is `None` (anonymous access to an
|
||||
/// anonymous app) no token is minted and the iframe relies on the public
|
||||
/// endpoints.
|
||||
///
|
||||
/// The CALLER MUST verify the viewer's access to `app_path` before calling: this
|
||||
/// mints a token on behalf of `opt_authed` unconditionally (DB access remains
|
||||
/// gated by the viewer's own RLS, but the token's existence is not access-checked
|
||||
/// here). All current call sites (`get_app_embed_token`,
|
||||
/// `get_app_embed_token_for_path`, and the EE custom-path variant) do this.
|
||||
///
|
||||
/// Scope confinement IS enforced here: the minted scopes must be within the
|
||||
/// caller's own (`ensure_scopes_within_caller`), so a scope-restricted bearer
|
||||
/// token cannot bootstrap a broader-scoped embed token. For the normal caller —
|
||||
/// an unscoped browser session — this is a no-op and the mint is purely
|
||||
/// narrowing.
|
||||
pub async fn mint_app_embed_token(
|
||||
db: &DB,
|
||||
w_id: &str,
|
||||
app_path: &str,
|
||||
opt_authed: Option<&ApiAuthed>,
|
||||
) -> Result<EmbedTokenResponse> {
|
||||
let token_and_exp = if let Some(authed) = opt_authed {
|
||||
// An app embed token represents untrusted app JS in the sandboxed iframe; it
|
||||
// must never reach this mint path to renew itself. The 12h expiry is the
|
||||
// blast-radius cap on a leaked embed token, and `ensure_scopes_within_caller`
|
||||
// below would pass a same-scoped renewal (the requested scopes equal the
|
||||
// caller's own), making the credential indefinitely self-renewable. Refresh
|
||||
// minting is the trusted embedder session/JWT's job.
|
||||
if windmill_api_auth::scopes::has_app_embed_sentinel(authed.scopes.as_deref()) {
|
||||
return Err(Error::NotAuthorized(
|
||||
"App embed tokens cannot mint or renew embed tokens".to_string(),
|
||||
));
|
||||
}
|
||||
let expiration =
|
||||
chrono::Utc::now() + chrono::Duration::hours(APP_EMBED_TOKEN_VALIDITY_HOURS);
|
||||
let mut scopes: Vec<String> = APP_EMBED_SCOPES.iter().map(|s| s.to_string()).collect();
|
||||
// Path-scoped read so the app can fetch its OWN definition (apps/get/p,
|
||||
// which the in-workspace sandboxed viewer uses) — but no other app's. The
|
||||
// public viewer fetches via apps_u/public_app and doesn't rely on this.
|
||||
scopes.push(format!("apps:read:{app_path}"));
|
||||
// Path-scoped run (NOT unqualified `apps:run`) so the token can only execute
|
||||
// THIS app's components: `execute_component` re-checks `apps:run:<path>` for
|
||||
// the requested app, so the token can't drive another app's runnables.
|
||||
scopes.push(format!("apps:run:{app_path}"));
|
||||
// A scope-restricted caller token must not bootstrap a broader-scoped
|
||||
// embed token (`create_token_internal` deliberately does not check this
|
||||
// itself). No-op for unscoped sessions — the normal embed flow.
|
||||
ensure_scopes_within_caller(authed, Some(&scopes))?;
|
||||
let token_config = NewToken::new(
|
||||
Some(format!("embed_app:{app_path}")),
|
||||
Some(expiration),
|
||||
None,
|
||||
Some(scopes),
|
||||
Some(w_id.to_string()),
|
||||
// Never let an embed token gain write capability the caller's own
|
||||
// session lacks.
|
||||
Some(authed.read_only),
|
||||
);
|
||||
let mut tx = db.begin().await?;
|
||||
let token = create_token_internal(&mut *tx, db, authed, token_config).await?;
|
||||
tx.commit().await?;
|
||||
Some((token, expiration))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
Ok(EmbedTokenResponse {
|
||||
token: token_and_exp.as_ref().map(|(t, _)| t.clone()),
|
||||
expiration: token_and_exp.map(|(_, e)| e),
|
||||
raw_app: false,
|
||||
sandbox: false,
|
||||
app_path: Some(app_path.to_string()),
|
||||
workspace_id: Some(w_id.to_string()),
|
||||
})
|
||||
}
|
||||
|
||||
/// Issue an embed token for a public app addressed by its (secret) share id.
|
||||
/// Mirrors the access check in [`get_public_app_by_secret`]: anonymous apps are
|
||||
/// reachable without auth, otherwise the caller must be logged in and have read
|
||||
/// access to the app.
|
||||
async fn get_app_embed_token(
|
||||
OptAuthed(opt_authed): OptAuthed,
|
||||
Extension(user_db): Extension<UserDB>,
|
||||
Extension(db): Extension<DB>,
|
||||
Path((w_id, secret)): Path<(String, String)>,
|
||||
) -> JsonResult<EmbedTokenResponse> {
|
||||
let id = get_id_from_secret(&db, &w_id, secret, None).await?;
|
||||
|
||||
let app = sqlx::query!(
|
||||
"SELECT a.path, a.policy::text as policy, a.versions[array_upper(a.versions, 1)] as version, av.raw_app as raw_app
|
||||
FROM app a JOIN app_version av ON av.id = a.versions[array_upper(a.versions, 1)]
|
||||
WHERE a.id = $1 AND a.workspace_id = $2",
|
||||
id,
|
||||
&w_id
|
||||
)
|
||||
.fetch_optional(&db)
|
||||
.await?;
|
||||
let app = not_found_if_none(app, "App", id.to_string())?;
|
||||
let raw_app = app.raw_app;
|
||||
let policy_str = app
|
||||
.policy
|
||||
.ok_or_else(|| Error::internal_err("App policy missing".to_string()))?;
|
||||
// Lenient field-level read instead of a strict `Policy` parse: a legacy app
|
||||
// whose stored policy predates newer required fields must still resolve to
|
||||
// its (unsandboxed) render here rather than erroring out of the viewer.
|
||||
let policy = parse_embed_policy(&policy_str)?;
|
||||
|
||||
let authed_for_token = if policy.anonymous_execution {
|
||||
// Anonymous app: still mint a scoped token if the viewer happens to be
|
||||
// logged in (so the app sees their identity), otherwise stay anonymous.
|
||||
opt_authed
|
||||
} else {
|
||||
let authed = opt_authed.ok_or_else(|| {
|
||||
Error::NotAuthorized(
|
||||
"App visibility does not allow public access and you are not logged in".to_string(),
|
||||
)
|
||||
})?;
|
||||
let mut tx = user_db.begin(&authed).await?;
|
||||
let is_visible = sqlx::query_scalar!(
|
||||
"SELECT EXISTS(SELECT 1 FROM app WHERE id = $1 AND workspace_id = $2)",
|
||||
id,
|
||||
&w_id
|
||||
)
|
||||
.fetch_one(&mut *tx)
|
||||
.await?;
|
||||
tx.commit().await?;
|
||||
if !is_visible.unwrap_or(false) {
|
||||
return Err(Error::NotAuthorized(
|
||||
"App visibility does not allow public access and you are logged in but you have no read-access to that app".to_string(),
|
||||
));
|
||||
}
|
||||
Some(authed)
|
||||
};
|
||||
|
||||
// The token is only consumed by the sandboxed low-code render. Raw apps
|
||||
// render single-iframe with the page credential (WIN-2006 Variant A), and
|
||||
// unsandboxed apps render same-origin with the viewer's own session — minting
|
||||
// for those would write a useless token row per view and, worse, could fail
|
||||
// the whole render for a scope-restricted caller (`ensure_scopes_within_caller`)
|
||||
// even though no token is needed. The access check above still gates
|
||||
// visibility in every case.
|
||||
let mut resp = if raw_app || !policy.sandbox {
|
||||
EmbedTokenResponse {
|
||||
token: None,
|
||||
expiration: None,
|
||||
raw_app,
|
||||
sandbox: policy.sandbox,
|
||||
app_path: None,
|
||||
workspace_id: None,
|
||||
}
|
||||
} else {
|
||||
mint_app_embed_token(&db, &w_id, &app.path, authed_for_token.as_ref()).await?
|
||||
};
|
||||
resp.raw_app = raw_app;
|
||||
resp.sandbox = policy.sandbox;
|
||||
resp.app_path = Some(app.path);
|
||||
resp.workspace_id = Some(w_id.to_string());
|
||||
Ok(Json(resp))
|
||||
}
|
||||
|
||||
/// Minimal, lenient view of an app policy for the embed-token endpoints
|
||||
/// (WIN-2006). Reads only the fields the sandbox decision needs, via
|
||||
/// `serde_json::Value`, so a legacy policy that no longer satisfies the strict
|
||||
/// [`Policy`] struct (e.g. `triggerables_v2` entries predating now-required
|
||||
/// fields) still renders instead of failing the viewer with "Not found".
|
||||
/// A missing/unknown `execution_mode` is treated as NOT anonymous — the
|
||||
/// strictest access interpretation.
|
||||
pub struct EmbedPolicyView {
|
||||
pub anonymous_execution: bool,
|
||||
pub sandbox: bool,
|
||||
}
|
||||
|
||||
pub fn parse_embed_policy(policy_str: &str) -> Result<EmbedPolicyView> {
|
||||
let v: serde_json::Value = serde_json::from_str(policy_str).map_err(to_anyhow)?;
|
||||
Ok(EmbedPolicyView {
|
||||
anonymous_execution: v.get("execution_mode").and_then(|m| m.as_str()) == Some("anonymous"),
|
||||
sandbox: v.get("sandbox").and_then(|b| b.as_bool()).unwrap_or(false),
|
||||
})
|
||||
}
|
||||
|
||||
/// Authenticated, path-based embed token for the in-workspace app viewer
|
||||
/// (WIN-2006). Mirrors [`get_app_embed_token`] but keyed by app path and gated by
|
||||
/// the caller's read access (RLS), so the logged-in `/apps/get` viewer can render
|
||||
/// the app sandboxed — isolated from the member's full session — using the same
|
||||
/// scoped token. Raw apps get no token (single-iframe with the page credential).
|
||||
async fn get_app_embed_token_for_path(
|
||||
authed: ApiAuthed,
|
||||
Extension(user_db): Extension<UserDB>,
|
||||
Extension(db): Extension<DB>,
|
||||
Path((w_id, path)): Path<(String, StripPath)>,
|
||||
) -> JsonResult<EmbedTokenResponse> {
|
||||
let path = path.to_path();
|
||||
check_scopes(&authed, || format!("apps:read:{}", path))?;
|
||||
// RLS: the caller must have read access to this app, otherwise it's not found.
|
||||
let mut tx = user_db.begin(&authed).await?;
|
||||
let app = sqlx::query!(
|
||||
"SELECT a.policy::text as policy, a.versions[array_upper(a.versions, 1)] as version, av.raw_app as raw_app
|
||||
FROM app a JOIN app_version av ON av.id = a.versions[array_upper(a.versions, 1)]
|
||||
WHERE a.path = $1 AND a.workspace_id = $2",
|
||||
path,
|
||||
&w_id
|
||||
)
|
||||
.fetch_optional(&mut *tx)
|
||||
.await?;
|
||||
tx.commit().await?;
|
||||
let app = not_found_if_none(app, "App", path)?;
|
||||
let raw_app = app.raw_app;
|
||||
let policy_str = app
|
||||
.policy
|
||||
.ok_or_else(|| Error::internal_err("App policy missing".to_string()))?;
|
||||
// Lenient parse + mint only for the sandboxed low-code render — see
|
||||
// [`get_app_embed_token`] for the rationale (identical here).
|
||||
let policy = parse_embed_policy(&policy_str)?;
|
||||
|
||||
let mut resp = if raw_app || !policy.sandbox {
|
||||
EmbedTokenResponse {
|
||||
token: None,
|
||||
expiration: None,
|
||||
raw_app,
|
||||
sandbox: policy.sandbox,
|
||||
app_path: None,
|
||||
workspace_id: None,
|
||||
}
|
||||
} else {
|
||||
mint_app_embed_token(&db, &w_id, path, Some(&authed)).await?
|
||||
};
|
||||
resp.raw_app = raw_app;
|
||||
resp.sandbox = policy.sandbox;
|
||||
resp.app_path = Some(path.to_string());
|
||||
resp.workspace_id = Some(w_id.to_string());
|
||||
Ok(Json(resp))
|
||||
}
|
||||
|
||||
async fn get_id_from_secret(
|
||||
db: &DB,
|
||||
w_id: &str,
|
||||
@@ -2285,6 +2757,17 @@ async fn execute_component(
|
||||
Path((w_id, path)): Path<(String, StripPath)>,
|
||||
Json(mut payload): Json<ExecuteApp>,
|
||||
) -> Result<String> {
|
||||
let path = path.to_path();
|
||||
// Authorize FIRST, before touching the payload: confine the app embed token (the
|
||||
// only credential handed to untrusted app JS, carrying `apps:run:<own path>`) to
|
||||
// the app it was minted for. The route layer can't path-check the apps domain, so
|
||||
// enforce it here. Scoped to embed tokens only — other callers (anonymous, cookie,
|
||||
// plain external JWT) keep their existing access; the run is still policy-gated.
|
||||
if let Some(authed) = opt_authed.as_ref() {
|
||||
if windmill_api_auth::scopes::has_app_embed_sentinel(authed.scopes.as_deref()) {
|
||||
check_scopes(authed, || format!("apps:run:{}", path))?;
|
||||
}
|
||||
}
|
||||
// Only honor temp_script_refs for the inline-script preview path:
|
||||
// preview/editor mode (force_viewer_static_fields set, == `is_preview`),
|
||||
// raw_code present, and no deployed app_script id — i.e. `wmill app dev`.
|
||||
@@ -2307,7 +2790,6 @@ async fn execute_component(
|
||||
_ => {}
|
||||
};
|
||||
|
||||
let path = path.to_path();
|
||||
let (arc_policy, policy): (Arc<Policy>, Policy);
|
||||
let policy_triggerables_default = Default::default();
|
||||
// Preview mode means the request was issued from the editor; the editing
|
||||
@@ -2771,6 +3253,15 @@ async fn upload_s3_file_from_app(
|
||||
Query(query): Query<UploadFileToS3Query>,
|
||||
request: axum::extract::Request,
|
||||
) -> JsonResult<AppUploadFileResponse> {
|
||||
// Confine an app embed token (untrusted app JS) to uploading for its OWN app.
|
||||
// The route is reachable with `apps:run` (RUN_PATH_ACTIONS), so without this a
|
||||
// token minted for app A could drive app B's upload policy. Mirrors
|
||||
// execute_component / download_s3_file; other callers are unaffected.
|
||||
if let Some(authed) = opt_authed.as_ref() {
|
||||
if windmill_api_auth::scopes::has_app_embed_sentinel(authed.scopes.as_deref()) {
|
||||
check_scopes(authed, || format!("apps:run:{}", path.to_path()))?;
|
||||
}
|
||||
}
|
||||
let policy = if let Some(file_key_regex) = query.force_viewer_file_key_regex {
|
||||
// `force_viewer_*` lets the caller supply a synthetic upload policy that
|
||||
// bypasses the deployed app's file_key_regex / resource restrictions.
|
||||
@@ -2805,6 +3296,7 @@ async fn upload_s3_file_from_app(
|
||||
.unwrap_or_default(),
|
||||
}]),
|
||||
allowed_s3_keys: None,
|
||||
sandbox: None,
|
||||
})
|
||||
} else {
|
||||
let policy_o = sqlx::query_scalar!(
|
||||
@@ -3164,6 +3656,7 @@ async fn get_on_behalf_authed_from_app(
|
||||
on_behalf_of_email: None,
|
||||
s3_inputs: None,
|
||||
allowed_s3_keys: Some(force_allowed_s3_keys),
|
||||
sandbox: None,
|
||||
}
|
||||
} else {
|
||||
// TODO: improve db query to not return uneeded fields
|
||||
@@ -3186,6 +3679,7 @@ async fn get_on_behalf_authed_from_app(
|
||||
on_behalf_of_email: None,
|
||||
s3_inputs: None,
|
||||
allowed_s3_keys: None,
|
||||
sandbox: None,
|
||||
})
|
||||
};
|
||||
|
||||
@@ -3229,34 +3723,46 @@ async fn check_if_allowed_to_access_s3_file_from_app(
|
||||
return Err(Error::InternalErr(
|
||||
"Internal error: signature validation is not supported in open source mode".to_string(),
|
||||
));
|
||||
} else if opt_authed.is_some() {
|
||||
} else if opt_authed.as_ref().is_some_and(|authed| {
|
||||
!windmill_api_auth::scopes::has_app_embed_sentinel(authed.scopes.as_deref())
|
||||
}) {
|
||||
// A normal logged-in caller (editor / full session) may fetch any file they
|
||||
// can reach. An app embed token also carries an identity but represents
|
||||
// untrusted app JS, so it falls through to the allowlist below instead of
|
||||
// this bypass — otherwise the app could read arbitrary S3 keys the
|
||||
// viewer/on-behalf identity can see, beyond its own declared keys/outputs.
|
||||
Ok(())
|
||||
} else {
|
||||
let allowed = policy
|
||||
.allowed_s3_keys
|
||||
// Anonymous viewer, or an app embed token: confine to the app's declared S3
|
||||
// keys, or files produced by THIS app's own component runs. The producing
|
||||
// identity is the embed viewer for a token, else `anonymous`.
|
||||
let creator = opt_authed
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.any(|key| key.s3_path == file_query.s3 && key.storage == file_query.storage)
|
||||
|| {
|
||||
sqlx::query_scalar!(
|
||||
r#"SELECT EXISTS (
|
||||
.map(|authed| authed.username.clone())
|
||||
.unwrap_or_else(|| "anonymous".to_string());
|
||||
let allowed = policy.allowed_s3_keys.as_ref().is_some_and(|keys| {
|
||||
keys.iter()
|
||||
.any(|key| key.s3_path == file_query.s3 && key.storage == file_query.storage)
|
||||
}) || {
|
||||
sqlx::query_scalar!(
|
||||
r#"SELECT EXISTS (
|
||||
SELECT 1 FROM v2_job_completed c JOIN v2_job j USING (id)
|
||||
WHERE j.workspace_id = $2
|
||||
AND (j.kind = 'appscript' OR j.kind = 'preview')
|
||||
AND j.created_by = 'anonymous'
|
||||
AND j.created_by = $4
|
||||
AND c.started_at > now() - interval '3 hours'
|
||||
AND j.runnable_path LIKE $3 || '/%'
|
||||
AND c.result @> ('{"s3":"' || $1 || '"}')::jsonb
|
||||
)"#,
|
||||
file_query.s3,
|
||||
w_id,
|
||||
path,
|
||||
)
|
||||
.fetch_one(db)
|
||||
.await?
|
||||
.unwrap_or(false)
|
||||
};
|
||||
file_query.s3,
|
||||
w_id,
|
||||
path,
|
||||
creator,
|
||||
)
|
||||
.fetch_one(db)
|
||||
.await?
|
||||
.unwrap_or(false)
|
||||
};
|
||||
|
||||
if !allowed {
|
||||
Err(Error::BadRequest("File restricted".to_string()))
|
||||
@@ -3295,6 +3801,15 @@ async fn download_s3_file_from_app(
|
||||
|
||||
let path = path.to_path();
|
||||
|
||||
// Authorize the app path first: a scoped caller (notably an app embed token,
|
||||
// which carries `apps:read:<own path>`) may only download files for the app it
|
||||
// was minted for — otherwise it could read another app's S3 files via that app's
|
||||
// on-behalf policy. Unscoped sessions / anonymous callers pass through (the
|
||||
// latter still gated by the policy allowlist in `check_if_allowed_...`).
|
||||
if let Some(authed) = opt_authed.as_ref() {
|
||||
check_scopes(authed, || format!("apps:read:{}", path))?;
|
||||
}
|
||||
|
||||
let force_viewer_allowed_s3_keys = if let Some(force_viewer_allowed_s3_keys) =
|
||||
query.force_viewer_allowed_s3_keys.clone()
|
||||
{
|
||||
@@ -3571,3 +4086,253 @@ async fn build_args(
|
||||
job_id,
|
||||
))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod embed_token_tests {
|
||||
use super::APP_EMBED_SCOPES;
|
||||
use windmill_api_auth::scopes::check_scopes_for_route;
|
||||
|
||||
/// The embed token must reach exactly the endpoints an app needs and nothing
|
||||
/// else. This locks the allow/deny matrix that confines a malicious or
|
||||
/// compromised app to app-only routes (WIN-2006).
|
||||
#[test]
|
||||
fn embed_scopes_allow_app_routes_and_deny_the_rest() {
|
||||
let mut scopes: Vec<String> = APP_EMBED_SCOPES.iter().map(|s| s.to_string()).collect();
|
||||
// Mirror mint_app_embed_token: the per-app path-scoped read + run.
|
||||
scopes.push("apps:read:u/admin/app".to_string());
|
||||
scopes.push("apps:run:u/admin/app".to_string());
|
||||
let scopes = Some(scopes.as_slice());
|
||||
|
||||
// Allowed: the routes a running app legitimately calls.
|
||||
let allowed = [
|
||||
// Own definition + the public app-serving / execution endpoints.
|
||||
("/api/w/test/apps/get/p/u/admin/app", "GET"),
|
||||
("/api/w/test/apps_u/public_app/secret", "GET"),
|
||||
("/api/w/test/apps_u/get_data/v/secret.js", "GET"),
|
||||
("/api/w/test/apps_u/public_resource/f/app_themes/t", "GET"),
|
||||
("/api/w/test/apps_u/execute_component/u/admin/app", "POST"),
|
||||
// S3 file upload from the app's S3 File Input component: a `run` action
|
||||
// (RUN_PATH_ACTIONS) so the embed token reaches it; the handler re-checks
|
||||
// `apps:run:<path>` to confine it to this app, like execute_component.
|
||||
("/api/w/test/apps_u/upload_s3_file/u/admin/app", "POST"),
|
||||
// By-id job poll routes (the JobLoader surface) stay allowed.
|
||||
("/api/w/test/jobs_u/get/some-uuid", "GET"),
|
||||
("/api/w/test/jobs_u/getupdate/some-uuid", "GET"),
|
||||
("/api/w/test/jobs_u/getupdate_sse/some-uuid", "GET"),
|
||||
("/api/w/test/jobs_u/completed/get_result/some-uuid", "GET"),
|
||||
("/api/w/test/jobs_u/completed/get_timing/some-uuid", "GET"),
|
||||
// By-id cancel (POST): permitted at the route layer; the handler confines
|
||||
// it to the app's own jobs (created_by == viewer).
|
||||
("/api/w/test/jobs_u/queue/cancel/some-uuid", "POST"),
|
||||
("/api/w/test/users/whoami", "GET"),
|
||||
// Resource METADATA only (picker list + type schemas) — never values.
|
||||
("/api/w/test/resources/list", "GET"),
|
||||
("/api/w/test/resources/exists/u/admin/r", "GET"),
|
||||
("/api/w/test/resources/type/list", "GET"),
|
||||
("/api/w/test/folders/listnames", "GET"),
|
||||
];
|
||||
for (path, method) in allowed {
|
||||
assert!(
|
||||
check_scopes_for_route(scopes, path, method).is_ok(),
|
||||
"embed token should allow {method} {path}"
|
||||
);
|
||||
}
|
||||
|
||||
// Denied: anything outside what an app needs, including app management
|
||||
// (apps:write is intentionally withheld), resource VALUE reads (which can
|
||||
// hold credentials), and other workspace domains.
|
||||
let denied = [
|
||||
("/api/w/test/apps/update/u/admin/app", "POST"),
|
||||
("/api/w/test/apps/delete/u/admin/app", "DELETE"),
|
||||
// Workspace app inventory must NOT be reachable (Apps domain is
|
||||
// default-denied for the embed sentinel; only own-def + apps_u/* allowed).
|
||||
("/api/w/test/apps/exists/u/admin/app", "GET"),
|
||||
("/api/w/test/apps/custom_path_exists/foo", "GET"),
|
||||
(
|
||||
"/api/w/test/apps/list_paths_from_workspace_runnable/script/u/admin/x",
|
||||
"GET",
|
||||
),
|
||||
("/api/w/test/apps/list", "GET"),
|
||||
// The embed-token MINT endpoints are public app routes (`apps_u/`) but
|
||||
// create credentials — denied so a captured embed token can't renew
|
||||
// itself indefinitely past the 12h expiry (refresh is the embedder's job).
|
||||
("/api/w/test/apps_u/embed_token/secret", "GET"),
|
||||
("/api/w/test/apps_u/embed_token_by_custom_path/foo", "GET"),
|
||||
("/api/w/test/scripts/list", "GET"),
|
||||
("/api/w/test/variables/list", "GET"),
|
||||
("/api/w/test/resources/update/u/admin/r", "POST"),
|
||||
// Resource value reads must NOT be reachable with the embed token.
|
||||
("/api/w/test/resources/get/u/admin/r", "GET"),
|
||||
("/api/w/test/resources/get_value/u/admin/r", "GET"),
|
||||
(
|
||||
"/api/w/test/resources/get_value_interpolated/u/admin/r",
|
||||
"GET",
|
||||
),
|
||||
("/api/w/test/resources/list_search", "GET"),
|
||||
// Workspace-wide job enumeration/export must NOT be reachable — an app
|
||||
// reads only jobs it launched, by id (blocked via the app_embed sentinel).
|
||||
("/api/w/test/jobs/list", "GET"),
|
||||
("/api/w/test/jobs/list_filtered_uuids", "GET"),
|
||||
("/api/w/test/jobs/completed/list", "GET"),
|
||||
("/api/w/test/jobs/completed/export", "GET"),
|
||||
("/api/w/test/jobs/queue/list", "GET"),
|
||||
("/api/w/test/jobs/queue/list_filtered_uuids", "GET"),
|
||||
("/api/w/test/jobs/queue/export", "GET"),
|
||||
// Job counts (workspace-wide aggregates) and the capability-minting
|
||||
// routes (signed resume/approval URLs) are NOT by-id polling — denied.
|
||||
("/api/w/test/jobs/completed/count", "GET"),
|
||||
("/api/w/test/jobs/completed/count_jobs", "GET"),
|
||||
("/api/w/test/jobs/queue/count", "GET"),
|
||||
("/api/w/test/jobs/job_signature/some-uuid/some-rid", "GET"),
|
||||
("/api/w/test/jobs/resume_urls/some-uuid/some-rid", "GET"),
|
||||
// get_root_job_id has no access check in its handler and the app never
|
||||
// calls it — denied so the token can't probe foreign jobs' flow lineage.
|
||||
("/api/w/test/jobs_u/get_root_job_id/some-uuid", "GET"),
|
||||
// `users:read`/`folders:read` exist only for whoami/listnames — every
|
||||
// other route in those domains is denied via the app_embed sentinel
|
||||
// (the whole /users and /folders routers are CORS-enabled for the iframe).
|
||||
("/api/w/test/users/list", "GET"),
|
||||
("/api/w/test/users/list_usage", "GET"),
|
||||
("/api/w/test/users/username_to_email/admin", "GET"),
|
||||
("/api/w/test/folders/list", "GET"),
|
||||
("/api/w/test/folders/get/myfolder", "GET"),
|
||||
("/api/w/test/folders/getusage/myfolder", "GET"),
|
||||
];
|
||||
for (path, method) in denied {
|
||||
assert!(
|
||||
check_scopes_for_route(scopes, path, method).is_err(),
|
||||
"embed token should deny {method} {path}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// `apps:run` satisfies read at the route layer, so `apps/list` / `apps/list_search`
|
||||
/// pass the route check — that's why those handlers ALSO call
|
||||
/// `check_scopes(apps:read)`, which uses `ScopeDefinition::includes` (where run
|
||||
/// does NOT include read). Lock that: no embed scope, including the
|
||||
/// dynamically-minted path-scoped read, satisfies a domain-level `apps:read`, so
|
||||
/// the token cannot list all apps' definitions (their full `value`/code).
|
||||
#[test]
|
||||
fn embed_scopes_cannot_satisfy_domain_app_read() {
|
||||
use windmill_api_auth::scopes::ScopeDefinition;
|
||||
let mut scopes: Vec<String> = APP_EMBED_SCOPES.iter().map(|s| s.to_string()).collect();
|
||||
// mint_app_embed_token also grants read scoped to the single app path:
|
||||
scopes.push("apps:read:u/admin/app".to_string());
|
||||
let required = ScopeDefinition::from_scope_string("apps:read").unwrap();
|
||||
for s in &scopes {
|
||||
// The `app_embed` sentinel intentionally doesn't parse as a domain:action
|
||||
// scope (it grants nothing; it only drives the job-enumeration deny).
|
||||
let Ok(def) = ScopeDefinition::from_scope_string(s) else {
|
||||
continue;
|
||||
};
|
||||
assert!(
|
||||
!def.includes(&required),
|
||||
"embed scope {s} must not satisfy domain-level apps:read (would leak apps/list[_search])"
|
||||
);
|
||||
}
|
||||
// Sanity: a genuine domain-level apps:read token does satisfy it.
|
||||
assert!(ScopeDefinition::from_scope_string("apps:read")
|
||||
.unwrap()
|
||||
.includes(&required));
|
||||
}
|
||||
|
||||
/// The token carries path-scoped `apps:run:<own path>` and `apps:read:<own path>`
|
||||
/// (NOT unqualified `apps:run`). Every handler that resolves an app and acts on
|
||||
/// its behalf re-checks the requested path via `ScopeDefinition::includes`, so the
|
||||
/// token is confined to its OWN app:
|
||||
/// - `apps:run:<path>` — `execute_component`.
|
||||
/// - `apps:read:<path>` — `get_app` (apps/get/p), `get_public_app_by_secret`,
|
||||
/// the EE custom-path `get_public_app_by_custom_path`, and
|
||||
/// `download_s3_file_from_app`.
|
||||
/// This blocks cross-app execution, definition reads (by secret / custom path),
|
||||
/// and S3 file reads through another app's on-behalf policy.
|
||||
#[test]
|
||||
fn embed_run_scope_is_path_scoped_to_its_app() {
|
||||
use windmill_api_auth::scopes::ScopeDefinition;
|
||||
// The mint must not grant unqualified run (which would include any path).
|
||||
assert!(
|
||||
!APP_EMBED_SCOPES.contains(&"apps:run"),
|
||||
"embed scopes must not include unqualified apps:run"
|
||||
);
|
||||
for action in ["run", "read"] {
|
||||
let own =
|
||||
ScopeDefinition::from_scope_string(&format!("apps:{action}:u/admin/app")).unwrap();
|
||||
assert!(
|
||||
own.includes(
|
||||
&ScopeDefinition::from_scope_string(&format!("apps:{action}:u/admin/app"))
|
||||
.unwrap()
|
||||
),
|
||||
"apps:{action} must grant its own app"
|
||||
);
|
||||
assert!(
|
||||
!own.includes(
|
||||
&ScopeDefinition::from_scope_string(&format!("apps:{action}:u/admin/other"))
|
||||
.unwrap()
|
||||
),
|
||||
"apps:{action} must NOT grant another app (cross-app)"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// `mint_app_embed_token` guards its `create_token_internal` call with
|
||||
/// `ensure_scopes_within_caller`, so a scope-restricted bearer token cannot
|
||||
/// bootstrap the broader embed-scope set. Lock that boundary on the exact
|
||||
/// scope vec the mint builds: rejected for a path-scoped caller, no-op for
|
||||
/// the unscoped browser session that is the normal embed flow.
|
||||
#[test]
|
||||
fn embed_token_mint_is_scope_bounded() {
|
||||
use windmill_api_auth::{ensure_scopes_within_caller, ApiAuthed};
|
||||
|
||||
// Same scope set mint_app_embed_token assembles for an app.
|
||||
let mut minted: Vec<String> = APP_EMBED_SCOPES.iter().map(|s| s.to_string()).collect();
|
||||
minted.push("apps:read:u/admin/app".to_string());
|
||||
|
||||
// A caller restricted to a single app read must not widen to the full
|
||||
// embed set (apps:run, jobs:read, resources:read, ...).
|
||||
let restricted = ApiAuthed {
|
||||
scopes: Some(vec!["apps:read:u/admin/app".to_string()]),
|
||||
..Default::default()
|
||||
};
|
||||
assert!(
|
||||
ensure_scopes_within_caller(&restricted, Some(&minted)).is_err(),
|
||||
"a path-scoped caller must not mint the broader embed-scope set"
|
||||
);
|
||||
|
||||
// An unscoped session (the normal embed flow) passes — the mint only
|
||||
// narrows.
|
||||
let unscoped = ApiAuthed { scopes: None, ..Default::default() };
|
||||
assert!(ensure_scopes_within_caller(&unscoped, Some(&minted)).is_ok());
|
||||
}
|
||||
|
||||
/// The embed-token endpoints must keep working for legacy apps whose stored
|
||||
/// policy no longer satisfies the strict `Policy` struct (pre-dating
|
||||
/// now-required fields): `parse_embed_policy` reads only the sandbox-decision
|
||||
/// fields, leniently, and treats a missing/unknown `execution_mode` as NOT
|
||||
/// anonymous (the strictest access interpretation).
|
||||
#[test]
|
||||
fn embed_policy_parse_is_lenient() {
|
||||
use super::parse_embed_policy;
|
||||
|
||||
// Quirky legacy policy: triggerables_v2 entry missing required fields,
|
||||
// no execution_mode at all — must still parse, and absent `sandbox`
|
||||
// resolves to the unsandboxed default.
|
||||
let p = parse_embed_policy(r#"{"triggerables_v2": {"x": {}}}"#).unwrap();
|
||||
assert!(!p.sandbox);
|
||||
assert!(
|
||||
!p.anonymous_execution,
|
||||
"missing execution_mode must not grant anonymous access"
|
||||
);
|
||||
|
||||
// Normal policies map field-for-field.
|
||||
let p = parse_embed_policy(r#"{"execution_mode": "anonymous", "sandbox": true}"#).unwrap();
|
||||
assert!(p.anonymous_execution);
|
||||
assert!(p.sandbox);
|
||||
|
||||
// Unknown execution_mode value: lenient parse, but not anonymous.
|
||||
let p = parse_embed_policy(r#"{"execution_mode": "weird"}"#).unwrap();
|
||||
assert!(!p.anonymous_execution);
|
||||
|
||||
// Invalid JSON still errors.
|
||||
assert!(parse_embed_policy("not json").is_err());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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-*`
|
||||
|
||||
@@ -546,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())
|
||||
@@ -566,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())
|
||||
@@ -616,14 +630,23 @@ pub async fn run_server(
|
||||
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())
|
||||
@@ -729,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"))]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
};
|
||||
|
||||
@@ -1082,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);
|
||||
@@ -1513,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());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.736.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.736.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.736.0",
|
||||
"version": "1.737.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@windmill-labs/components",
|
||||
"version": "1.736.0",
|
||||
"version": "1.737.0",
|
||||
"hasInstallScript": true,
|
||||
"license": "AGPL-3.0",
|
||||
"dependencies": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@windmill-labs/components",
|
||||
"version": "1.736.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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,91 +1,30 @@
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation'
|
||||
/*
|
||||
* WIN-2006: in-workspace low-code app viewer. Thin wrapper over the shared
|
||||
* InWorkspaceAppViewer, which renders the app sandboxed (opaque iframe / scoped
|
||||
* token) through the same machinery as the public viewer. Raw apps use the
|
||||
* sibling /apps_raw/get route, which wraps the same component.
|
||||
*/
|
||||
import { base } from '$lib/base'
|
||||
import AppPreview from '$lib/components/apps/editor/AppPreview.svelte'
|
||||
import type { EditorBreakpoint } from '$lib/components/apps/types'
|
||||
|
||||
import { Button, Skeleton } from '$lib/components/common'
|
||||
import { AppService, type AppWithLastVersion } from '$lib/gen'
|
||||
import { userStore, workspaceStore } from '$lib/stores'
|
||||
import { canWrite, urlParamsToObject } from '$lib/utils'
|
||||
import { Pen } from 'lucide-svelte'
|
||||
import { writable } from 'svelte/store'
|
||||
import { twMerge } from 'tailwind-merge'
|
||||
import InWorkspaceAppViewer from '$lib/components/apps/editor/InWorkspaceAppViewer.svelte'
|
||||
import { Skeleton } from '$lib/components/common'
|
||||
import { workspaceStore } from '$lib/stores'
|
||||
import { page } from '$app/state'
|
||||
|
||||
let app: (AppWithLastVersion & { value: any }) | undefined = $state(undefined)
|
||||
let can_write = $state(false)
|
||||
|
||||
async function loadApp() {
|
||||
app = await AppService.getAppLiteByPath({
|
||||
workspace: $workspaceStore!,
|
||||
path: page.params.path ?? ''
|
||||
})
|
||||
can_write = canWrite(app?.path, app?.extra_perms!, $userStore)
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
if ($workspaceStore && page.params.path) {
|
||||
if (app && page.params.path === app.path) {
|
||||
console.log('App already loaded')
|
||||
} else {
|
||||
loadApp()
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const breakpoint = writable<EditorBreakpoint>('lg')
|
||||
|
||||
const hideRefreshBar = page.url.searchParams.get('hideRefreshBar') === 'true'
|
||||
const hideEditBtn = page.url.searchParams.get('hideEditBtn') === 'true'
|
||||
let workspace = $derived($workspaceStore ?? '')
|
||||
let path = $derived(page.params.path ?? '')
|
||||
</script>
|
||||
|
||||
{#if app}
|
||||
{#key app}
|
||||
<div
|
||||
class={twMerge(
|
||||
'min-h-screen h-full w-full flex flex-col',
|
||||
app?.value.css?.['app']?.['viewer']?.class,
|
||||
'wm-app-viewer'
|
||||
)}
|
||||
style={app?.value.css?.['app']?.['viewer']?.style}
|
||||
>
|
||||
<AppPreview
|
||||
context={{
|
||||
email: $userStore?.email,
|
||||
name: $userStore?.name,
|
||||
username: $userStore?.username,
|
||||
groups: $userStore?.groups,
|
||||
query: urlParamsToObject(page.url.searchParams, { stripReserved: true }),
|
||||
hash: page.url.hash.substring(1)
|
||||
}}
|
||||
workspace={$workspaceStore ?? ''}
|
||||
summary={app.summary}
|
||||
app={app.value}
|
||||
appPath={page.params.path}
|
||||
{breakpoint}
|
||||
policy={app.policy}
|
||||
isEditor={false}
|
||||
noBackend={false}
|
||||
{hideRefreshBar}
|
||||
replaceStateFn={(path) => {
|
||||
goto(path)
|
||||
}}
|
||||
gotoFn={(path, opt) => {
|
||||
goto(path, opt)
|
||||
}}
|
||||
/>
|
||||
{#if can_write && !hideEditBtn}
|
||||
<div id="app-edit-btn" class="absolute bottom-4 z-50 right-4">
|
||||
<Button
|
||||
size="sm"
|
||||
startIcon={{ icon: Pen }}
|
||||
variant="subtle"
|
||||
href="{base}/apps/edit/{app.path}">Edit</Button
|
||||
>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
<!-- Wait for the active workspace before mounting the embedder: it's needed to
|
||||
mint the token and to build the viewer iframe URL, and the store is set
|
||||
asynchronously by the (logged) layout. -->
|
||||
{#if workspace && path}
|
||||
<!-- Key by target: SvelteKit reuses this page component on in-route
|
||||
navigation (e.g. a navbar item linking to another app), so the viewer
|
||||
must fully remount — otherwise the previous app (and in sandbox mode its
|
||||
path-scoped token) sticks around. -->
|
||||
{#key `${workspace}/${path}`}
|
||||
<InWorkspaceAppViewer {workspace} {path} editHref="{base}/apps/edit/{path}?nodraft=true" />
|
||||
{/key}
|
||||
{:else}
|
||||
<Skeleton layout={[10]} />
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
export function load({ params }) {
|
||||
return {
|
||||
stuff: { title: `App ${params.path}` }
|
||||
}
|
||||
}
|
||||
@@ -1,41 +1,17 @@
|
||||
<script lang="ts">
|
||||
import { Skeleton } from '$lib/components/common'
|
||||
import { userStore, workspaceStore } from '$lib/stores'
|
||||
import { sendUserToast } from '$lib/toast'
|
||||
import { onDestroy, onMount } from 'svelte'
|
||||
import { goto } from '$app/navigation'
|
||||
import { base } from '$app/paths'
|
||||
import { page } from '$app/state'
|
||||
let loaded = $state(false)
|
||||
import { onMount } from 'svelte'
|
||||
|
||||
onMount(async () => {
|
||||
globalThis.windmill = {
|
||||
username: $userStore?.username,
|
||||
email: $userStore?.email,
|
||||
workspace: $workspaceStore
|
||||
}
|
||||
|
||||
// //@ts-ignore
|
||||
// await import('http://localhost:3000/app.iife.js')
|
||||
/* @vite-ignore */
|
||||
await import(
|
||||
/* webpackIgnore: true */
|
||||
`/api/w/${$workspaceStore}/raw_apps/get_data/${page.params.version}/${page.params.path}`
|
||||
)
|
||||
try {
|
||||
globalThis.render()
|
||||
} catch (e) {
|
||||
sendUserToast('App seem to be ill-defined', true)
|
||||
console.error(e)
|
||||
}
|
||||
loaded = true
|
||||
})
|
||||
|
||||
onDestroy(() => {
|
||||
globalThis.windmill = undefined
|
||||
// WIN-2006: the old same-origin raw-app viewer (`/apps/get_raw/{version}/{path}`,
|
||||
// which imported the bundle into the page with no isolation) was removed in favor
|
||||
// of the sandboxed unified viewer. Redirect stale bookmarks to the new route,
|
||||
// preserving query + hash. The pinned `version` is dropped — the unified viewer
|
||||
// always shows the latest, like every other in-workspace app link. Done in the
|
||||
// component (not a load redirect) because `url.hash` is unavailable in `load`.
|
||||
onMount(() => {
|
||||
const path = page.params.path ?? ''
|
||||
goto(`${base}/apps_raw/get/${path}${page.url.search}${page.url.hash}`, { replaceState: true })
|
||||
})
|
||||
</script>
|
||||
|
||||
<div id="root"></div>
|
||||
|
||||
{#if !loaded}
|
||||
<Skeleton layout={[10]} />
|
||||
{/if}
|
||||
|
||||
@@ -1,65 +1,26 @@
|
||||
<script lang="ts">
|
||||
import { base } from '$app/paths'
|
||||
import { Button, Skeleton } from '$lib/components/common'
|
||||
import { AppService, type AppWithLastVersion } from '$lib/gen'
|
||||
import { userStore, workspaceStore } from '$lib/stores'
|
||||
import { canWrite } from '$lib/utils'
|
||||
import { Pen } from 'lucide-svelte'
|
||||
import RawAppPreview from '$lib/components/raw_apps/RawAppPreview.svelte'
|
||||
/*
|
||||
* WIN-2006: in-workspace raw app viewer. Thin wrapper over the shared
|
||||
* InWorkspaceAppViewer (same component as the low-code /apps/get route) so raw
|
||||
* apps get the identical sandbox behavior. PublicAppFrame renders raw apps
|
||||
* inline with the bundle isolated in RawAppPreview's own opaque iframe.
|
||||
*/
|
||||
import { base } from '$lib/base'
|
||||
import InWorkspaceAppViewer from '$lib/components/apps/editor/InWorkspaceAppViewer.svelte'
|
||||
import { Skeleton } from '$lib/components/common'
|
||||
import { workspaceStore } from '$lib/stores'
|
||||
import { page } from '$app/state'
|
||||
import type { Runnable } from '$lib/components/raw_apps/rawAppPolicy'
|
||||
|
||||
const hideEditBtn = page.url.searchParams.get('hideEditBtn') === 'true'
|
||||
|
||||
let app = $state(undefined) as AppWithLastVersion | undefined
|
||||
|
||||
let secret = $state(undefined) as string | undefined
|
||||
async function loadApp() {
|
||||
console.log('Loading app')
|
||||
app = await AppService.getAppLiteByPath({
|
||||
workspace: $workspaceStore!,
|
||||
path: page.params.path ?? ''
|
||||
})
|
||||
}
|
||||
|
||||
async function loadSecret() {
|
||||
secret = await AppService.getPublicSecretOfLatestVersionOfApp({
|
||||
workspace: $workspaceStore!,
|
||||
path: page.params.path ?? ''
|
||||
})
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
$workspaceStore && loadApp()
|
||||
$workspaceStore && loadSecret()
|
||||
})
|
||||
|
||||
let can_write = $derived(canWrite(page.params.path ?? '', app?.extra_perms ?? {}, $userStore))
|
||||
function getRunnables(app: AppWithLastVersion) {
|
||||
return ((app?.value as any)?.runnables ?? {}) as Record<string, Runnable>
|
||||
}
|
||||
let workspace = $derived($workspaceStore ?? '')
|
||||
let path = $derived(page.params.path ?? '')
|
||||
</script>
|
||||
|
||||
<div class="h-full min-h-[600px] w-full relative p-2bg-white">
|
||||
{#if !$workspaceStore || !$userStore || !app}
|
||||
<Skeleton layout={[10]} />
|
||||
{:else}
|
||||
<RawAppPreview
|
||||
path={page.params.path ?? ''}
|
||||
workspace={$workspaceStore}
|
||||
user={$userStore}
|
||||
runnables={getRunnables(app)}
|
||||
{secret}
|
||||
/>
|
||||
{/if}
|
||||
{#if can_write && !hideEditBtn}
|
||||
<div id="app-edit-btn" class="absolute bottom-4 z-50 right-4">
|
||||
<Button
|
||||
size="sm"
|
||||
startIcon={{ icon: Pen }}
|
||||
variant="subtle"
|
||||
href="{base}/apps_raw/edit/{page.params.path}">Edit</Button
|
||||
>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{#if workspace && path}
|
||||
<!-- Key by target: SvelteKit reuses this page component on in-route
|
||||
navigation, so the viewer must fully remount (see /apps/get). -->
|
||||
{#key `${workspace}/${path}`}
|
||||
<InWorkspaceAppViewer {workspace} {path} editHref="{base}/apps_raw/edit/{path}?nodraft=true" />
|
||||
{/key}
|
||||
{:else}
|
||||
<Skeleton layout={[10]} />
|
||||
{/if}
|
||||
|
||||
@@ -3,14 +3,15 @@
|
||||
|
||||
import { AppService, OpenAPI, type AppWithLastVersion } from '$lib/gen'
|
||||
import { userStore, workspaceStore } from '$lib/stores'
|
||||
import { sendUserToast } from '$lib/toast'
|
||||
|
||||
import { setContext } from 'svelte'
|
||||
import { setLicense } from '$lib/enterpriseUtils'
|
||||
|
||||
import { getUserExt } from '$lib/user'
|
||||
import { sendUserToast } from '$lib/toast'
|
||||
import { page } from '$app/state'
|
||||
import { base } from '$lib/base'
|
||||
import PublicApp from '$lib/components/apps/editor/PublicApp.svelte'
|
||||
import PublicAppFrame from '$lib/components/apps/editor/PublicAppFrame.svelte'
|
||||
|
||||
let app: (AppWithLastVersion & { value: any }) | undefined = $state(undefined)
|
||||
let notExists = $state(false)
|
||||
@@ -44,16 +45,44 @@
|
||||
}
|
||||
}
|
||||
|
||||
let workspace: string | undefined = $state(undefined)
|
||||
async function loadApp() {
|
||||
const parsedCustomPath = parseCustomPath(page.params.path ?? '')
|
||||
const parsedCustomPath = parseCustomPath(page.params.path ?? '')
|
||||
|
||||
// URL for the opaque viewer iframe: the custom-path URL WITHOUT the trailing
|
||||
// JWT segment. The JWT is a viewer credential (broader and longer-lived than
|
||||
// the scoped embed token) consumed here on the embedder side only — it must
|
||||
// never appear in the iframe's own location, where app-authored code could
|
||||
// read it. Captured once (not reactively): the embedder mirrors the app's
|
||||
// hash/query back onto this page's URL, and re-deriving the src from it would
|
||||
// reload the app on its every navigation.
|
||||
const viewerUrl = `${base}/a/${parsedCustomPath.path}${page.url.search}${page.url.hash}`
|
||||
|
||||
let workspace: string | undefined = $state(undefined)
|
||||
let refresh: (() => void) | undefined
|
||||
|
||||
// Embedder side: validate access (main session cookie or shared JWT) and mint
|
||||
// a scoped embed token for the opaque iframe (WIN-2006).
|
||||
async function fetchEmbedToken(): Promise<{ token?: string }> {
|
||||
if (parsedCustomPath.jwt) {
|
||||
const token = 'jwt_ext_' + parsedCustomPath.jwt
|
||||
OpenAPI.TOKEN = token
|
||||
setContext<{ token?: string }>('AuthToken', { token })
|
||||
jwtError = false
|
||||
OpenAPI.TOKEN = 'jwt_ext_' + parsedCustomPath.jwt
|
||||
}
|
||||
const headers: Record<string, string> = {}
|
||||
if (typeof OpenAPI.TOKEN === 'string' && OpenAPI.TOKEN) {
|
||||
headers['Authorization'] = `Bearer ${OpenAPI.TOKEN}`
|
||||
}
|
||||
const res = await fetch(
|
||||
`${OpenAPI.BASE}/apps_u/embed_token_by_custom_path/${parsedCustomPath.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: load the app + user using the embed token handed to the iframe.
|
||||
async function loadApp() {
|
||||
try {
|
||||
app = await AppService.getPublicAppByCustomPath({
|
||||
customPath: parsedCustomPath.path
|
||||
@@ -62,9 +91,13 @@
|
||||
workspaceStore.set(app.workspace_id)
|
||||
noPermission = false
|
||||
notExists = false
|
||||
jwtError = false
|
||||
|
||||
try {
|
||||
userStore.set(await getUserExt(app.workspace_id))
|
||||
// A JWT in the custom path that fails to resolve a user is surfaced as a
|
||||
// toast (matches the pre-sandbox custom-path viewer) rather than silently
|
||||
// falling through to anonymous.
|
||||
if (!$userStore && parsedCustomPath.jwt) {
|
||||
jwtError = true
|
||||
sendUserToast('Could not authentify user with jwt token', true)
|
||||
@@ -74,7 +107,8 @@
|
||||
}
|
||||
} catch (e) {
|
||||
if (e.status == 401) {
|
||||
noPermission = true
|
||||
// Embed token missing/expired — ask the embedder for a fresh one.
|
||||
refresh?.()
|
||||
} else {
|
||||
notExists = true
|
||||
}
|
||||
@@ -83,17 +117,25 @@
|
||||
|
||||
if (BROWSER) {
|
||||
setLicense()
|
||||
loadApp()
|
||||
}
|
||||
</script>
|
||||
|
||||
<PublicApp
|
||||
{workspace}
|
||||
{notExists}
|
||||
{noPermission}
|
||||
{jwtError}
|
||||
{app}
|
||||
onLoginSuccess={() => {
|
||||
<PublicAppFrame
|
||||
{fetchEmbedToken}
|
||||
{viewerUrl}
|
||||
onViewerReady={(_token, requestTokenRefresh) => {
|
||||
refresh = requestTokenRefresh
|
||||
loadApp()
|
||||
}}
|
||||
></PublicApp>
|
||||
>
|
||||
{#snippet viewer()}
|
||||
<PublicApp
|
||||
{workspace}
|
||||
{notExists}
|
||||
{noPermission}
|
||||
{jwtError}
|
||||
{app}
|
||||
onLoginSuccess={() => loadApp()}
|
||||
></PublicApp>
|
||||
{/snippet}
|
||||
</PublicAppFrame>
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
<script lang="ts">
|
||||
/*
|
||||
* WIN-2006: in-workspace app viewer, sandboxed. This is the private analog of
|
||||
* the public `/public/[workspace]/[...secret]` route — same `PublicAppFrame`
|
||||
* embedder/viewer + scoped-token machinery, but the app is addressed by path and
|
||||
* the token is minted from the logged-in member's session (cookie) via the
|
||||
* authenticated `apps/embed_token/p/{path}` endpoint. It lives outside `(logged)`
|
||||
* so the opaque (cookieless) viewer iframe can load it without the auth redirect
|
||||
* or the workspace chrome. `/apps/get` embeds this route in an opaque iframe.
|
||||
*/
|
||||
import { BROWSER } from 'esm-env'
|
||||
import { AppService, OpenAPI } from '$lib/gen'
|
||||
import { userStore, workspaceStore } from '$lib/stores'
|
||||
import { setLicense } from '$lib/enterpriseUtils'
|
||||
import { getUserExt } from '$lib/user'
|
||||
import { page } from '$app/state'
|
||||
import PublicApp from '$lib/components/apps/editor/PublicApp.svelte'
|
||||
import PublicAppFrame from '$lib/components/apps/editor/PublicAppFrame.svelte'
|
||||
|
||||
let app: any = $state(undefined)
|
||||
let notExists = $state(false)
|
||||
let noPermission = $state(false)
|
||||
|
||||
const workspace = page.params.workspace ?? ''
|
||||
const path = page.params.path ?? ''
|
||||
// Forwarded by InWorkspaceAppViewer from the member-facing page's query.
|
||||
const hideRefreshBar = page.url.searchParams.get('hideRefreshBar') === 'true'
|
||||
let refresh: (() => void) | undefined
|
||||
|
||||
// Embedder side: the logged-in member's session mints a scoped embed token for
|
||||
// the opaque iframe, isolating the in-workspace app from their full 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: load the app + user using the embed token handed to the iframe.
|
||||
async function loadApp() {
|
||||
// Set the workspace store so app job operations (notably JobLoader.cancelJob,
|
||||
// which reads $workspaceStore) target this workspace instead of an empty/stale
|
||||
// one in the cookieless iframe.
|
||||
workspaceStore.set(workspace)
|
||||
try {
|
||||
userStore.set(await getUserExt(workspace))
|
||||
} catch (e) {
|
||||
console.warn('Anonymous user')
|
||||
}
|
||||
try {
|
||||
app = await AppService.getAppByPath({ workspace, path })
|
||||
noPermission = false
|
||||
notExists = false
|
||||
} catch (e: any) {
|
||||
if (e.status == 401) {
|
||||
// Embed token missing/expired — ask the embedder for a fresh one.
|
||||
refresh?.()
|
||||
} else if (e.status == 403) {
|
||||
noPermission = true
|
||||
} else {
|
||||
notExists = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (BROWSER) {
|
||||
setLicense()
|
||||
}
|
||||
</script>
|
||||
|
||||
<PublicAppFrame
|
||||
{fetchEmbedToken}
|
||||
onViewerReady={(_token, requestTokenRefresh) => {
|
||||
refresh = requestTokenRefresh
|
||||
loadApp()
|
||||
}}
|
||||
>
|
||||
{#snippet viewer()}
|
||||
<PublicApp
|
||||
{app}
|
||||
{workspace}
|
||||
{notExists}
|
||||
{noPermission}
|
||||
jwtError={false}
|
||||
inWorkspace
|
||||
{hideRefreshBar}
|
||||
onLoginSuccess={() => loadApp()}
|
||||
></PublicApp>
|
||||
{/snippet}
|
||||
</PublicAppFrame>
|
||||
@@ -1,5 +0,0 @@
|
||||
export function load({ params }) {
|
||||
return {
|
||||
stuff: { title: `Public App` }
|
||||
}
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
<script lang="ts">
|
||||
// import { page } from '$app/state'
|
||||
// import RawAppPreview from '$lib/components/raw_apps/RawAppPreview.svelte'
|
||||
// import { userStore } from '$lib/stores'
|
||||
</script>
|
||||
|
||||
<!-- <RawAppPreview
|
||||
workspace={page.params.workspace}
|
||||
user={$userStore}
|
||||
version={Number(page.params.version)}
|
||||
/> -->
|
||||
@@ -4,21 +4,20 @@
|
||||
import { AppService, OpenAPI, type AppWithLastVersion } from '$lib/gen'
|
||||
import { userStore } from '$lib/stores'
|
||||
|
||||
import { setContext } from 'svelte'
|
||||
import { setLicense } from '$lib/enterpriseUtils'
|
||||
|
||||
import { getUserExt } from '$lib/user'
|
||||
import { sendUserToast } from '$lib/toast'
|
||||
import { page } from '$app/state'
|
||||
import { base } from '$lib/base'
|
||||
import PublicApp from '$lib/components/apps/editor/PublicApp.svelte'
|
||||
import PublicAppFrame from '$lib/components/apps/editor/PublicAppFrame.svelte'
|
||||
|
||||
let app: (AppWithLastVersion & { value: any }) | undefined = $state(undefined)
|
||||
let notExists = $state(false)
|
||||
let noPermission = $state(false)
|
||||
|
||||
let jwtError = $state(false)
|
||||
|
||||
function parseSecret(secret: string): { secret: string; jwt: string } {
|
||||
function parseSecret(secret: string): { secret: string; jwt: string | undefined } {
|
||||
const parts = secret.split('/')
|
||||
return {
|
||||
secret: parts[0],
|
||||
@@ -27,18 +26,59 @@
|
||||
}
|
||||
|
||||
const parsedSecret = parseSecret(page.params.secret ?? '')
|
||||
const workspace = page.params.workspace ?? ''
|
||||
|
||||
// URL for the opaque viewer iframe: the share URL WITHOUT the trailing JWT
|
||||
// segment. The JWT is a viewer credential (broader and longer-lived than the
|
||||
// scoped embed token) consumed here on the embedder side only — it must never
|
||||
// appear in the iframe's own location, where app-authored code could read it.
|
||||
// Captured once (not reactively): the embedder mirrors the app's hash/query
|
||||
// back onto this page's URL, and re-deriving the src from it would reload the
|
||||
// app on its every navigation.
|
||||
const viewerUrl = `${base}/public/${workspace}/${parsedSecret.secret}${page.url.search}${page.url.hash}`
|
||||
|
||||
let refresh: (() => void) | undefined
|
||||
|
||||
// Embedder side: validate access (using the main session cookie or the shared
|
||||
// JWT) and mint a scoped embed token for the opaque iframe (WIN-2006).
|
||||
async function fetchEmbedToken(): Promise<{ token?: string }> {
|
||||
if (parsedSecret.jwt) {
|
||||
OpenAPI.TOKEN = 'jwt_ext_' + parsedSecret.jwt
|
||||
}
|
||||
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_u/embed_token/${parsedSecret.secret}`,
|
||||
{ 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: load the app + user using the embed token handed to the iframe.
|
||||
async function loadApp() {
|
||||
try {
|
||||
userStore.set(await getUserExt(workspace))
|
||||
} catch (e) {
|
||||
console.warn('Anonymous user')
|
||||
}
|
||||
try {
|
||||
app = await AppService.getPublicAppBySecret({
|
||||
workspace: page.params.workspace ?? '',
|
||||
workspace,
|
||||
path: parsedSecret.secret
|
||||
})
|
||||
noPermission = false
|
||||
notExists = false
|
||||
} catch (e) {
|
||||
if (e.status == 401) {
|
||||
noPermission = true
|
||||
// Embed token missing/expired — ask the embedder for a fresh one.
|
||||
refresh?.()
|
||||
} else {
|
||||
notExists = true
|
||||
}
|
||||
@@ -47,42 +87,25 @@
|
||||
|
||||
if (BROWSER) {
|
||||
setLicense()
|
||||
loadAll()
|
||||
}
|
||||
|
||||
function loadAll() {
|
||||
console.log('loadAll')
|
||||
loadUser().then(() => {
|
||||
loadApp()
|
||||
})
|
||||
}
|
||||
|
||||
async function loadUser() {
|
||||
if (parsedSecret.jwt) {
|
||||
const token = 'jwt_ext_' + parsedSecret.jwt
|
||||
OpenAPI.TOKEN = token
|
||||
setContext<{ token?: string }>('AuthToken', { token })
|
||||
jwtError = false
|
||||
}
|
||||
try {
|
||||
userStore.set(await getUserExt(page.params.workspace ?? ''))
|
||||
if (!$userStore && parsedSecret.jwt) {
|
||||
jwtError = true
|
||||
sendUserToast('Could not authentify user with jwt token', true)
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('Anonymous user')
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<PublicApp
|
||||
{app}
|
||||
workspace={page.params.workspace}
|
||||
{notExists}
|
||||
{noPermission}
|
||||
{jwtError}
|
||||
onLoginSuccess={() => {
|
||||
loadAll()
|
||||
<PublicAppFrame
|
||||
{fetchEmbedToken}
|
||||
{viewerUrl}
|
||||
onViewerReady={(_token, requestTokenRefresh) => {
|
||||
refresh = requestTokenRefresh
|
||||
loadApp()
|
||||
}}
|
||||
></PublicApp>
|
||||
>
|
||||
{#snippet viewer()}
|
||||
<PublicApp
|
||||
{app}
|
||||
{workspace}
|
||||
{notExists}
|
||||
{noPermission}
|
||||
{jwtError}
|
||||
onLoginSuccess={() => loadApp()}
|
||||
></PublicApp>
|
||||
{/snippet}
|
||||
</PublicAppFrame>
|
||||
|
||||
+1
-1
@@ -4,7 +4,7 @@ verify_ssl = true
|
||||
name = "pypi"
|
||||
|
||||
[packages]
|
||||
wmill = ">=1.736.0"
|
||||
wmill = ">=1.737.0"
|
||||
sendgrid = "*"
|
||||
mysql-connector-python = "*"
|
||||
pymongo = "*"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
openapi: '3.0.3'
|
||||
|
||||
info:
|
||||
version: 1.736.0
|
||||
version: 1.737.0
|
||||
title: OpenFlow Spec
|
||||
contact:
|
||||
name: Ruben Fiszel
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
RootModule = 'WindmillClient.psm1'
|
||||
|
||||
# Version number of this module.
|
||||
ModuleVersion = '1.736.0'
|
||||
ModuleVersion = '1.737.0'
|
||||
|
||||
# Supported PSEditions
|
||||
# CompatiblePSEditions = @()
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[tool.poetry]
|
||||
name = "wmill"
|
||||
version = "1.736.0"
|
||||
version = "1.737.0"
|
||||
description = "A client library for accessing Windmill server wrapping the Windmill client API"
|
||||
license = "Apache-2.0"
|
||||
homepage = "https://windmill.dev"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@windmill/windmill",
|
||||
"version": "1.736.0",
|
||||
"version": "1.737.0",
|
||||
"exports": "./src/index.ts",
|
||||
"publish": {
|
||||
"exclude": ["!src", "./s3Types.ts", "./sqlUtils.ts", "./client.ts"]
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "windmill-client",
|
||||
"description": "Windmill SDK client for browsers and Node.js",
|
||||
"version": "1.736.0",
|
||||
"version": "1.737.0",
|
||||
"author": "Ruben Fiszel",
|
||||
"license": "Apache 2.0",
|
||||
"homepage": "https://github.com/windmill-labs/windmill/tree/main/typescript-client#readme",
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
1.736.0
|
||||
1.737.0
|
||||
|
||||
Reference in New Issue
Block a user