Hide a Forge module per project: app properties in a display condition
Mihai Perdum
Author
11 min readAugust 1, 2026
Key takeaways
Forge has no entity_property_contains_any_user_group. The replacement is a jiraExpression display condition reading your app's own app.properties — and FRGE-582, the parity request that asked for exactly this, is closed as Fixed.
Set the property at PUT /rest/forge/1/app/properties/{key} with asApp(). The Connect path /rest/atlassian-connect/1/addons/{key}/properties 404s for a pure Forge app, because it has no Connect key.
GET /rest/forge/1/app/properties/{key} does work, despite community answers saying no read endpoint exists — but it hands the value back as a JSON string, while a Jira expression sees the same property as a real object.
Omit the '' + string coercion and the expression returns false in every project — your module vanishes everywhere and Jira reports no error at all. Measured both ways.
The route template tag rejects an interpolated segment containing a colon or a slash, so an app ARI can never be used as a path segment.
Someone asked on the developer community whether Forge would ever get entityPropertyContainsAnyUserGroup for Jira display conditions — the Connect condition that hides a module unless an entity property says otherwise. It is a fair question, and the answers around it are mostly wrong in the same way: people conclude Forge cannot do this.
Forge can do it. It just does it with one general mechanism instead of a menu of named conditions, and the mechanism is badly served by the docs. So I built the thing, deployed it to a real Jira site, and measured what it does — including the failure mode that makes people give up on it.
Atlassian already shipped this
Worth knowing before you go vote on a ticket. FRGE-582, "Jira Display Conditions connect parity" — 46 votes — is Resolved / Fixed, with a resolution date of 2024-12-03.
The ticket description has two halves, and it is worth keeping them apart. Atlassian's own note says only this:
We want to add more robust display conditions which will support jiraProperties. The solution is not yet designed.
Below that, under "Original request description", sits what the community actually asked for:
Some configurable or dynamic means of limiting the display of app modules, e.g. using App properties or some kind of Jira Expression, is needed
So the phrase "Jira Expression" is the requester's, not Atlassian's — I am not going to pretend Atlassian announced the mechanism by name. What the tracker does tell you is that a request explicitly asking for app properties or Jira expressions was closed as Fixed, and the thing that shipped in that window is the jiraExpression display condition. Read that as strong evidence rather than a statement of intent.
The practical consequence is the same either way: a newer per-condition request like FRGE-2221 is asking Atlassian to reopen a line they have already marked delivered, which is worth knowing before you spend your goodwill on it.
The trap that convinces people it is broken
The usual debugging move is to test the expression with curl against POST /rest/api/3/expression/evaluate, using a personal API token. That returns:
text
1"app" - identifier not available in context
At which point it is very natural to conclude that app.properties does not work. It does. Read the availability note on the app context variable: it is "Always available for expressions used in app modules and REST API requests made by apps", and Forge app requests must be authenticated with asApp(). A curl with your own token is not a request made by an app. The identifier is legitimately absent because the caller was wrong, not because the feature is missing.
Everything below is therefore run from inside a deployed Forge app, which is the only place this can be tested honestly.
The manifest
Two panels. One carries the condition; the other carries nothing, and exists purely so that "I see no panels at all" can be told apart from "the condition is doing its job". Build the control in — without it, a blank issue view has three explanations and you cannot pick between them.
app.properties is an EntityProperties map keyed by property name. A missing key returns null rather than throwing, which is why the optional chaining matters, and why a typo in the property name fails silently rather than loudly.
Warning
'' + issue.project.id is not decoration. issue.project.id is a number; the ids you store in a JSON property come back as strings; and Jira expressions do not coerce between them the way Connect's conditions did. Get this wrong and includes returns false in every project. I measured both versions side by side further down — the wrong one produces no error anywhere.
Use issue.project.id rather than the bare project variable. Both are documented for issue-view conditions, but issue.project.id is the form with a known-working precedent, and it is the one I verified.
Setting the property
This is where most of the time goes, because two plausible endpoints are wrong.
The Connect app-properties path is the one search results will hand you:
For a pure Forge app this 404s — {"statusCode":404,"message":"App with key does not exist."}. I got that with the bare app UUID as the key; the ARI cannot even be attempted, for a reason worth its own warning below. The underlying reason is structural rather than a matter of finding the right string: an app that was never a Connect app has no Connect key for that path to match.
The endpoint that exists is Forge-specific and has no app key in the path at all — the app is identified by its own credentials:
201 on create, 200 on update. It must be asApp(); an impersonated request is rejected. write:app-data:jira is currently optional but is the correct scope to declare.
Yes, you can read it back
The prevailing community answer is that there is no GET for Forge app properties and you must duplicate the data into Forge Storage. I nearly repeated that. Then I tried it:
javascript
1const g =await api.asApp().requestJira(route`/rest/forge/1/app/properties/${PROP}`);2// 200 {"key":"display-settings","value":"{\"allowedProjectIds\":[\"10006\"]}"}
It returns 200 with the value. No duplication needed. Note the shape though — value comes back as a JSON string, so you need a second JSON.parse on it. The same property inside a Jira expression is not a string: app.properties['display-settings']?.allowedProjectIds indexes straight into it as an object. One property, two representations, depending on who is asking.
The route tag will reject your path
Two separate failures, both reported as the same unhelpful line:
text
1Error: Disallowing path manipulation attempt
The first is building the path as a string and interpolating the whole thing:
javascript
1const base =`/rest/forge/1/app/properties/${PROP}`;2await api.asApp().requestJira(route`${base}`);// throws3await api.asApp().requestJira(route`/rest/forge/1/app/properties/${PROP}`);// fine
Interpolate segments, never a pre-built path. The second is subtler: an interpolated segment containing : or / is treated as path manipulation too, so an app ARI (ari:cloud:ecosystem::app/<uuid>) can never be passed as a path segment. If you are looping over candidate key formats to find one that works, wrap each attempt in its own try — otherwise the first malformed candidate throws and takes the whole function down, and your webtrigger returns a bare 424 Failed Dependency with the real cause only in forge logs.
Proving it works
The honest test is to evaluate the exact manifest expression the way Jira evaluates it — from inside the app, as the app. I ran it against two issues: one in the project I had allowed (BDP, id 10006) and one in a project I had not (TPP, id 10007). And I ran the same expression without the string coercion, to see the failure mode rather than describe it.
That is the whole thing in one table. The correct expression gates exactly as intended. The version missing two characters returns false in both projects — the module disappears everywhere, Jira raises no error, forge logs stays empty, and there is nothing to search for. If your gated module has vanished from every project, this is the first thing to check.
One more result worth keeping: /rest/api/3/expression/evaluate does not derive project from the issue you pass. Supply only { issue: { key } } and a project.id reference fails with "Type null does not have any properties". Every context variable your expression touches has to be supplied explicitly when you evaluate by hand — Jira populates them for you when it runs a real display condition, which is one more reason the by-hand test misleads people.
Try it yourself
1
Create and register
scaffold an app, then forge register -s <developer-space-id> -y "<name>". The space id comes from forge developer-spaces list; without -s the CLI tries to prompt and dies in any non-TTY shell.
2
Add the condition
put the displayConditions.jiraExpression block on your module, plus a second module with no condition as a control.
3
Declare the scopes
write:app-data:jira to set the property, read:jira-user if you also want to call the expression-evaluate endpoint.
4
Deploy and install
forge deploy -e development then forge install --upgrade --site <site> --product jira -e development. The --upgrade matters whenever scopes changed.
5
Set the property
hit your webtrigger with the project ids you want to allow, and check for a 201 or 200.
6
Verify as the app
evaluate the expression against one allowed and one disallowed issue. Do not trust a curl with a personal token; it cannot see app.
What I did not verify
I proved the expression semantics from inside the deployed app, on a real site, and the app is installed with the condition live. I did not visually confirm the rendered issue view in a browser in this session, so treat "the panel disappears" as following from an expression that evaluates false rather than as something I watched happen. If you run it, that is the one step to do with your own eyes.
I also tested this only for jira:issuePanel on Jira Cloud. Confluence display conditions are a different surface with a different context, and none of the specifics above transfer.
The mechanism is more capable than the Connect condition it replaced — you have a whole expression language rather than one predicate, so per-group, per-user and per-field gating are all the same shape of problem. It is just unforgiving about types, and completely silent when it disagrees with you. Which of the Connect conditions are you still missing an equivalent for?
Locking in Forge KVS: FAIL_IF_EXISTS works, TTL leases do not