From 6f7f28946c6643aecd96da043e9a1d1549193437 Mon Sep 17 00:00:00 2001
From: Anthony Stirling <77850077+Frooodle@users.noreply.github.com>
Date: Wed, 19 Aug 2026 14:19:26 +0000
Subject: [PATCH 1/8] Set deployment: false on environment jobs that do not
deploy (#7562)
# Description of Changes
thanks ludy for the tip :P
---
## Checklist
### General
- [ ] I have read the [Contribution
Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md)
- [ ] I have read the [Stirling-PDF Developer
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md)
(if applicable)
- [ ] I have read the [How to add new languages to
Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md)
(if applicable)
- [ ] I have performed a self-review of my own code
- [ ] My changes generate no new warnings
### Documentation
- [ ] I have updated relevant docs on [Stirling-PDF's doc
repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/)
(if functionality has heavily changed)
- [ ] I have read the section [Add New Translation
Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags)
(for new translation tags only)
### Translations (if applicable)
- [ ] I ran
[`scripts/counter_translation.py`](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/docs/counter_translation.md)
### UI Changes (if applicable)
- [ ] Screenshots or videos demonstrating the UI changes are attached
(e.g., as comments or direct attachments in the PR)
### Testing (if applicable)
- [ ] I have run `task check` to verify linters, typechecks, and tests
pass
- [ ] I have tested my changes locally. Refer to the [Testing
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md#7-testing)
for more details.
---
.github/workflows/PR-Auto-Deploy-V2.yml | 5 ++++-
.github/workflows/PR-Demo-cleanup.yml | 5 ++++-
.github/workflows/backend-build.yml | 4 +++-
.github/workflows/build-enterprise.yml | 8 ++++++--
.github/workflows/build.yml | 4 +++-
.github/workflows/check-licence.yml | 4 +++-
.github/workflows/check-openapi.yml | 4 +++-
.github/workflows/db-migration-test.yml | 4 +++-
.github/workflows/docker-compose-tests.yml | 4 +++-
.github/workflows/e2e-live.yml | 4 +++-
.github/workflows/frontend-backend-licenses-update.yml | 8 ++++++--
.github/workflows/multiOSReleases.yml | 8 ++++++--
.github/workflows/nightly.yml | 4 +++-
.github/workflows/tauri-build.yml | 4 +++-
.github/workflows/test-build-docker.yml | 4 +++-
15 files changed, 56 insertions(+), 18 deletions(-)
diff --git a/.github/workflows/PR-Auto-Deploy-V2.yml b/.github/workflows/PR-Auto-Deploy-V2.yml
index 0f07aabbe5..4375b1b8b0 100644
--- a/.github/workflows/PR-Auto-Deploy-V2.yml
+++ b/.github/workflows/PR-Auto-Deploy-V2.yml
@@ -462,7 +462,10 @@ jobs:
});
cleanup-v2-deployment:
- environment: pr-preview
+ # Tearing a preview down is not a deployment - no deployment object.
+ environment:
+ name: pr-preview
+ deployment: false
if: github.event.action == 'closed'
runs-on: ubuntu-latest
permissions:
diff --git a/.github/workflows/PR-Demo-cleanup.yml b/.github/workflows/PR-Demo-cleanup.yml
index 1407939994..098f8d7803 100644
--- a/.github/workflows/PR-Demo-cleanup.yml
+++ b/.github/workflows/PR-Demo-cleanup.yml
@@ -9,7 +9,10 @@ permissions:
jobs:
cleanup:
- environment: pr-preview
+ # Tearing a preview down is not a deployment - no deployment object.
+ environment:
+ name: pr-preview
+ deployment: false
if: github.event.action == 'closed'
runs-on: ubuntu-latest
permissions:
diff --git a/.github/workflows/backend-build.yml b/.github/workflows/backend-build.yml
index 6623940bce..596be96e8c 100644
--- a/.github/workflows/backend-build.yml
+++ b/.github/workflows/backend-build.yml
@@ -20,7 +20,9 @@ permissions:
jobs:
build:
- environment: ci-unsigned
+ environment:
+ name: ci-unsigned
+ deployment: false
runs-on: ubuntu-latest
strategy:
fail-fast: false
diff --git a/.github/workflows/build-enterprise.yml b/.github/workflows/build-enterprise.yml
index b4a8373ccc..194d86d9da 100644
--- a/.github/workflows/build-enterprise.yml
+++ b/.github/workflows/build-enterprise.yml
@@ -42,7 +42,9 @@ jobs:
uses: ./.github/workflows/_runner-pick.yml
playwright-e2e-enterprise:
- environment: ci-unsigned
+ environment:
+ name: ci-unsigned
+ deployment: false
needs: pick
# Skip on fork PRs / untrusted authors: they have no PREMIUM_KEY_ENTERPRISE,
# so the suite can't boot premium and would fail. See the header comment.
@@ -325,7 +327,9 @@ jobs:
# Multi-node regression: builds + seeds the clustered stack (testing/compose/docker-compose-multinode.yml)
# and runs behave features/multinode. Licence-gated, so it runs after the Playwright job (not in parallel).
multinode-e2e:
- environment: ci-unsigned
+ environment:
+ name: ci-unsigned
+ deployment: false
needs: [pick, playwright-e2e-enterprise]
# Nightly cron + manual dispatch only (heavy build), fork-gated for the licence secret.
if: >-
diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
index 566262d24f..1feaff2560 100644
--- a/.github/workflows/build.yml
+++ b/.github/workflows/build.yml
@@ -61,7 +61,9 @@ jobs:
filters: .github/config/.files.yaml
gradle-cache-prime:
- environment: ci-unsigned
+ environment:
+ name: ci-unsigned
+ deployment: false
name: Prime shared Gradle cache
needs: [files-changed]
runs-on: ubuntu-latest
diff --git a/.github/workflows/check-licence.yml b/.github/workflows/check-licence.yml
index 17c64d5c64..4e04a83656 100644
--- a/.github/workflows/check-licence.yml
+++ b/.github/workflows/check-licence.yml
@@ -10,7 +10,9 @@ permissions:
jobs:
check-licence:
- environment: ci-unsigned
+ environment:
+ name: ci-unsigned
+ deployment: false
runs-on: ubuntu-latest
steps:
- name: Harden Runner
diff --git a/.github/workflows/check-openapi.yml b/.github/workflows/check-openapi.yml
index ed83447335..bc9b302857 100644
--- a/.github/workflows/check-openapi.yml
+++ b/.github/workflows/check-openapi.yml
@@ -11,7 +11,9 @@ permissions:
jobs:
check-generate-openapi-docs:
- environment: ci-unsigned
+ environment:
+ name: ci-unsigned
+ deployment: false
runs-on: ubuntu-latest
steps:
- name: Harden Runner
diff --git a/.github/workflows/db-migration-test.yml b/.github/workflows/db-migration-test.yml
index ccb46d3988..785073944e 100644
--- a/.github/workflows/db-migration-test.yml
+++ b/.github/workflows/db-migration-test.yml
@@ -13,7 +13,9 @@ permissions:
jobs:
migration-test:
- environment: ci-unsigned
+ environment:
+ name: ci-unsigned
+ deployment: false
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
diff --git a/.github/workflows/docker-compose-tests.yml b/.github/workflows/docker-compose-tests.yml
index 039c73e5db..439d4240b2 100644
--- a/.github/workflows/docker-compose-tests.yml
+++ b/.github/workflows/docker-compose-tests.yml
@@ -17,7 +17,9 @@ permissions:
jobs:
docker-compose-tests:
- environment: ci-unsigned
+ environment:
+ name: ci-unsigned
+ deployment: false
runs-on: ubuntu-latest
permissions:
actions: write
diff --git a/.github/workflows/e2e-live.yml b/.github/workflows/e2e-live.yml
index 43d66dd1cf..844d26a3d1 100644
--- a/.github/workflows/e2e-live.yml
+++ b/.github/workflows/e2e-live.yml
@@ -11,7 +11,9 @@ permissions:
jobs:
playwright-e2e-live:
- environment: ci-unsigned
+ environment:
+ name: ci-unsigned
+ deployment: false
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
diff --git a/.github/workflows/frontend-backend-licenses-update.yml b/.github/workflows/frontend-backend-licenses-update.yml
index 7fd0136718..9aef2316d2 100644
--- a/.github/workflows/frontend-backend-licenses-update.yml
+++ b/.github/workflows/frontend-backend-licenses-update.yml
@@ -43,7 +43,9 @@ jobs:
generate-frontend-license-report:
# ci-bot, not bot-identity: this job runs on PRs too, and bot-identity is main-only.
- environment: ci-bot
+ environment:
+ name: ci-bot
+ deployment: false
if: needs.files-changed.outputs.licenses-frontend == 'true'
name: Generate Frontend License Report
needs: files-changed
@@ -319,7 +321,9 @@ jobs:
generate-backend-license-report:
# ci-bot, not bot-identity: this job runs on PRs too, and bot-identity is main-only.
- environment: ci-bot
+ environment:
+ name: ci-bot
+ deployment: false
if: needs.files-changed.outputs.licenses-backend == 'true'
needs: files-changed
name: Generate Backend License Report
diff --git a/.github/workflows/multiOSReleases.yml b/.github/workflows/multiOSReleases.yml
index 0f0b2d3585..0ac94ffe68 100644
--- a/.github/workflows/multiOSReleases.yml
+++ b/.github/workflows/multiOSReleases.yml
@@ -38,7 +38,9 @@ permissions:
jobs:
determine-matrix:
- environment: ci-unsigned
+ environment:
+ name: ci-unsigned
+ deployment: false
if: ${{ vars.CI_PROFILE != 'lite' }}
runs-on: ubuntu-latest
outputs:
@@ -116,7 +118,9 @@ jobs:
env:
INPUT_PLATFORM: ${{ github.event.inputs.platform }}
build-jars:
- environment: ci-unsigned
+ environment:
+ name: ci-unsigned
+ deployment: false
needs: determine-matrix
runs-on: ubuntu-latest
strategy:
diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml
index c92d17f027..65c25b7b66 100644
--- a/.github/workflows/nightly.yml
+++ b/.github/workflows/nightly.yml
@@ -127,7 +127,9 @@ jobs:
# Runs the @nightly tag (conversion scenarios) plus a 10-shard concurrency run
# of every other feature.
cucumber-nightly:
- environment: ci-unsigned
+ environment:
+ name: ci-unsigned
+ deployment: false
name: Cucumber (nightly scenarios + full concurrency)
runs-on: ubuntu-latest
# Fork pull requests get no MAVEN_* secrets, so the image build cannot work.
diff --git a/.github/workflows/tauri-build.yml b/.github/workflows/tauri-build.yml
index e3f3122773..0a82647690 100644
--- a/.github/workflows/tauri-build.yml
+++ b/.github/workflows/tauri-build.yml
@@ -63,7 +63,9 @@ jobs:
determine-matrix:
# Only probes APPLE_CERTIFICATE for presence, so it stays on the unrestricted
# signing environment - release-signing would block every PR run.
- environment: ci-signing
+ environment:
+ name: ci-signing
+ deployment: false
if: ${{ vars.CI_PROFILE != 'lite' }}
runs-on: ubuntu-latest
outputs:
diff --git a/.github/workflows/test-build-docker.yml b/.github/workflows/test-build-docker.yml
index 12d5a35a1f..37cb7cb546 100644
--- a/.github/workflows/test-build-docker.yml
+++ b/.github/workflows/test-build-docker.yml
@@ -37,7 +37,9 @@ jobs:
# spring-security=true matrix entry if `task backend:build` and
# `task backend:build:ci` produce equivalent JARs (verify before wiring).
test-build-docker-images:
- environment: ci-unsigned
+ environment:
+ name: ci-unsigned
+ deployment: false
runs-on: ubuntu-latest
strategy:
fail-fast: false
From 0f8803f35f14cf8e9c191cfc5b6101a33586674c Mon Sep 17 00:00:00 2001
From: EthanHealy01 <80844253+EthanHealy01@users.noreply.github.com>
Date: Wed, 19 Aug 2026 14:27:39 +0000
Subject: [PATCH 2/8] Require the policy-management role to run a policy
against its sources (#7565)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
## What
Running a stored policy against its **configured sources** (`POST
/api/v1/policies/{id}/trigger`, the manual "run now") now requires the
policy-management role — global admin self-hosted, team leader on SaaS —
alongside the existing team scoping.
## Why
A source sweep operates on the team's configured sources using the
server's stored connection credentials, so it belongs with the other
policy-management capabilities rather than with ordinary use. Team
scoping on its own didn't express that distinction.
## Not changed
- `POST /{id}/run` — running a policy over documents the **caller
supplied** stays open to every team member. That's ordinary editor
enforcement on upload and export, and gating it would break it.
- Ad-hoc pipelines (`/run`, `/run/stream`).
- The scheduled, folder-watch and webhook triggers.
- Single-user deployments (login disabled), which have no roles.
## Implementation
`PolicyManagementAuthority` gains `canTriggerPolicies()`, kept separate
from `canEditPolicies()` so the two capabilities can diverge later. Both
current implementations grant it to the same principals that may edit
policies.
## Tests
- role absent → 403, rejected before any run starts
- role present → 202
- login disabled → check skipped entirely
- `/{id}/run` asserted to consult neither authority method, so the gate
can't quietly extend to the editor path later
---
.../AdminPolicyManagementAuthority.java | 5 ++
.../config/PolicyManagementAuthority.java | 11 +++
.../policy/controller/PolicyController.java | 30 +++++++-
.../AdminPolicyManagementAuthorityTest.java | 12 +++
.../controller/PolicyControllerTest.java | 74 +++++++++++++++++++
.../TeamLeaderPolicyManagementAuthority.java | 5 ++
...amLeaderPolicyManagementAuthorityTest.java | 12 +++
7 files changed, 145 insertions(+), 4 deletions(-)
diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/config/AdminPolicyManagementAuthority.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/config/AdminPolicyManagementAuthority.java
index a49c8e5aa9..6d8229aa26 100644
--- a/app/proprietary/src/main/java/stirling/software/proprietary/policy/config/AdminPolicyManagementAuthority.java
+++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/config/AdminPolicyManagementAuthority.java
@@ -26,6 +26,11 @@ public class AdminPolicyManagementAuthority implements PolicyManagementAuthority
return userService.isCurrentUserAdmin();
}
+ @Override
+ public boolean canTriggerPolicies() {
+ return userService.isCurrentUserAdmin();
+ }
+
@Override
public Long currentUserTeamId() {
String username = userService.getCurrentUsername();
diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/config/PolicyManagementAuthority.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/config/PolicyManagementAuthority.java
index 0ea3c298ad..d7e4f50ad1 100644
--- a/app/proprietary/src/main/java/stirling/software/proprietary/policy/config/PolicyManagementAuthority.java
+++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/config/PolicyManagementAuthority.java
@@ -12,6 +12,17 @@ public interface PolicyManagementAuthority {
/** Whether the current user may create, edit, or delete policies (for their own team). */
boolean canEditPolicies();
+ /**
+ * Whether the current user may run a policy against its configured sources (the manual
+ * "run now" sweep). Kept separate from {@link #canEditPolicies()} because the two are distinct
+ * capabilities, even where a deployment grants both to the same people: a sweep operates on the
+ * team's configured sources using the server's stored connection credentials, which makes it a
+ * policy-management capability rather than ordinary use. Running a policy over the caller's
+ * own uploaded files is not covered by this and stays open to every team member — that
+ * is ordinary editor enforcement.
+ */
+ boolean canTriggerPolicies();
+
/**
* The team that scopes the current user's policies — the team a new policy is stamped with and
* the only team whose policies the user may see/run/edit. {@code null} when it can't be
diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/controller/PolicyController.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/controller/PolicyController.java
index 506bb75578..778a04e169 100644
--- a/app/proprietary/src/main/java/stirling/software/proprietary/policy/controller/PolicyController.java
+++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/controller/PolicyController.java
@@ -432,9 +432,10 @@ public class PolicyController {
* admin gets no say on SaaS. Team scoping (which team's policies) is enforced separately by
* {@link PolicyAccessGuard}. Every mutation routes through {@link #savePolicy} (pause/resume
* re-save with a flipped {@code enabled} flag) or {@link #deletePolicy}, so gating those two
- * covers them all; runs ({@code /run}) stay open to the team. Single-user deployments (login
- * disabled) have no such role, so they trust the local operator. The path allowlist for folder
- * sources/outputs is enforced separately by {@link PolicyValidator} at validation time.
+ * covers them all; runs over the caller's own files ({@code /{id}/run}) stay open to the team,
+ * while source sweeps are gated by {@link #requirePolicySweepAllowed}. Single-user deployments
+ * (login disabled) have no such role, so they trust the local operator. The path allowlist for
+ * folder sources/outputs is enforced separately by {@link PolicyValidator} at validation time.
*/
private void requirePolicyEditingAllowed() {
if (!applicationProperties.getSecurity().isEnableLogin()) {
@@ -447,6 +448,25 @@ public class PolicyController {
}
}
+ /**
+ * Sweeping a policy's configured sources requires the same role as managing policies: the sweep
+ * operates on the team's configured sources using the server's stored connection credentials,
+ * which makes it a policy-management capability rather than ordinary use, and team scoping on
+ * its own does not express that. Deliberately narrower than it looks: it gates only the sweep,
+ * not {@link #runStoredPolicy}, because running a policy over documents the caller supplied is
+ * ordinary editor enforcement that every member performs on upload and export.
+ */
+ private void requirePolicySweepAllowed() {
+ if (!applicationProperties.getSecurity().isEnableLogin()) {
+ return;
+ }
+ if (!policyManagementAuthority.canTriggerPolicies()) {
+ throw new ResponseStatusException(
+ HttpStatus.FORBIDDEN,
+ "Not permitted to run this policy against its configured sources");
+ }
+ }
+
@GetMapping
@Operation(
summary = "List policies",
@@ -571,8 +591,10 @@ public class PolicyController {
+ " the enabled flag (which only gates automatic triggering). Returns"
+ " the ids of the runs started (poll the run-status endpoint for each)"
+ " plus what the sweep skipped - already-processed, parked-by-failure,"
- + " and in-flight counts - so an empty result explains itself.")
+ + " and in-flight counts - so an empty result explains itself. Requires"
+ + " the policy-management role.")
public ResponseEntity trigger(@PathVariable String policyId) {
+ requirePolicySweepAllowed();
Policy policy =
policyStore
.get(policyId)
diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/policy/config/AdminPolicyManagementAuthorityTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/policy/config/AdminPolicyManagementAuthorityTest.java
index 811aae6b4f..0f97fcaf62 100644
--- a/app/proprietary/src/test/java/stirling/software/proprietary/policy/config/AdminPolicyManagementAuthorityTest.java
+++ b/app/proprietary/src/test/java/stirling/software/proprietary/policy/config/AdminPolicyManagementAuthorityTest.java
@@ -39,6 +39,18 @@ class AdminPolicyManagementAuthorityTest {
assertFalse(authority().canEditPolicies());
}
+ @Test
+ void adminMayTriggerPolicies() {
+ when(userService.isCurrentUserAdmin()).thenReturn(true);
+ assertTrue(authority().canTriggerPolicies());
+ }
+
+ @Test
+ void nonAdminMayNotTriggerPolicies() {
+ when(userService.isCurrentUserAdmin()).thenReturn(false);
+ assertFalse(authority().canTriggerPolicies());
+ }
+
@Test
void currentUserTeamIdResolvesFromTheCurrentUsersTeam() {
Team team = new Team();
diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/policy/controller/PolicyControllerTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/policy/controller/PolicyControllerTest.java
index 84e9998b90..2fa675597c 100644
--- a/app/proprietary/src/test/java/stirling/software/proprietary/policy/controller/PolicyControllerTest.java
+++ b/app/proprietary/src/test/java/stirling/software/proprietary/policy/controller/PolicyControllerTest.java
@@ -2,6 +2,7 @@ package stirling.software.proprietary.policy.controller;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
+import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
@@ -738,5 +739,78 @@ class PolicyControllerTest {
assertThat(((ResponseStatusException) e).getStatusCode())
.isEqualTo(HttpStatus.NOT_FOUND));
}
+
+ @Test
+ @DisplayName("trigger is forbidden for a team member who cannot manage policies")
+ void triggerForbiddenForMember() {
+ // Sweeping a policy's configured sources is a policy-management capability, so being
+ // in the policy's team is not on its own enough to perform it.
+ applicationProperties.getSecurity().setEnableLogin(true);
+ when(policyManagementAuthority.canTriggerPolicies()).thenReturn(false);
+
+ assertThatThrownBy(() -> controller.trigger("a"))
+ .isInstanceOf(ResponseStatusException.class)
+ .satisfies(
+ e ->
+ assertThat(((ResponseStatusException) e).getStatusCode())
+ .isEqualTo(HttpStatus.FORBIDDEN));
+ // Rejected before the policy is looked up, so no run starts.
+ verify(policyRunner, never()).run(any());
+ verify(policyStore, never()).get(any());
+ }
+
+ @Test
+ @DisplayName("trigger runs for a caller who may manage policies")
+ void triggerAllowedForLeader() {
+ applicationProperties.getSecurity().setEnableLogin(true);
+ when(policyManagementAuthority.canTriggerPolicies()).thenReturn(true);
+ Policy p = policy("a", 1L);
+ when(policyStore.get("a")).thenReturn(Optional.of(p));
+ when(policyAccessGuard.canAccess(p)).thenReturn(true);
+ SweepOutcome outcome = new SweepOutcome(List.of("run-a"), 1, 0, 0, 0);
+ when(policyRunner.run(p)).thenReturn(outcome);
+
+ ResponseEntity response = controller.trigger("a");
+
+ assertThat(response.getStatusCode()).isEqualTo(HttpStatus.ACCEPTED);
+ assertThat(response.getBody()).isEqualTo(outcome);
+ }
+
+ @Test
+ @DisplayName("trigger skips the role check when login is disabled")
+ void triggerTrustsTheLocalOperator() {
+ // Single-user deployments have no roles at all; the gate must not lock them out of
+ // their
+ // own sweeps.
+ applicationProperties.getSecurity().setEnableLogin(false);
+ Policy p = policy("a", null);
+ when(policyStore.get("a")).thenReturn(Optional.of(p));
+ when(policyAccessGuard.canAccess(p)).thenReturn(true);
+ SweepOutcome outcome = new SweepOutcome(List.of("run-a"), 1, 0, 0, 0);
+ when(policyRunner.run(p)).thenReturn(outcome);
+
+ assertThat(controller.trigger("a").getStatusCode()).isEqualTo(HttpStatus.ACCEPTED);
+ verify(policyManagementAuthority, never()).canTriggerPolicies();
+ }
+
+ @Test
+ @DisplayName("running a policy over the caller's own files stays open to any member")
+ void storedRunIsNotGatedByRole() {
+ // Editor enforcement: every member's upload/export runs the team's stored policies on
+ // their own documents. Gating this the way the sweep is gated would break the editor.
+ applicationProperties.getSecurity().setEnableLogin(true);
+ Policy p = policy("a", 1L);
+ when(policyStore.get("a")).thenReturn(Optional.of(p));
+ when(policyAccessGuard.canAccess(p)).thenReturn(true);
+ when(policyRunner.runWith(eq(p), any(), eq(PolicyProgressListener.NOOP)))
+ .thenReturn(handle("run-9"));
+
+ ResponseEntity> response =
+ assertDoesNotThrow(() -> controller.runStoredPolicy("a", new PolicyRunFiles()));
+
+ assertThat(response.getStatusCode()).isEqualTo(HttpStatus.ACCEPTED);
+ verify(policyManagementAuthority, never()).canTriggerPolicies();
+ verify(policyManagementAuthority, never()).canEditPolicies();
+ }
}
}
diff --git a/app/saas/src/main/java/stirling/software/saas/security/TeamLeaderPolicyManagementAuthority.java b/app/saas/src/main/java/stirling/software/saas/security/TeamLeaderPolicyManagementAuthority.java
index e2f5b65b47..0ce5e8e308 100644
--- a/app/saas/src/main/java/stirling/software/saas/security/TeamLeaderPolicyManagementAuthority.java
+++ b/app/saas/src/main/java/stirling/software/saas/security/TeamLeaderPolicyManagementAuthority.java
@@ -25,6 +25,11 @@ public class TeamLeaderPolicyManagementAuthority implements PolicyManagementAuth
return teamSecurity.isCurrentUserTeamLeader();
}
+ @Override
+ public boolean canTriggerPolicies() {
+ return teamSecurity.isCurrentUserTeamLeader();
+ }
+
@Override
public Long currentUserTeamId() {
return teamSecurity.currentUserTeamId();
diff --git a/app/saas/src/test/java/stirling/software/saas/security/TeamLeaderPolicyManagementAuthorityTest.java b/app/saas/src/test/java/stirling/software/saas/security/TeamLeaderPolicyManagementAuthorityTest.java
index 70cd360d7c..2c37980a5c 100644
--- a/app/saas/src/test/java/stirling/software/saas/security/TeamLeaderPolicyManagementAuthorityTest.java
+++ b/app/saas/src/test/java/stirling/software/saas/security/TeamLeaderPolicyManagementAuthorityTest.java
@@ -32,6 +32,18 @@ class TeamLeaderPolicyManagementAuthorityTest {
assertFalse(authority().canEditPolicies());
}
+ @Test
+ void teamLeaderMayTriggerPolicies() {
+ when(teamSecurity.isCurrentUserTeamLeader()).thenReturn(true);
+ assertTrue(authority().canTriggerPolicies());
+ }
+
+ @Test
+ void nonLeaderMayNotTriggerPolicies() {
+ when(teamSecurity.isCurrentUserTeamLeader()).thenReturn(false);
+ assertFalse(authority().canTriggerPolicies());
+ }
+
@Test
void currentUserTeamIdDelegatesToTeamSecurity() {
when(teamSecurity.currentUserTeamId()).thenReturn(9L);
From 6bae9d516dc029869fee80ec5c2e92da89a5d541 Mon Sep 17 00:00:00 2001
From: ConnorYoh <40631091+ConnorYoh@users.noreply.github.com>
Date: Wed, 19 Aug 2026 14:46:00 +0000
Subject: [PATCH 3/8] chore(saas): one task per environment, and make the
frontend follow it (#7483)
## The problem
The `dev` profile hardcoded one project ref (`qacaivhsjtftfwtgjvva`) in
five places: the ref, the Supabase URL, the publishable key, the
datasource host and the meter endpoint.
That made it both the shared environment everyone relies on *and* the
only thing you could point the backend at. Testing an open SaaS PR meant
hand-overriding all five via env just to reach that PR's Supabase
preview branch, which is the only place the PR's migrations have
actually been applied. Get it wrong and you see `relation
"stirling_pdf." does not exist` for a table the PR added,
which is what happened on
[#7414](https://github.com/Stirling-Tools/Stirling-PDF/pull/7414).
## One task per environment
```bash
task dev:saas # backend + frontend + engine, against this PR's preview branch
task staging:saas # backend + frontend + engine, against the shared v3 project
task backend:dev:saas # backend only, preview branch
task backend:staging:saas # backend only, v3
```
| | how | vars | project |
|---|---|---|---|
| prod | `PROFILES=none` | `SAAS_DB_*` | the live one |
| staging | `PROFILES=staging` | `SAAS_STAGING_*` | pinned to v3, always
there |
| dev | `PROFILES=dev` | `SAAS_DEV_*` | follows a SaaS PR's preview
branch |
`PROFILES` is still the underlying switch, so the old spelling keeps
working. Production deliberately has no named task: reaching it should
take a conscious `PROFILES=none`, not a tab-complete.
**staging** is the old `dev` configuration, moved and kept pinned. The
value of a shared environment is that it is still there tomorrow:
reproduce a bug, paste a link to a colleague, share data.
**dev** is parameterised by `SAAS_DEV_PROJECT_REF` and derives the
Supabase URL, JWT issuer, JWKS, meter endpoint and (unless overridden)
the database host from it. Switching which PR you are testing is one
variable instead of five. With no ref set, `task backend:dev:saas` stops
and says what to set rather than falling back.
## The frontend was the real gap
`frontend/editor/.env` is committed and pins the **production** Supabase
project, and nothing in the frontend knew about dev or staging. So `task
dev:saas` gave you a backend on a preview branch and a login against
prod, unless you happened to have hand-written
`frontend/editor/.env.saas.local`.
The dev tasks now read the backend's env files and derive
`VITE_SUPABASE_URL` and `VITE_SUPABASE_PUBLISHABLE_DEFAULT_KEY` from the
same project ref the backend resolved, so the two halves cannot point at
different projects. Nothing to keep in sync by hand, no new vite mode,
and `SAAS_ENV=prod` opts back out to the committed values.
## Where to put your local values
Two files, both gitignored, neither ever committed:
**`app/.env.saas.local`** is the only one you normally need. The tasks
load it for the backend *and* the frontend.
```bash
# staging: everything else is already defaulted, so this is all it takes
SAAS_STAGING_DB_PASSWORD=...
# dev: the preview branch of the PR you are testing, from its "Supabase Preview" check.
# A branch has its OWN password and API keys; the parent project's will not authenticate.
SAAS_DEV_PROJECT_REF=...
SAAS_DEV_DB_PASSWORD=...
SAAS_DEV_PUBLISHABLE_KEY=...
# prod, if you ever need it
SAAS_DB_PROJECT_REF=...
SAAS_DB_URL=...
SAAS_DB_PASSWORD=...
SUPABASE_EDGE_FUNCTION_SECRET=...
```
**`frontend/editor/.env.saas.local`** is no longer needed for choosing a
Supabase project, and is best left empty or deleted. If you have one
from before this PR, note that the task-supplied values now win, which
is the point: the frontend follows the backend.
**A blank is not the same as absent.** A dotenv line with an empty value
still *sets* the variable, and Spring's `${VAR:default}` only falls back
when a variable is absent. So `.env.saas` lists what you must set as
blanks, and leaves out the two `*_DB_URL` overrides, which have real
defaults to fall back to. This is not theoretical, see below.
Committed `app/.env.saas` holds non-secret defaults only. Real secrets
are passwords, the edge-function secret and service-role keys. Project
refs and publishable keys are neither: a ref is the public
`.supabase.co` subdomain and a publishable key ships in the browser
bundle by design, which is why `frontend/editor/.env` has always carried
prod's.
## Three bugs found while building the tasks
All three were in this PR's own earlier commits, and all three were
caught by actually booting things rather than by reading the config.
**staging could not boot at all.** A blank `SAAS_STAGING_DB_URL=` in
`.env.saas` set the variable to empty, so
`${SAAS_STAGING_DB_URL:jdbc:...}` resolved to `""` and startup failed
with `spring.datasource.url is required when the saas profile is
active`. The file already carried a comment warning about exactly this;
it had only been applied to the dev block. The original verification for
this PR was "placeholders resolve" and "the task parses", neither of
which boots anything.
**The dev to staging fallback ran `ddl-auto=update` against shared v3.**
The dev profile sets `update`, which is right for a disposable preview
branch, and separately fell back to staging's project ref. Together that
meant Hibernate was free to reconcile tables that RLS policies depend
on. `application-staging.properties` pins `none`, but that only applies
when the staging profile is the active one, which it was not on the
fallback path. There is no fallback now: with no ref the task stops
before gradle, and the frontend fails the same way, both naming the
variable.
**`PROFILES=` never selected production.** Go template `default` treats
`""` as absent, so it silently resolved back to `dev`. It is
`PROFILES=none` now.
## Two choices worth reviewing
**Staging keeps its committed project ref**, now as a
`${SAAS_STAGING_PROJECT_REF:...}` default in one place, with the URL,
database host and meter endpoint all derived from it. So staging still
works with zero setup, and repointing it is one variable. Nothing in CI
referenced the ref or the profile. Its publishable key default carries
no inline `gitleaks:allow`: a trailing comment in a `.properties` file
is part of the value, so the pragma ended up inside the key. It is in
`.gitleaksignore` instead.
**`SAAS_DEV_DB_URL` still overrides the whole URL**, so a branch needing
the pooler host rather than the direct one is reachable without touching
committed config.
## Verification
- `task backend:staging:saas` boots against v3 and serves `200`. It
could not boot before this commit.
- `task backend:dev:saas` with no ref stops before gradle naming the
variable, and `PROFILES=none` still reaches production. `task
frontend:dev:saas` fails the same way; `SAAS_ENV=staging` still resolves
with no local config.
- Frontend routing picks the SaaS runner for dev/staging and the plain
runner for prod; the derivation returns the right URL and key for each.
- Vite's `process.env` precedence and Task's dotenv/env semantics were
measured, not assumed. That is how one trap surfaced: Task sets an
`env:` key even when its value resolves to empty, and Vite treats an
empty `process.env` `VITE_*` as authoritative over a committed `.env`.
Putting the Supabase vars on the shared `dev:_run` would have blanked
Supabase config for the core, proprietary and desktop dev servers, so
the SaaS path has its own runner.
- `:saas:spotlessApply` and `:saas:compileJava` green.
`DevProfileProjectNotice` becomes `SaasProjectNotice` and covers both
profiles, stating the project ref and `ddl-auto` at startup so which
environment you are on is never a guess.
No behaviour change for prod: the `saas` profile is untouched.
---
.gitleaksignore | 5 ++
.taskfiles/backend.yml | 55 ++++++++++++--
.taskfiles/frontend.yml | 74 ++++++++++++++++---
Taskfile.yml | 21 +++++-
app/.env.saas | 52 ++++++++-----
.../saas/config/SaasProjectNotice.java | 53 +++++++++++++
.../main/resources/application-dev.properties | 38 ++++++----
.../resources/application-staging.properties | 39 ++++++++++
8 files changed, 286 insertions(+), 51 deletions(-)
create mode 100644 app/saas/src/main/java/stirling/software/saas/config/SaasProjectNotice.java
create mode 100644 app/saas/src/main/resources/application-staging.properties
diff --git a/.gitleaksignore b/.gitleaksignore
index 12d98aebeb..c3917e985f 100644
--- a/.gitleaksignore
+++ b/.gitleaksignore
@@ -27,3 +27,8 @@ app/core/src/main/java/stirling/software/SPDF/pdf/signature/CreateSignatureBase.
# Supabase publishable key (public by design, RLS-protected) used as a CI fallback
# default in the tauri-build workflow when the GitHub secret is unset - not a real secret.
.github/workflows/tauri-build.yml:generic-api-key:402
+
+# Staging Supabase publishable key (public by design). Ignored here rather than with an
+# inline gitleaks:allow because a trailing comment in a .properties file is part of the
+# value, so the pragma would end up inside the key.
+app/saas/src/main/resources/application-staging.properties:generic-api-key:16
diff --git a/.taskfiles/backend.yml b/.taskfiles/backend.yml
index 63773f61fc..08a12b9535 100644
--- a/.taskfiles/backend.yml
+++ b/.taskfiles/backend.yml
@@ -57,16 +57,57 @@ tasks:
- cmd: ./gradlew clean bootRun -PbuildWithFrontend=true
platforms: [linux, darwin]
+ # SaaS backend. dev:saas -> the PR's preview branch, staging:saas -> shared v3,
+ # PROFILES=none -> production against your own SAAS_DB_*. Production has no named
+ # task on purpose. Use `none`, not an empty value: Go template `default` treats ""
+ # as absent and would resolve back to dev.
+
dev:saas:
- desc: "Start backend in SaaS flavor against Supabase"
- # `dotenv:` reads from the root Taskfile's directory (".") because this
- # subtaskfile is included with `dir: .`.
+ desc: "Start SaaS backend against the current PR's Supabase preview branch"
+ dotenv: ['app/.env.saas.local', 'app/.env.saas']
+ vars:
+ PROFILES: '{{.PROFILES | default "dev"}}'
+ cmds:
+ # Don't move this check into a `sh:` var: dotenv is visible in cmds but not
+ # during var evaluation, so the test would always see an empty value.
+ - cmd: |
+ if [ "{{.PROFILES}}" = "dev" ] && [ -z "${SAAS_DEV_PROJECT_REF:-}" ]; then
+ echo ">> SAAS_DEV_PROJECT_REF is not set."
+ echo ">> Testing a SaaS PR? Put its ref, DB password and publishable key in app/.env.saas.local."
+ echo ">> Wanted the shared v3 project? Use 'task backend:staging:saas' instead."
+ exit 1
+ fi
+ - task: _run:saas
+ vars:
+ PORT: '{{.PORT}}'
+ PROFILES: '{{.PROFILES}}'
+ AIENGINE_URL: '{{.AIENGINE_URL}}'
+ AIENGINE_ENABLED: '{{.AIENGINE_ENABLED}}'
+ AIENGINE_TIMEOUTSECONDS: '{{.AIENGINE_TIMEOUTSECONDS}}'
+
+ staging:saas:
+ desc: "Start SaaS backend against the shared v3 staging project"
+ cmds:
+ - task: _run:saas
+ vars:
+ PORT: '{{.PORT}}'
+ PROFILES: staging
+ AIENGINE_URL: '{{.AIENGINE_URL}}'
+ AIENGINE_ENABLED: '{{.AIENGINE_ENABLED}}'
+ AIENGINE_TIMEOUTSECONDS: '{{.AIENGINE_TIMEOUTSECONDS}}'
+
+ _run:saas:
+ internal: true
dotenv: ['app/.env.saas.local', 'app/.env.saas']
ignore_error: true
vars:
PORT: '{{.PORT | default "8080"}}'
- # Override to "" to run the pure `saas` profile against your own SAAS_DB_*.
PROFILES: '{{.PROFILES | default "dev"}}'
+ # Built here rather than inline in the cmds below: the Windows line is an
+ # unquoted YAML scalar wrapping a cmd.exe string, so a nested {{if ne .X
+ # "none"}} needs escaped quotes that reach the Go template as literal
+ # backslashes and fail with `unexpected "\" in operand`.
+ PROFILE_ARGS: '{{if ne .PROFILES "none"}}--spring.profiles.include={{.PROFILES}}{{end}}'
AIENGINE_URL: '{{.AIENGINE_URL | default ""}}'
AIENGINE_ENABLED: '{{.AIENGINE_ENABLED | default "false"}}'
AIENGINE_TIMEOUTSECONDS: '{{.AIENGINE_TIMEOUTSECONDS | default "120"}}'
@@ -77,9 +118,11 @@ tasks:
AIENGINE_ENABLED: '{{.AIENGINE_ENABLED}}'
AIENGINE_TIMEOUTSECONDS: '{{.AIENGINE_TIMEOUTSECONDS}}'
cmds:
- - cmd: cmd /c ".\gradlew.bat :stirling-pdf:bootRun {{if .PROFILES}}--args=\"--spring.profiles.include={{.PROFILES}}\"{{end}}"
+ # PROFILE_ARGS is empty when PROFILES=none, i.e. the bare `saas` profile
+ # against SAAS_DB_* (production).
+ - cmd: cmd /c ".\gradlew.bat :stirling-pdf:bootRun {{if .PROFILE_ARGS}}--args=\"{{.PROFILE_ARGS}}\"{{end}}"
platforms: [windows]
- - cmd: ./gradlew :stirling-pdf:bootRun {{if .PROFILES}}--args='--spring.profiles.include={{.PROFILES}}'{{end}}
+ - cmd: ./gradlew :stirling-pdf:bootRun {{if .PROFILE_ARGS}}--args='{{.PROFILE_ARGS}}'{{end}}
platforms: [linux, darwin]
build:
diff --git a/.taskfiles/frontend.yml b/.taskfiles/frontend.yml
index 481a225ce1..f5325c9ed7 100644
--- a/.taskfiles/frontend.yml
+++ b/.taskfiles/frontend.yml
@@ -5,6 +5,14 @@ version: '3'
# mode flag) or use `--project editor/...` for tsc — so the editor lives
# under frontend/editor/ without each task needing a cd.
+vars:
+ # Dev-only browser-tab label so concurrent worktrees are distinguishable. Only
+ # the worktree folder basename (e.g. "wt1") is exposed — never the full path,
+ # hostname, or user. Dropped from production builds.
+ DEV_LABEL:
+ sh: >-
+ {{if eq OS "windows"}}powershell -NoProfile -Command '$root = git rev-parse --show-toplevel 2>$null; if (-not $root) { $root = (Get-Location).Path }; Split-Path -Leaf $root'{{else}}basename "$(git rev-parse --show-toplevel 2>/dev/null || pwd)"{{end}}
+
tasks:
install:
desc: "Install dependencies"
@@ -80,16 +88,52 @@ tasks:
OPEN: '{{.OPEN | default ""}}'
env:
BACKEND_URL: '{{.BACKEND_URL}}'
- # Dev-only browser-tab label so concurrent worktrees are distinguishable.
- # Only the worktree folder basename (e.g. "wt1") is exposed — never the
- # full path, hostname, or user. Consumed at dev-serve time by vite.config
- # and dropped from production builds.
- STIRLING_DEV_LABEL:
- sh: >-
- {{if eq OS "windows"}}powershell -NoProfile -Command '$root = git rev-parse --show-toplevel 2>$null; if (-not $root) { $root = (Get-Location).Path }; Split-Path -Leaf $root'{{else}}basename "$(git rev-parse --show-toplevel 2>/dev/null || pwd)"{{end}}
+ STIRLING_DEV_LABEL: '{{.DEV_LABEL}}'
cmds:
- npx vite editor --mode {{.MODE}} --port {{.PORT}}{{if .OPEN}} --open{{end}}
+ # Separate from dev:_run rather than a flag on it: Task sets an `env:` key even
+ # when its value resolves to empty, and Vite treats an empty process.env VITE_* as
+ # authoritative over the committed editor/.env, so folding these in blanks Supabase
+ # config for the core, proprietary and desktop dev servers.
+ dev:_run:saas:
+ internal: true
+ ignore_error: true
+ # The backend's own env files, so both halves target one project. Paths are
+ # relative to this taskfile's dir, `frontend`.
+ dotenv: ['../app/.env.saas.local', '../app/.env.saas']
+ vars:
+ PORT: '{{.PORT | default "5173"}}'
+ BACKEND_URL: '{{.BACKEND_URL | default "http://localhost:8080"}}'
+ OPEN: '{{.OPEN | default ""}}'
+ SAAS_ENV: '{{.SAAS_ENV | default "dev"}}'
+ env:
+ BACKEND_URL: '{{.BACKEND_URL}}'
+ STIRLING_DEV_LABEL: '{{.DEV_LABEL}}'
+ SAAS_ENV: '{{.SAAS_ENV}}'
+ # A real process.env VITE_* beats a committed .env in Vite (loadEnv applies
+ # process.env last), which is what lets this override editor/.env.
+ #
+ # These must stay `sh:`, not Go templates: dotenv values are visible to Task's
+ # embedded shell but not to templates, where {{.SAAS_DEV_PROJECT_REF}} is
+ # always empty.
+ VITE_SUPABASE_URL:
+ sh: |
+ case "${SAAS_ENV:-dev}" in
+ staging) ref="${SAAS_STAGING_PROJECT_REF:?set it in app/.env.saas.local}" ;;
+ *) ref="${SAAS_DEV_PROJECT_REF:?set it in app/.env.saas.local, or run task staging:saas}" ;;
+ esac
+ echo "https://${ref}.supabase.co"
+ VITE_SUPABASE_PUBLISHABLE_DEFAULT_KEY:
+ sh: |
+ case "${SAAS_ENV:-dev}" in
+ staging) echo "${SAAS_STAGING_PUBLISHABLE_KEY:?set it in app/.env.saas.local}" ;;
+ *) echo "${SAAS_DEV_PUBLISHABLE_KEY:?set it in app/.env.saas.local}" ;;
+ esac
+ cmds:
+ - 'echo ">> frontend Supabase target: $VITE_SUPABASE_URL"'
+ - npx vite editor --mode saas --port {{.PORT}}{{if .OPEN}} --open{{end}}
+
dev:
desc: "Start frontend dev server"
cmds:
@@ -111,13 +155,23 @@ tasks:
vars: { MODE: proprietary, PORT: '{{.PORT}}', BACKEND_URL: '{{.BACKEND_URL}}', OPEN: '{{.OPEN}}' }
dev:saas:
- desc: "Start frontend dev server in SaaS mode"
+ desc: "Start frontend dev server in SaaS mode (SAAS_ENV=dev|staging|prod)"
deps:
- task: prepare
vars: { MODE: saas }
+ vars:
+ SAAS_ENV: '{{.SAAS_ENV | default "dev"}}'
+ # prod routes to the plain runner, which sets no VITE_SUPABASE_* and so leaves
+ # the committed editor/.env alone.
+ RUNNER: '{{if eq .SAAS_ENV "prod"}}dev:_run{{else}}dev:_run:saas{{end}}'
cmds:
- - task: dev:_run
- vars: { MODE: saas, PORT: '{{.PORT}}', BACKEND_URL: '{{.BACKEND_URL}}', OPEN: '{{.OPEN}}' }
+ - task: '{{.RUNNER}}'
+ vars:
+ MODE: saas
+ PORT: '{{.PORT}}'
+ BACKEND_URL: '{{.BACKEND_URL}}'
+ OPEN: '{{.OPEN}}'
+ SAAS_ENV: '{{.SAAS_ENV}}'
dev:desktop:
desc: "Start frontend dev server in desktop mode"
diff --git a/Taskfile.yml b/Taskfile.yml
index fc7a564032..92dcdcc742 100644
--- a/Taskfile.yml
+++ b/Taskfile.yml
@@ -99,11 +99,22 @@ tasks:
BACKEND_URL: 'http://localhost:{{.BACKEND_PORT}}'
OPEN: "true"
+ # Set SAAS_DEV_PROJECT_REF in app/.env.saas.local to pick the PR.
dev:saas:
- desc: "Start SaaS backend + frontend concurrently on free ports"
+ desc: "Start SaaS backend + frontend + engine against the current PR's preview branch"
cmds:
- task: dev:_all
- vars: { FRONTEND: saas, BACKEND: saas }
+ vars: { FRONTEND: saas, BACKEND: saas, SAAS_ENV: dev }
+
+ staging:saas:
+ desc: "Start SaaS backend + frontend + engine against the shared v3 staging project"
+ cmds:
+ - task: dev:_all
+ vars:
+ FRONTEND: saas
+ BACKEND: saas
+ BACKEND_TASK: backend:staging:saas
+ SAAS_ENV: staging
dev:all:
desc: "Start backend + frontend + engine concurrently on free ports"
@@ -115,6 +126,9 @@ tasks:
vars:
FRONTEND: '{{.FRONTEND | default "proprietary"}}'
BACKEND: '{{.BACKEND | default "proprietary"}}'
+ BACKEND_TASK: '{{.BACKEND_TASK | default (printf "backend:dev:%s" .BACKEND)}}'
+ # Only meaningful to the saas frontend; every other flavor ignores it.
+ SAAS_ENV: '{{.SAAS_ENV | default ""}}'
PORTS:
sh: '{{if eq OS "windows"}}{{.FIND_FREE_PORT_PS}} 8080 5173 5001{{else}}{{.FIND_FREE_PORT_SH}} 8080 5173 5001{{end}}'
BACKEND_PORT: '{{index (splitList "\n" .PORTS) 0}}'
@@ -124,7 +138,7 @@ tasks:
- task: engine:dev
vars:
PORT: '{{.ENGINE_PORT}}'
- - task: 'backend:dev:{{.BACKEND}}'
+ - task: '{{.BACKEND_TASK}}'
vars:
PORT: '{{.BACKEND_PORT}}'
AIENGINE_URL: 'http://localhost:{{.ENGINE_PORT}}'
@@ -134,6 +148,7 @@ tasks:
PORT: '{{.FRONTEND_PORT}}'
BACKEND_URL: 'http://localhost:{{.BACKEND_PORT}}'
OPEN: "true"
+ SAAS_ENV: '{{.SAAS_ENV}}'
# ============================================================
# Build
diff --git a/app/.env.saas b/app/.env.saas
index fb5feec559..25eefb84c5 100644
--- a/app/.env.saas
+++ b/app/.env.saas
@@ -1,15 +1,16 @@
-###############################################################################
-# Stirling-PDF SaaS environment defaults.
+# Stirling-PDF SaaS environment defaults. Committed, non-secret. Real values for secrets go in
+# .env.saas.local, which is loaded first and wins. Do not commit that file.
#
-# This file is committed and provides non-secret defaults loaded by
-# `task backend:dev:saas`. Put real values for secrets (passwords, project
-# refs, edge function secrets) in `.env.saas.local` - any variable set there
-# takes precedence over what's defined here.
+# Three environments, each deriving its Supabase URLs, JWT issuer and JWKS from one project ref:
#
-# DO NOT commit `.env.saas.local`. Only `.env.saas` is checked in.
-###############################################################################
+# prod PROFILES=none SAAS_DB_* the live project
+# staging PROFILES=staging SAAS_STAGING_* pinned to v3, always there
+# dev PROFILES=dev SAAS_DEV_* follows a SaaS PR's preview branch
+#
+# dev is the default for `task backend:dev:saas`. Use staging for somewhere stable; use dev when
+# testing an open SaaS PR, since its preview branch is the only place those migrations are applied.
-# ---------- Supabase project ----------
+# ---------- Supabase project (prod / no-profile) ----------
# Project reference (the subdomain part of .supabase.co). Required.
# Set in .env.saas.local.
SAAS_DB_PROJECT_REF=
@@ -17,18 +18,35 @@ SAAS_DB_PROJECT_REF=
# Edge function secret used by billing/license rollup calls. Set in .env.saas.local.
SUPABASE_EDGE_FUNCTION_SECRET=
-# ---------- Database (saas profile) ----------
-# Direct JDBC URL to the Supabase Postgres. Required when running the plain
-# `saas` profile (i.e. without `--spring.profiles.include=dev`).
+# ---------- Database (no profile) ----------
+# Direct JDBC URL to the Supabase Postgres. Required when running without
+# `--spring.profiles.include=...`.
# Example: jdbc:postgresql://db..supabase.co:5432/postgres
SAAS_DB_URL=
SAAS_DB_USERNAME=postgres
SAAS_DB_PASSWORD=
-# ---------- Database (dev profile overrides) ----------
-# Used when `--spring.profiles.include=dev` is active. The dev profile
-# defaults the URL/username to the shared dev Supabase project, but the
-# password must still be provided in .env.saas.local.
-SAAS_DEV_DB_URL=
+# ---------- staging profile ----------
+# The shared long-lived v3 project. application-staging.properties defaults the ref,
+# URL, database host and meter endpoint, so staging needs only the password, in
+# .env.saas.local. Set SAAS_STAGING_PROJECT_REF to repoint it; everything derives.
+#
+# The ref and publishable key are duplicated here because the task derives the
+# frontend's VITE_SUPABASE_* from them and a shell cannot read a Spring default.
+# Neither is secret: the ref is a public subdomain, the key ships in the bundle.
+SAAS_STAGING_PROJECT_REF=qacaivhsjtftfwtgjvva
+SAAS_STAGING_PUBLISHABLE_KEY=sb_publishable_nIM8y-9ARPE7EzQwAQHKMg_40fCN6kY # gitleaks:allow
+SAAS_STAGING_DB_USERNAME=postgres
+SAAS_STAGING_DB_PASSWORD=
+
+# ---------- dev profile ----------
+# The SaaS PR's Supabase preview branch. Take the ref from that PR's "Supabase
+# Preview" check; the profile derives URL, JWT issuer, JWKS, meter endpoint and
+# database host from it, so this one value follows a different PR.
+#
+# A preview branch has its own password and keys; the parent project's will not
+# authenticate. Both go in .env.saas.local, along with the ref.
+SAAS_DEV_PROJECT_REF=
+SAAS_DEV_PUBLISHABLE_KEY=
SAAS_DEV_DB_USERNAME=postgres
SAAS_DEV_DB_PASSWORD=
diff --git a/app/saas/src/main/java/stirling/software/saas/config/SaasProjectNotice.java b/app/saas/src/main/java/stirling/software/saas/config/SaasProjectNotice.java
new file mode 100644
index 0000000000..39305fcbcd
--- /dev/null
+++ b/app/saas/src/main/java/stirling/software/saas/config/SaasProjectNotice.java
@@ -0,0 +1,53 @@
+package stirling.software.saas.config;
+
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.boot.context.event.ApplicationReadyEvent;
+import org.springframework.context.annotation.Profile;
+import org.springframework.context.event.EventListener;
+import org.springframework.core.env.Environment;
+import org.springframework.stereotype.Component;
+
+import lombok.extern.slf4j.Slf4j;
+
+/** Logs which Supabase project this backend is talking to, and its schema policy. */
+@Slf4j
+@Component
+@Profile({"dev", "staging"})
+public class SaasProjectNotice {
+
+ private final Environment environment;
+ private final String projectRef;
+ private final String ddlAuto;
+
+ public SaasProjectNotice(
+ Environment environment,
+ @Value("${app.supabase.project-ref:unknown}") String projectRef,
+ @Value("${spring.jpa.hibernate.ddl-auto:none}") String ddlAuto) {
+ this.environment = environment;
+ this.projectRef = projectRef;
+ this.ddlAuto = ddlAuto;
+ }
+
+ @EventListener(ApplicationReadyEvent.class)
+ public void announceProject() {
+ boolean staging = environment.matchesProfiles("staging");
+ if (staging) {
+ log.info(
+ """
+ SaaS staging profile: Supabase project {}, ddl-auto={}. This is the SHARED \
+ long-lived environment, so its data and schema are not yours alone. Testing an \
+ open SaaS PR? Use that PR's preview branch instead \
+ (SAAS_DEV_PROJECT_REF in app/.env.saas.local); staging will not have its \
+ migrations.\
+ """,
+ projectRef,
+ ddlAuto);
+ return;
+ }
+ log.info(
+ "SaaS dev profile: Supabase preview branch {}, ddl-auto={}. Disposable, so Hibernate"
+ + " is allowed to add the inherited tables the migrations do not create.",
+ projectRef,
+ ddlAuto);
+ }
+}
diff --git a/app/saas/src/main/resources/application-dev.properties b/app/saas/src/main/resources/application-dev.properties
index ee8bf80ff2..b289fc95cc 100644
--- a/app/saas/src/main/resources/application-dev.properties
+++ b/app/saas/src/main/resources/application-dev.properties
@@ -1,32 +1,40 @@
-# SaaS dev profile. Points at the dev Supabase project.
-# Boot: java -jar stirling-pdf.jar --spring.profiles.include=dev
+# SaaS dev profile: follows the Supabase preview branch of the SaaS PR under test.
+# One variable switches PR, SAAS_DEV_PROJECT_REF; everything else derives from it.
+# Want a stable shared environment instead? Use the staging profile.
+
spring.config.import=optional:classpath:application-dev-local.properties
-app.supabase.project-ref=qacaivhsjtftfwtgjvva
+# Let Hibernate reconcile the entity tables so a fresh preview branch heals itself. A branch is built
+# from the Supabase migrations, which cover the SaaS-owned tables but not the ~28 inherited from the
+# self-hosted app -- those have only ever been created by ddl-auto. Safe here because a preview branch
+# is disposable and `update` only ever adds; staging pins `none`, so keep this profile-scoped.
+spring.jpa.hibernate.ddl-auto=update
-stirling.supabase.url=https://qacaivhsjtftfwtgjvva.supabase.co
-stirling.supabase.publishable-key=sb_publishable_nIM8y-9ARPE7EzQwAQHKMg_40fCN6kY # gitleaks:allow
+# From the PR's "Supabase Preview" check. Required with no fallback: ddl-auto=update above must never
+# be aimed at the shared project.
+app.supabase.project-ref=${SAAS_DEV_PROJECT_REF}
-spring.datasource.url=${SAAS_DEV_DB_URL:jdbc:postgresql://db.qacaivhsjtftfwtgjvva.supabase.co:5432/postgres?ApplicationName=stirling-consolidation-${user.name}}
+stirling.supabase.url=https://${app.supabase.project-ref}.supabase.co
+# Per-branch, not derivable. Dashboard > Settings > API.
+stirling.supabase.publishable-key=${SAAS_DEV_PUBLISHABLE_KEY}
+
+# Override the whole URL if the branch needs the pooler host rather than the direct one.
+spring.datasource.url=${SAAS_DEV_DB_URL:jdbc:postgresql://db.${app.supabase.project-ref}.supabase.co:5432/postgres?ApplicationName=stirling-dev-${user.name}}
spring.datasource.username=${SAAS_DEV_DB_USERNAME:postgres}
-# Password not committed; export SAAS_DEV_DB_PASSWORD or pass --spring.datasource.password=...
+# A preview branch has its own password; the parent project's will not authenticate.
spring.datasource.password=${SAAS_DEV_DB_PASSWORD:}
-# Conservative dev pool sizing.
spring.datasource.hikari.maximum-pool-size=2
spring.datasource.hikari.minimum-idle=1
spring.datasource.hikari.idle-timeout=60000
spring.datasource.hikari.max-lifetime=1800000
spring.datasource.hikari.keepalive-time=300000
-spring.datasource.hikari.data-source-properties.ApplicationName=stirling-consolidation-${user.name}
+spring.datasource.hikari.data-source-properties.ApplicationName=stirling-dev-${user.name}
logging.level.stirling.software.saas=DEBUG
logging.level.org.springframework.security.oauth2.jwt=WARN
logging.level.org.springframework.security.oauth2.server.resource=WARN
-# Supabase meter edge fn the Java backend calls (server-to-server, on job close).
-# URL is not a secret; auth rides the existing SUPABASE_EDGE_FUNCTION_SECRET (same
-# shared secret the team-invitation flow uses — no service-role key in the Java env).
-# Blank secret → the meter service no-ops with a WARN, so the app still boots.
-# The billing portal is NOT here — the FE calls create-customer-portal-session directly.
-payg.meter.endpoint=https://qacaivhsjtftfwtgjvva.supabase.co/functions/v1/meter-payg-units
+# Server-to-server meter call. Auth rides SUPABASE_EDGE_FUNCTION_SECRET; blank secret means the meter
+# service no-ops with a WARN rather than failing the boot.
+payg.meter.endpoint=https://${app.supabase.project-ref}.supabase.co/functions/v1/meter-payg-units
diff --git a/app/saas/src/main/resources/application-staging.properties b/app/saas/src/main/resources/application-staging.properties
new file mode 100644
index 0000000000..4874ed3fb5
--- /dev/null
+++ b/app/saas/src/main/resources/application-staging.properties
@@ -0,0 +1,39 @@
+# SaaS staging profile: the long-lived shared v3 project, pinned so it is still there tomorrow.
+# For work on an open SaaS PR use the dev profile, which follows that PR's preview branch.
+
+spring.config.import=optional:classpath:application-staging-local.properties
+
+# Stated rather than inherited: application-saas.properties defaults to `update`, and staging's
+# schema is shared and RLS-dependent, so it must not be reconciled by Hibernate.
+spring.jpa.hibernate.ddl-auto=none
+
+# Committed as a default rather than a literal, so staging needs no setup but stays repointable.
+# Neither the ref nor the publishable key is secret: the ref is a public subdomain, the key ships in
+# the browser bundle. Everything below derives from the ref, so an override follows through.
+app.supabase.project-ref=${SAAS_STAGING_PROJECT_REF:qacaivhsjtftfwtgjvva}
+
+stirling.supabase.url=https://${app.supabase.project-ref}.supabase.co
+stirling.supabase.publishable-key=${SAAS_STAGING_PUBLISHABLE_KEY:sb_publishable_nIM8y-9ARPE7EzQwAQHKMg_40fCN6kY}
+
+spring.datasource.url=${SAAS_STAGING_DB_URL:jdbc:postgresql://db.${app.supabase.project-ref}.supabase.co:5432/postgres?ApplicationName=stirling-staging-${user.name}}
+spring.datasource.username=${SAAS_STAGING_DB_USERNAME:postgres}
+# Password not committed; export SAAS_STAGING_DB_PASSWORD or pass --spring.datasource.password=...
+spring.datasource.password=${SAAS_STAGING_DB_PASSWORD:}
+
+# Conservative pool sizing: this is a shared project, so don't hold connections others need.
+spring.datasource.hikari.maximum-pool-size=2
+spring.datasource.hikari.minimum-idle=1
+spring.datasource.hikari.idle-timeout=60000
+spring.datasource.hikari.max-lifetime=1800000
+spring.datasource.hikari.keepalive-time=300000
+spring.datasource.hikari.data-source-properties.ApplicationName=stirling-staging-${user.name}
+
+logging.level.stirling.software.saas=DEBUG
+logging.level.org.springframework.security.oauth2.jwt=WARN
+logging.level.org.springframework.security.oauth2.server.resource=WARN
+
+# Supabase meter edge fn the Java backend calls (server-to-server, on job close).
+# URL is not a secret; auth rides the existing SUPABASE_EDGE_FUNCTION_SECRET (same
+# shared secret the team-invitation flow uses — no service-role key in the Java env).
+# Blank secret → the meter service no-ops with a WARN, so the app still boots.
+payg.meter.endpoint=https://${app.supabase.project-ref}.supabase.co/functions/v1/meter-payg-units
From 088e0ef4e25e06ae5f911f8be74912fef8673240 Mon Sep 17 00:00:00 2001
From: Ludy
Date: Wed, 19 Aug 2026 18:29:02 +0000
Subject: [PATCH 4/8] deps(frontend): upgrade Cantoo PDF library to 2.8.2
(#7493)
# Description of Changes
This pull request upgrades the frontend PDF dependency from
`@cantoo/pdf-lib` 2.6.5 to 2.8.2.
- Updated `frontend/package.json` to require `@cantoo/pdf-lib` `^2.8.2`.
- Regenerated `frontend/package-lock.json` with `@cantoo/pdf-lib@2.8.2`,
`pako@2.2.0`, and `node-html-better-parser@1.5.9`.
- Added the root npm `pako` override recommended by the upstream
release.
- The upgrade brings upstream parser, object-stream, encryption, form,
PNG, and PDF serialization fixes into the frontend dependency.
- No application API migration was required because the project does not
use the newly added PDF/A, XFA, Factur-X, incremental-update, fontkit,
or page-content-extraction APIs.
The main challenge was validating the broad upstream change set against
the project's actual usage. The frontend typecheck and a direct PDF
create/save/load smoke test passed. The complete `frontend:check` and
`frontend:test` tasks exceeded the available execution timeout without
reporting a test failure.
No related issue.
---
## Checklist
### General
- [ ] I have read the [Contribution
Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md)
- [ ] I have read the [Stirling-PDF Developer
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md)
(if applicable)
- [ ] I have read the [How to add new languages to
Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/HowToAddNewLanguage.md)
(if applicable)
- [x] I have performed a self-review of my own code
- [ ] My changes generate no new warnings
### Documentation
- [ ] I have updated relevant docs on [Stirling-PDF's doc
repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/)
(if functionality has heavily changed)
- [ ] I have read the section [Add New Translation
Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/HowToAddNewLanguage.md#add-new-translation-tags)
(for new translation tags only)
### UI Changes (if applicable)
- [ ] Screenshots or videos demonstrating the UI changes are attached
(e.g., as comments or direct attachments in the PR)
### Testing (if applicable)
- [x] I have tested my changes locally. Refer to the [Testing
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md#6-testing)
for more details.
---
frontend/package-lock.json | 38 ++++++++++++++++++++++++++------------
frontend/package.json | 3 ++-
2 files changed, 28 insertions(+), 13 deletions(-)
diff --git a/frontend/package-lock.json b/frontend/package-lock.json
index d50868bd60..37bca97b95 100644
--- a/frontend/package-lock.json
+++ b/frontend/package-lock.json
@@ -10,7 +10,7 @@
"license": "SEE LICENSE IN https://raw.githubusercontent.com/Stirling-Tools/Stirling-PDF/refs/heads/main/proprietary/LICENSE",
"dependencies": {
"@atlaskit/pragmatic-drag-and-drop": "^1.7.7",
- "@cantoo/pdf-lib": "^2.5.3",
+ "@cantoo/pdf-lib": "^2.8.2",
"@dnd-kit/core": "^6.3.1",
"@embedpdf/core": "^2.14.4",
"@embedpdf/engines": "^2.14.4",
@@ -606,18 +606,22 @@
}
},
"node_modules/@cantoo/pdf-lib": {
- "version": "2.6.5",
- "resolved": "https://registry.npmjs.org/@cantoo/pdf-lib/-/pdf-lib-2.6.5.tgz",
- "integrity": "sha512-3eMHEaqKHt/G/q+6QjT06A3lz0S/a8x3+myiSN7FNeL3uWcedO0lpfs6TWofa4C03Z1wz3tWeHoa4CsI7DrTSA==",
+ "version": "2.8.2",
+ "resolved": "https://registry.npmjs.org/@cantoo/pdf-lib/-/pdf-lib-2.8.2.tgz",
+ "integrity": "sha512-f0BJM3uPOjbPR3YriSEUIaTM0qnqthjFmTZX9NGI0NDM2Tj4a8xv7Z5Hb6jzUrhfb3/9Y77+xxoOln0IIiYq+w==",
"license": "MIT",
"dependencies": {
"@pdf-lib/standard-fonts": "^1.0.0",
"@pdf-lib/upng": "^1.0.1",
"color": "^4.2.3",
"crypto-js": "^4.2.0",
- "node-html-better-parser": ">=1.4.0",
- "pako": "^1.0.11",
+ "html-entities": "^2.3.2",
+ "node-html-better-parser": ">=1.5.9",
+ "pako": "^2.2.0",
"tslib": ">=2"
+ },
+ "peerDependencies": {
+ "html-entities": "^2.3.2"
}
},
"node_modules/@csstools/color-helpers": {
@@ -12459,9 +12463,9 @@
}
},
"node_modules/node-html-better-parser": {
- "version": "1.5.8",
- "resolved": "https://registry.npmjs.org/node-html-better-parser/-/node-html-better-parser-1.5.8.tgz",
- "integrity": "sha512-t/wAKvaTSKco43X+yf9+76RiMt18MtMmzd4wc7rKj+fWav6DV4ajDEKdWlLzSE8USDF5zr/06uGj0Wr/dGAFtw==",
+ "version": "1.5.9",
+ "resolved": "https://registry.npmjs.org/node-html-better-parser/-/node-html-better-parser-1.5.9.tgz",
+ "integrity": "sha512-z1I5UINMezJXYL9cH3h0a9KBth2G978gSLlfkpQ+CQzzVHVQy9gpARgm9eDsz1O4gn1HtgUqjdAIYxKFZm6uHQ==",
"license": "MIT",
"dependencies": {
"html-entities": "^2.3.2"
@@ -12751,9 +12755,19 @@
"license": "MIT"
},
"node_modules/pako": {
- "version": "1.0.11",
- "resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz",
- "integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==",
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/pako/-/pako-2.2.0.tgz",
+ "integrity": "sha512-zJq6RP/5q+TO2OpFV3FHzlPnFjmkb7Nc99a5SNjJE+uu/PkpChs+NIZSSzbBoD+6kjiISXjfYdwj1ZRQ81dz/w==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/puzrin"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/nodeca"
+ }
+ ],
"license": "(MIT AND Zlib)"
},
"node_modules/parent-module": {
diff --git a/frontend/package.json b/frontend/package.json
index 90a0e10b05..f874dedfa3 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -7,7 +7,7 @@
"proxy": "http://localhost:8080",
"dependencies": {
"@atlaskit/pragmatic-drag-and-drop": "^1.7.7",
- "@cantoo/pdf-lib": "^2.5.3",
+ "@cantoo/pdf-lib": "^2.8.2",
"@dnd-kit/core": "^6.3.1",
"@embedpdf/core": "^2.14.4",
"@embedpdf/engines": "^2.14.4",
@@ -171,6 +171,7 @@
},
"overrides": {
"devalue": "^5.8.1",
+ "pako": "^2.2.0",
"tsconfck": {
"typescript": "$typescript"
}
From 1690cc25ccbf100f3be1699ac098bca419f1ca6a Mon Sep 17 00:00:00 2001
From: "stirlingbot[bot]" <195170888+stirlingbot[bot]@users.noreply.github.com>
Date: Thu, 20 Aug 2026 05:15:37 +0000
Subject: [PATCH 5/8] Update Frontend 3rd Party Licenses (#7573)
Auto-generated by stirlingbot[bot]
This PR updates the frontend license report based on changes to
package.json dependencies.
Signed-off-by: stirlingbot[bot]
Co-authored-by: stirlingbot[bot] <195170888+stirlingbot[bot]@users.noreply.github.com>
---
frontend/editor/src/assets/3rdPartyLicenses.json | 13 ++++++++++---
1 file changed, 10 insertions(+), 3 deletions(-)
diff --git a/frontend/editor/src/assets/3rdPartyLicenses.json b/frontend/editor/src/assets/3rdPartyLicenses.json
index 110a6c88a5..e17cef8f8b 100644
--- a/frontend/editor/src/assets/3rdPartyLicenses.json
+++ b/frontend/editor/src/assets/3rdPartyLicenses.json
@@ -10,7 +10,7 @@
{
"moduleName": "@cantoo/pdf-lib",
"moduleUrl": "https://github.com/cantoo-scribe/pdf-lib",
- "moduleVersion": "2.6.5",
+ "moduleVersion": "2.8.2",
"moduleLicense": "MIT",
"moduleLicenseUrl": "https://opensource.org/licenses/MIT"
},
@@ -255,14 +255,14 @@
{
"moduleName": "@stripe/react-stripe-js",
"moduleUrl": "https://github.com/stripe/react-stripe-js",
- "moduleVersion": "4.0.2",
+ "moduleVersion": "6.8.0",
"moduleLicense": "MIT",
"moduleLicenseUrl": "https://opensource.org/licenses/MIT"
},
{
"moduleName": "@stripe/stripe-js",
"moduleUrl": "https://github.com/stripe/stripe-js",
- "moduleVersion": "7.9.0",
+ "moduleVersion": "9.10.0",
"moduleLicense": "MIT",
"moduleLicenseUrl": "https://opensource.org/licenses/MIT"
},
@@ -287,6 +287,13 @@
"moduleLicense": "MIT",
"moduleLicenseUrl": "https://opensource.org/licenses/MIT"
},
+ {
+ "moduleName": "@tanstack/react-table",
+ "moduleUrl": "https://github.com/TanStack/table",
+ "moduleVersion": "9.1.2",
+ "moduleLicense": "MIT",
+ "moduleLicenseUrl": "https://opensource.org/licenses/MIT"
+ },
{
"moduleName": "@tanstack/react-virtual",
"moduleUrl": "https://github.com/TanStack/virtual",
From a744102cb68441a5dcf9b436a77919cf73c7eaab Mon Sep 17 00:00:00 2001
From: James Brunton
Date: Thu, 20 Aug 2026 08:23:20 +0000
Subject: [PATCH 6/8] Support Supporting Files in Pipelines (#7547)
# Description of Changes
Currently in the Processor's Pipelines page, none of the tools which
require supporting files are usable because it's never been hooked up to
the new API to upload supporting files. This PR hooks it up to that so
all tools using supporting files work in the processor. I had to tweak
the type generation a little for this so we have a static map of which
params are for supporting files so we know to handle them differently.
The `Test with a file` button has to work a little differently than the
main run since it's running an ad-hoc pipeline so the files haven't
necessarily been saved to the server yet. In this case, it'll use
whatever local changes the user has made for those pipeline steps, and
for all other steps, it'll just use what's saved in the server.
---
.../policy/controller/PolicyController.java | 42 +++-
.../controller/PolicyControllerTest.java | 48 +++-
.../public/locales/en-US/translation.toml | 4 +-
.../scripts/generate-tool-api-types.mts | 64 ++++-
.../hooks/tools/shared/toolApiMapping.test.ts | 19 +-
.../core/hooks/tools/shared/toolApiMapping.ts | 22 +-
.../hooks/tools/shared/toolAutomation.test.ts | 178 +++++++++++++-
.../core/hooks/tools/shared/toolAutomation.ts | 223 +++++++++++++++++-
.../hooks/tools/shared/toolOperationTypes.ts | 47 +++-
.../editor/src/core/types/toolApiTypes.ts | 46 ++--
.../editor/src/portal/api/pipelineAssets.ts | 41 ++++
frontend/editor/src/portal/api/pipelines.ts | 28 ++-
.../pipelines/PipelineStepSettings.css | 17 ++
.../PipelineStepSettings.stories.tsx | 2 +
.../pipelines/PipelineStepSettings.test.tsx | 10 +
.../pipelines/PipelineStepSettings.tsx | 146 +++++++++---
.../src/portal/mocks/handlers/pipelines.ts | 42 ++++
.../src/portal/views/PipelineBuilder.test.tsx | 122 +++++++++-
.../src/portal/views/PipelineBuilder.tsx | 201 +++++++++++++---
19 files changed, 1148 insertions(+), 154 deletions(-)
create mode 100644 frontend/editor/src/portal/api/pipelineAssets.ts
create mode 100644 frontend/editor/src/portal/components/pipelines/PipelineStepSettings.css
diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/controller/PolicyController.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/controller/PolicyController.java
index 778a04e169..9b9fca4133 100644
--- a/app/proprietary/src/main/java/stirling/software/proprietary/policy/controller/PolicyController.java
+++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/controller/PolicyController.java
@@ -21,6 +21,7 @@ import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RequestPart;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.context.request.RequestContextHolder;
@@ -51,6 +52,7 @@ import stirling.software.common.util.TempFile;
import stirling.software.common.util.TempFileManager;
import stirling.software.proprietary.audit.AuditContext;
import stirling.software.proprietary.policy.asset.PolicyAssetCleaner;
+import stirling.software.proprietary.policy.asset.PolicyAssetResolver;
import stirling.software.proprietary.policy.config.PolicyAccessGuard;
import stirling.software.proprietary.policy.config.PolicyManagementAuthority;
import stirling.software.proprietary.policy.engine.PolicyRunHandle;
@@ -106,6 +108,7 @@ public class PolicyController {
private final PolicyTriggerManager policyTriggerManager;
private final PolicyOverviewService policyOverviewService;
private final PolicyAssetCleaner assetCleaner;
+ private final PolicyAssetResolver assetResolver;
private final ProcessedLedger processedLedger;
private final List policyTriggers;
private final ApplicationProperties applicationProperties;
@@ -125,12 +128,13 @@ public class PolicyController {
+ " endpoint and download outputs via /api/v1/general/files/{id}.")
public ResponseEntity> run(
@RequestPart("json") PipelineDefinition definition,
+ @RequestParam(value = "policyId", required = false) String policyId,
@Valid @ModelAttribute PolicyRunFiles files)
throws IOException {
stampPolicyAudit(definition);
requireRunnable(definition);
validateAdHocRun(definition);
- PolicyInputs inputs = toInputs(files);
+ PolicyInputs inputs = resolveStoredAssets(policyId, toInputs(files));
PolicyRunHandle handle =
policyRunner.runAdHoc(definition, inputs, PolicyProgressListener.NOOP);
recordEditorDocs(inputs);
@@ -146,12 +150,13 @@ public class PolicyController {
+ " 'cancelled', or 'waiting' event carrying the final run view.")
public SseEmitter runStream(
@RequestPart("json") PipelineDefinition definition,
+ @RequestParam(value = "policyId", required = false) String policyId,
@Valid @ModelAttribute PolicyRunFiles files)
throws IOException {
stampPolicyAudit(definition);
requireRunnable(definition);
validateAdHocRun(definition);
- PolicyInputs inputs = toInputs(files);
+ PolicyInputs inputs = resolveStoredAssets(policyId, toInputs(files));
SseEmitter emitter =
new SseEmitter(applicationProperties.getPolicies().getStreamTimeoutMs());
@@ -438,10 +443,7 @@ public class PolicyController {
* folder sources/outputs is enforced separately by {@link PolicyValidator} at validation time.
*/
private void requirePolicyEditingAllowed() {
- if (!applicationProperties.getSecurity().isEnableLogin()) {
- return;
- }
- if (!policyManagementAuthority.canEditPolicies()) {
+ if (!policyEditingAllowed()) {
throw new ResponseStatusException(
HttpStatus.FORBIDDEN,
"Policies may only be created or modified by a team leader");
@@ -467,6 +469,15 @@ public class PolicyController {
}
}
+ /**
+ * Whether the caller may create/modify policies (a team leader, or any operator when login is
+ * off).
+ */
+ private boolean policyEditingAllowed() {
+ return !applicationProperties.getSecurity().isEnableLogin()
+ || policyManagementAuthority.canEditPolicies();
+ }
+
@GetMapping
@Operation(
summary = "List policies",
@@ -672,6 +683,25 @@ public class PolicyController {
inputs.primary().size());
}
+ /**
+ * Resolve a test run's stored {@code asset:} bindings from the saved policy the builder is
+ * editing, so their bytes need not be re-uploaded. Scoped to that policy (the resolver loads
+ * only the assets it references, in its own team) and gated to policy editors - the same
+ * authority that can read asset bytes - so a member can't rebind a policy's stored asset into
+ * an ad-hoc step to read it back. A blank id (an unsaved pipeline has no stored bindings) or an
+ * inaccessible policy leaves the run-supplied inputs untouched.
+ */
+ private PolicyInputs resolveStoredAssets(String policyId, PolicyInputs inputs) {
+ if (policyId == null || policyId.isBlank() || !policyEditingAllowed()) {
+ return inputs;
+ }
+ return policyStore
+ .get(policyId)
+ .filter(policyAccessGuard::canAccess)
+ .map(policy -> assetResolver.resolve(policy, inputs))
+ .orElse(inputs);
+ }
+
/**
* Turn the typed run files into engine {@link PolicyInputs}: the primary documents plus the
* named supporting-file store, where each asset's {@code key} is the name a step references
diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/policy/controller/PolicyControllerTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/policy/controller/PolicyControllerTest.java
index 2fa675597c..36f5cc221b 100644
--- a/app/proprietary/src/test/java/stirling/software/proprietary/policy/controller/PolicyControllerTest.java
+++ b/app/proprietary/src/test/java/stirling/software/proprietary/policy/controller/PolicyControllerTest.java
@@ -83,6 +83,8 @@ class PolicyControllerTest {
@Mock private stirling.software.proprietary.policy.asset.PolicyAssetCleaner assetCleaner;
+ @Mock private stirling.software.proprietary.policy.asset.PolicyAssetResolver assetResolver;
+
@Mock private ProcessedLedger processedLedger;
@Mock private TempFileManager tempFileManager;
@@ -115,6 +117,7 @@ class PolicyControllerTest {
policyTriggerManager,
policyOverviewService,
assetCleaner,
+ assetResolver,
processedLedger,
policyTriggers,
applicationProperties,
@@ -232,7 +235,7 @@ class PolicyControllerTest {
.thenReturn(handle("run-1"));
ResponseEntity> response =
- controller.run(definitionWithStep(), new PolicyRunFiles());
+ controller.run(definitionWithStep(), null, new PolicyRunFiles());
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.ACCEPTED);
assertThat(response.getBody().getJobId()).isEqualTo("run-1");
@@ -245,7 +248,7 @@ class PolicyControllerTest {
.thenReturn(handle("run-1"));
when(sourceAccessGuard.currentTeamId()).thenReturn(3L);
- controller.run(definitionWithStep(), new PolicyRunFiles());
+ controller.run(definitionWithStep(), null, new PolicyRunFiles());
verify(docCounter).record(EditorSource.counterKey(3L), 0L);
}
@@ -255,7 +258,7 @@ class PolicyControllerTest {
void rejectsEmptyPipeline() {
PipelineDefinition empty = new PipelineDefinition("pipe", List.of(), List.of());
- assertThatThrownBy(() -> controller.run(empty, new PolicyRunFiles()))
+ assertThatThrownBy(() -> controller.run(empty, null, new PolicyRunFiles()))
.isInstanceOf(ResponseStatusException.class)
.satisfies(
e ->
@@ -277,7 +280,7 @@ class PolicyControllerTest {
.when(policyValidator)
.validateOutput(any());
- assertThatThrownBy(() -> controller.run(definition, new PolicyRunFiles()))
+ assertThatThrownBy(() -> controller.run(definition, null, new PolicyRunFiles()))
.isInstanceOf(ResponseStatusException.class)
.satisfies(
e ->
@@ -285,6 +288,38 @@ class PolicyControllerTest {
.isEqualTo(HttpStatus.BAD_REQUEST));
verify(policyRunner, never()).runAdHoc(any(), any(), any());
}
+
+ @Test
+ @DisplayName("resolves stored assets from the supplied policy when the caller may edit it")
+ void resolvesStoredAssetsForEditor() throws Exception {
+ applicationProperties.getSecurity().setEnableLogin(false); // editing allowed
+ Policy p = policy("pol-1", 1L);
+ when(policyStore.get("pol-1")).thenReturn(Optional.of(p));
+ when(policyAccessGuard.canAccess(p)).thenReturn(true);
+ when(assetResolver.resolve(eq(p), any())).thenAnswer(inv -> inv.getArgument(1));
+ when(policyRunner.runAdHoc(any(), any(), eq(PolicyProgressListener.NOOP)))
+ .thenReturn(handle("run-1"));
+
+ controller.run(definitionWithStep(), "pol-1", new PolicyRunFiles());
+
+ verify(assetResolver).resolve(eq(p), any());
+ }
+
+ @Test
+ @DisplayName("does not resolve a policy's stored assets for a caller who cannot edit it")
+ void skipsStoredAssetsForNonEditor() throws Exception {
+ // Gating asset resolution to editors keeps a member from rebinding a policy's stored
+ // asset into an ad-hoc step to read it back.
+ applicationProperties.getSecurity().setEnableLogin(true);
+ when(policyManagementAuthority.canEditPolicies()).thenReturn(false);
+ when(policyRunner.runAdHoc(any(), any(), eq(PolicyProgressListener.NOOP)))
+ .thenReturn(handle("run-1"));
+
+ controller.run(definitionWithStep(), "pol-1", new PolicyRunFiles());
+
+ verify(assetResolver, never()).resolve(any(), any());
+ verify(policyStore, never()).get(any());
+ }
}
@Nested
@@ -296,7 +331,8 @@ class PolicyControllerTest {
void returnsEmitter() throws Exception {
when(policyRunner.runAdHoc(any(), any(), any())).thenReturn(handle("run-2"));
- SseEmitter emitter = controller.runStream(definitionWithStep(), new PolicyRunFiles());
+ SseEmitter emitter =
+ controller.runStream(definitionWithStep(), null, new PolicyRunFiles());
assertThat(emitter).isNotNull();
}
@@ -306,7 +342,7 @@ class PolicyControllerTest {
void rejectsEmpty() {
PipelineDefinition empty = new PipelineDefinition("pipe", List.of(), List.of());
- assertThatThrownBy(() -> controller.runStream(empty, new PolicyRunFiles()))
+ assertThatThrownBy(() -> controller.runStream(empty, null, new PolicyRunFiles()))
.isInstanceOf(ResponseStatusException.class);
}
}
diff --git a/frontend/editor/public/locales/en-US/translation.toml b/frontend/editor/public/locales/en-US/translation.toml
index 12ad26ceec..01db314031 100644
--- a/frontend/editor/public/locales/en-US/translation.toml
+++ b/frontend/editor/public/locales/en-US/translation.toml
@@ -7705,7 +7705,6 @@ moreActions = "More actions"
needsConfiguring = "Needs setting up"
needsDestination = "No destination chosen"
needsSource = "No source chosen"
-needsUpload = "Needs an uploaded file"
noToolMatches = "No tools match your search."
pause = "Pause"
rename = "Rename pipeline"
@@ -7713,11 +7712,11 @@ searchTools = "Search tools"
sendToSystem = "Send to another system"
stepsIncompatible = "These steps can't run on what their prior step produces: {{tools}}."
stepsNeedSetup = "These steps still need setting up before saving: {{tools}}."
+supportingFiles = "Supporting files"
testRun = "Test with a file"
unknownStep = "Unrecognized operation, kept as-is."
unsavedBody = "You have unsaved changes. Save them before leaving, or discard them?"
unsavedTitle = "Unsaved changes"
-uploadUnsupported = "Uploaded files aren't supported in pipelines yet, so these steps can't be saved: {{tools}}."
usesDefaults = "Runs with default settings"
viewDefinition = "View definition"
@@ -7730,7 +7729,6 @@ saveHeading = "To save your changes:"
schedule = "Set how often it runs"
setup = "Finish setting up: {{tools}}"
source = "Choose an input source"
-upload = "Remove steps that need an uploaded file: {{tools}}"
[portal.pipelines.builder.diagnostic]
fan-in = "Combines every incoming file"
diff --git a/frontend/editor/scripts/generate-tool-api-types.mts b/frontend/editor/scripts/generate-tool-api-types.mts
index d5990096af..38eb7fe7b6 100644
--- a/frontend/editor/scripts/generate-tool-api-types.mts
+++ b/frontend/editor/scripts/generate-tool-api-types.mts
@@ -28,10 +28,11 @@ const ALLOWED_PATH_PREFIXES = [
"/api/v1/integration/",
];
-// File plumbing, not user parameters: `fileInput` is the uploaded document and
-// `fileId` a server-side handle. Stripped from every generated request model.
-// Named file fields (stampImage, attachments, ...) are real parameters and kept.
-const BASE_FILE_FIELDS = new Set(["fileInput", "fileId"]);
+// File plumbing, not user parameters: `fileInput` and `file` are the uploaded primary document
+// (endpoints use one name or the other - `file` is never a second, supporting upload) and `fileId`
+// a server-side handle. Stripped from every generated request model. Named supporting-file fields
+// (stampImage, attachments, ...) are real parameters and kept.
+const BASE_FILE_FIELDS = new Set(["fileInput", "file", "fileId"]);
// The shared "upload a file or provide a file ID" wrapper schema and its two
// branches. An endpoint whose body is exactly this has no parameters, so it must
@@ -73,6 +74,19 @@ function isObject(value: unknown): value is Json {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
+/** A single file upload: `type: string, format: binary` (a Java MultipartFile param). */
+function isBinaryField(schema: unknown): schema is Json {
+ return (
+ isObject(schema) && schema.type === "string" && schema.format === "binary"
+ );
+}
+
+/** A multi file upload: an array of binary items (some specs also flag the array itself binary). */
+function isBinaryArrayField(schema: unknown): schema is Json {
+ if (!isObject(schema) || schema.type !== "array") return false;
+ return schema.format === "binary" || isBinaryField(schema.items);
+}
+
/**
* Recursively sort object keys so the output is byte-stable regardless of the
* key ordering springdoc happens to emit.
@@ -358,6 +372,9 @@ async function main(): Promise {
const usedClassNames = new Set();
const pendingComponents = new Set();
const skipped: string[] = [];
+ // Named file fields (as File uploads) per model, so a caller can tell a file param from a scalar
+ // string param - which `format: binary` -> `string` would otherwise erase.
+ const fileFieldsByClass: Record = {};
for (const path of Object.keys(paths).sort()) {
if (
@@ -408,7 +425,32 @@ async function main(): Promise {
const query = queryParameters(pathItem);
// Body wins over query on a name collision.
const properties: Json = { ...query.props, ...bodyProps };
+ // `file` is stripped as a primary-document alias (see BASE_FILE_FIELDS). That only holds while
+ // no endpoint uses `file` as a *supporting* upload beside a primary `fileInput`; if one ever
+ // does, blanket-stripping would silently drop it. Fail generation so the assumption is fixed
+ // here rather than shipping a lost file.
+ if ("file" in properties && "fileInput" in properties) {
+ throw new Error(
+ `${path} has both 'fileInput' and 'file' uploads. 'file' is stripped as a primary-document` +
+ " alias, which would drop it as a supporting file. Rename the supporting param or revise" +
+ " BASE_FILE_FIELDS handling in this generator.",
+ );
+ }
for (const field of BASE_FILE_FIELDS) delete properties[field];
+ // Type each named file upload as File/File[] (not the `string` a binary format yields) via
+ // json-schema-to-typescript's `tsType` override, and record it. Base file fields are already
+ // stripped, so what remains is the real supporting-file params.
+ const fileFields: string[] = [];
+ for (const [name, prop] of Object.entries(properties)) {
+ if (isBinaryField(prop)) {
+ prop.tsType = "File";
+ fileFields.push(name);
+ } else if (isBinaryArrayField(prop)) {
+ prop.tsType = "File[]";
+ fileFields.push(name);
+ }
+ }
+ fileFieldsByClass[className] = fileFields;
modelSchema.properties = properties;
const required = new Set(computeRequired(modelSchema, properties));
for (const name of query.required) {
@@ -464,6 +506,7 @@ async function main(): Promise {
await compileAndWrite(
tools,
definitions,
+ fileFieldsByClass,
outputPath,
values.check ?? false,
skipped,
@@ -473,6 +516,7 @@ async function main(): Promise {
async function compileAndWrite(
tools: DiscoveredTool[],
definitions: Record,
+ fileFieldsByClass: Record,
outputPath: string,
check: boolean,
skipped: string[],
@@ -525,6 +569,15 @@ async function compileAndWrite(
const endpointList = tools
.map((t) => ` ${JSON.stringify(t.path)},`)
.join("\n");
+ // Endpoints that take supporting files, mapped to those file params' names. Only endpoints with at
+ // least one are listed, so membership answers "does this tool take extra files".
+ const fileFieldEntries = tools
+ .filter((t) => (fileFieldsByClass[t.className] ?? []).length > 0)
+ .map(
+ (t) =>
+ ` ${JSON.stringify(t.path)}: ${JSON.stringify(fileFieldsByClass[t.className])},`,
+ )
+ .join("\n");
const footer = [
"/** Endpoint path for a generated tool operation (the operation identity across languages). */",
@@ -536,6 +589,9 @@ async function compileAndWrite(
"/** Every generated tool endpoint, for iteration. */",
`export const TOOL_ENDPOINTS = [\n${endpointList}\n] as const satisfies readonly ToolEndpoint[];`,
"",
+ "/** The supporting-file parameters each endpoint accepts beyond its primary fileInput, by name. */",
+ `export const TOOL_FILE_FIELDS = {\n${fileFieldEntries}\n} as const satisfies Partial<\n Record\n>;`,
+ "",
"/** Union of every generated tool request model. */",
`export type ToolApiRequest = ToolApiParams[ToolEndpoint];`,
].join("\n");
diff --git a/frontend/editor/src/core/hooks/tools/shared/toolApiMapping.test.ts b/frontend/editor/src/core/hooks/tools/shared/toolApiMapping.test.ts
index ba8c0bc2be..f3443dfb51 100644
--- a/frontend/editor/src/core/hooks/tools/shared/toolApiMapping.test.ts
+++ b/frontend/editor/src/core/hooks/tools/shared/toolApiMapping.test.ts
@@ -41,12 +41,13 @@ describe("objectToFormData", () => {
});
test("expands arrays into repeated fields", () => {
- const request: ToolApiParams["/api/v1/misc/add-attachments"] = {
- attachments: ["a.png", "b.png", "c.png"],
+ const request: ToolApiParams["/api/v1/misc/ocr-pdf"] = {
+ ocrType: "Normal",
+ languages: ["eng", "fra", "deu"],
};
const formData = objectToFormData(request);
- expect(formData.getAll("attachments")).toEqual(["a.png", "b.png", "c.png"]);
+ expect(formData.getAll("languages")).toEqual(["eng", "fra", "deu"]);
});
test("throws on a non-primitive field value rather than dropping it", () => {
@@ -70,6 +71,18 @@ describe("objectToFormData", () => {
expect(formData.get("optimizeLevel")).toBe("5");
});
+ test("sends a File-valued model field as a file part, not stringified", () => {
+ const stamp = new File(["s"], "stamp.png", { type: "image/png" });
+ const request: ToolApiParams["/api/v1/misc/add-stamp"] = {
+ stampType: "image",
+ stampImage: stamp,
+ };
+ const formData = objectToFormData(request);
+
+ expect(formData.get("stampImage")).toBe(stamp);
+ expect(formData.get("stampType")).toBe("image");
+ });
+
test("appends multiple files under the same field name", () => {
const files = [
new File(["1"], "a.pdf", { type: "application/pdf" }),
diff --git a/frontend/editor/src/core/hooks/tools/shared/toolApiMapping.ts b/frontend/editor/src/core/hooks/tools/shared/toolApiMapping.ts
index 9c6f5d9e4b..81663fbf69 100644
--- a/frontend/editor/src/core/hooks/tools/shared/toolApiMapping.ts
+++ b/frontend/editor/src/core/hooks/tools/shared/toolApiMapping.ts
@@ -47,26 +47,28 @@ function appendPrimitive(
formData.append(key, value);
} else if (typeof value === "number" || typeof value === "boolean") {
formData.append(key, `${value}`);
+ } else if (typeof Blob !== "undefined" && value instanceof Blob) {
+ // A File upload (models type binary params as File): send it as the file part, not stringified.
+ formData.append(key, value);
} else {
- // A non-primitive here means a mapper produced a value the backend cannot
- // receive as a form field. Fail loudly rather than silently drop it:
- // structured fields must be JSON-encoded in the mapper, and Files passed via
- // the `files` argument.
+ // Any other non-primitive means a mapper produced a value the backend cannot receive as a form
+ // field. Fail loudly rather than silently drop it: structured fields must be JSON-encoded first.
throw new Error(
`objectToFormData: field "${key}" has an unsupported value of type ` +
- `"${typeof value}"; expected a string, number, or boolean.`,
+ `"${typeof value}"; expected a string, number, boolean, or File.`,
);
}
}
/**
* Serialize a backend request model (the output of a `toApiParams` function)
- * into multipart FormData: primitives become string fields, arrays become
- * repeated fields, and `undefined`/`null` are omitted. Files are appended
- * separately via `files`, keeping file plumbing out of the parameter mapper.
+ * into multipart FormData: primitives become string fields, `File` values become
+ * file parts, arrays become repeated fields, and `undefined`/`null` are omitted.
+ * Extra files may still be passed via `files` (the primary `fileInput`, or a
+ * field the mapper doesn't carry).
*
- * Throws if a field holds a non-primitive value, since that cannot be sent as a
- * form field: structured fields must be JSON-encoded by the mapper.
+ * Throws if a field holds any other non-primitive value, since that cannot be
+ * sent as a form field: structured fields must be JSON-encoded by the mapper.
*/
export function objectToFormData(
params: ToolApiRequest,
diff --git a/frontend/editor/src/core/hooks/tools/shared/toolAutomation.test.ts b/frontend/editor/src/core/hooks/tools/shared/toolAutomation.test.ts
index 5be4831907..0f7184f848 100644
--- a/frontend/editor/src/core/hooks/tools/shared/toolAutomation.test.ts
+++ b/frontend/editor/src/core/hooks/tools/shared/toolAutomation.test.ts
@@ -10,11 +10,14 @@ import {
asRegistryConfig,
ToolType,
} from "@app/hooks/tools/shared/toolOperationTypes";
+import { objectToFormData } from "@app/hooks/tools/shared/toolApiMapping";
import {
+ activeFileFields,
deserializeToolStep,
+ extractStepFiles,
getExecutableTools,
serializeToolStep,
- stepRequiresUpload,
+ stepNeedsConfiguring,
type WorkingToolStep,
} from "@app/hooks/tools/shared/toolAutomation";
import { compressOperationConfig } from "@app/hooks/tools/compress/useCompressOperation";
@@ -28,6 +31,10 @@ import { addPasswordOperationConfig } from "@app/hooks/tools/addPassword/useAddP
import { changePermissionsOperationConfig } from "@app/hooks/tools/changePermissions/useChangePermissionsOperation";
import { convertOperationConfig } from "@app/hooks/tools/convert/useConvertOperation";
import { defaultParameters as convertDefaults } from "@app/hooks/tools/convert/useConvertParameters";
+import { overlayPdfsOperationConfig } from "@app/hooks/tools/overlayPdfs/useOverlayPdfsOperation";
+import { defaultParameters as overlayDefaults } from "@app/hooks/tools/overlayPdfs/useOverlayPdfsParameters";
+import { certSignOperationConfig } from "@app/hooks/tools/certSign/useCertSignOperation";
+import { defaultParameters as certSignDefaults } from "@app/hooks/tools/certSign/useCertSignParameters";
function entry(over: Partial): ToolRegistryEntry {
return {
@@ -419,18 +426,167 @@ describe("convert (format-routed custom tool)", () => {
});
});
-describe("stepRequiresUpload", () => {
- const step = (params: Record): WorkingToolStep => ({
- toolId: "compress" as ToolId,
- operation: "/api/v1/misc/compress-pdf",
- params,
+describe("supporting files", () => {
+ const fileRegistry: Partial = {
+ overlayPdfs: entry({
+ name: "Overlay",
+ automationSettings: NoopSettings,
+ operationConfig: asRegistryConfig(overlayPdfsOperationConfig),
+ }),
+ certSign: entry({
+ name: "Cert sign",
+ automationSettings: NoopSettings,
+ operationConfig: asRegistryConfig(certSignOperationConfig),
+ }),
+ };
+
+ const overlayStep = (
+ params: Record,
+ fileParameters?: Record,
+ ): WorkingToolStep => ({
+ toolId: "overlayPdfs" as ToolId,
+ operation: "/api/v1/general/overlay-pdfs",
+ params: { ...overlayDefaults, ...params },
support: "editable",
+ fileParameters,
});
- test("detects a File (or list of Files) among the parameters", () => {
- const image = new File(["x"], "logo.png", { type: "image/png" });
- expect(stepRequiresUpload(step({ level: 5 }))).toBe(false);
- expect(stepRequiresUpload(step({ watermarkImage: image }))).toBe(true);
- expect(stepRequiresUpload(step({ attachments: [image] }))).toBe(true);
+ const certStep = (
+ params: Record,
+ fileParameters?: Record,
+ ): WorkingToolStep => ({
+ toolId: "certSign" as ToolId,
+ operation: "/api/v1/security/cert-sign",
+ params: { ...certSignDefaults, signMode: "MANUAL", ...params },
+ support: "editable",
+ fileParameters,
+ });
+
+ test("extractStepFiles groups fresh picks by their backend file field", () => {
+ const a = new File(["1"], "a.pdf", { type: "application/pdf" });
+ const b = new File(["2"], "b.pdf", { type: "application/pdf" });
+ expect(
+ extractStepFiles(overlayStep({ overlayFiles: [a, b] }), fileRegistry),
+ ).toEqual({ overlayFiles: [a, b] });
+ });
+
+ test("extractStepFiles respects a tool's file selection (certSign by certType)", () => {
+ const p12 = new File(["k"], "key.p12");
+ expect(
+ extractStepFiles(
+ certStep({ certType: "PKCS12", p12File: p12 }),
+ fileRegistry,
+ ),
+ ).toEqual({ p12File: [p12] });
+ });
+
+ test("serialize/deserialize round-trips fileParameters", () => {
+ const step = certStep({ certType: "PKCS12" }, { p12File: "asset:abc" });
+ const api = serializeToolStep(step, fileRegistry);
+ expect(api.fileParameters).toEqual({ p12File: "asset:abc" });
+ expect(deserializeToolStep(api, fileRegistry).fileParameters).toEqual({
+ p12File: "asset:abc",
+ });
+ });
+
+ test("stepNeedsConfiguring: a stored binding satisfies the file requirement", () => {
+ expect(
+ stepNeedsConfiguring(
+ certStep({ certType: "PKCS12" }, { p12File: "asset:abc" }),
+ fileRegistry,
+ ),
+ ).toBe(false);
+ // Without the binding the keystore is still owed.
+ expect(
+ stepNeedsConfiguring(certStep({ certType: "PKCS12" }), fileRegistry),
+ ).toBe(true);
+ });
+
+ test("activeFileFields drops a stored binding the tool no longer emits", () => {
+ // Still PKCS12: the p12File binding is what the tool sends.
+ expect(
+ activeFileFields(
+ certStep({ certType: "PKCS12" }, { p12File: "asset:abc" }),
+ fileRegistry,
+ ),
+ ).toEqual(["p12File"]);
+ // Switched to PEM: certSign wants privateKeyFile/certFile, so the p12File binding is stale.
+ expect(
+ activeFileFields(
+ certStep({ certType: "PEM" }, { p12File: "asset:abc" }),
+ fileRegistry,
+ ),
+ ).toEqual([]);
+ });
+
+ test("activeFileFields is null (not empty) when the tool can't be probed", () => {
+ // A buildFormData that throws can't be probed; returning null (vs []) tells callers to keep the
+ // step's stored bindings rather than drop them and let the server GC the assets.
+ const config = asRegistryConfig<{ signingCert?: File }>({
+ toolType: ToolType.singleFile,
+ operationType: "certSign",
+ endpoint: "/api/v1/security/cert-sign",
+ defaultParameters: {},
+ buildFormData: () => {
+ throw new Error("cannot build");
+ },
+ });
+ const registry: Partial = {
+ certSign: entry({ name: "Boom", operationConfig: config }),
+ };
+ const step: WorkingToolStep = {
+ toolId: "certSign" as ToolId,
+ operation: "/api/v1/security/cert-sign",
+ params: {},
+ support: "editable",
+ fileParameters: { certFile: "asset:x" },
+ };
+ expect(activeFileFields(step, registry)).toBeNull();
+ });
+
+ test("the overlay sentinel is sized to the binding's asset count", () => {
+ // Two ids -> two files, matching two counts, so FixedRepeat validation passes.
+ const step = overlayStep(
+ { overlayMode: "FixedRepeatOverlay", counts: [1, 2] },
+ { overlayFiles: "asset:one,two" },
+ );
+ expect(activeFileFields(step, fileRegistry)).toEqual(["overlayFiles"]);
+ expect(stepNeedsConfiguring(step, fileRegistry)).toBe(false);
+ });
+
+ test("a rename override binds a backend field to a differently-named param", () => {
+ // The cert-sign endpoint's `certFile` is held by a frontend param named `signingCert`.
+ const config = asRegistryConfig<{ signingCert?: File }>({
+ toolType: ToolType.singleFile,
+ operationType: "certSign",
+ endpoint: "/api/v1/security/cert-sign",
+ defaultParameters: {},
+ validateParams: (p) => p.signingCert !== undefined,
+ // Sends the File under the backend field `certFile`, like real tools do via objectToFormData
+ // (which sends a param's File or File[] under a named field, iterating arrays).
+ buildFormData: (p, file) =>
+ objectToFormData({}, { fileInput: file, certFile: p.signingCert }),
+ fileParamOverrides: [{ field: "certFile", param: "signingCert" }],
+ });
+ const registry: Partial = {
+ certSign: entry({ name: "Sign", operationConfig: config }),
+ };
+ const step = (
+ fileParameters?: Record,
+ ): WorkingToolStep => ({
+ toolId: "certSign" as ToolId,
+ operation: "/api/v1/security/cert-sign",
+ params: {},
+ support: "editable",
+ fileParameters,
+ });
+ // The stored binding is keyed by the backend field, but satisfies the frontend param on reload.
+ expect(stepNeedsConfiguring(step({ certFile: "asset:x" }), registry)).toBe(
+ false,
+ );
+ expect(stepNeedsConfiguring(step(), registry)).toBe(true);
+ expect(activeFileFields(step({ certFile: "asset:x" }), registry)).toEqual([
+ "certFile",
+ ]);
});
});
diff --git a/frontend/editor/src/core/hooks/tools/shared/toolAutomation.ts b/frontend/editor/src/core/hooks/tools/shared/toolAutomation.ts
index 0485791f15..a9368ccdb7 100644
--- a/frontend/editor/src/core/hooks/tools/shared/toolAutomation.ts
+++ b/frontend/editor/src/core/hooks/tools/shared/toolAutomation.ts
@@ -18,11 +18,13 @@ import {
type ToolRegistryEntry,
} from "@app/data/toolsTaxonomy";
import { type ToolId } from "@app/types/toolId";
+import { TOOL_FILE_FIELDS } from "@app/types/toolApiTypes";
import {
isToolEndpoint,
type ToolEndpoint,
} from "@app/hooks/tools/shared/toolApiMapping";
import {
+ ToolType,
type ErasedToolParams,
type RegistryToolOperationConfig,
} from "@app/hooks/tools/shared/toolOperationTypes";
@@ -62,6 +64,12 @@ export interface ExecutableTool {
export interface ToolApiStep {
operation: string;
parameters: Record;
+ /**
+ * Supporting-file bindings: a backend file field (e.g. `stampImage`, `overlayFiles`) mapped to
+ * `asset:[,]` (stored supporting files) or a run-supplied key. Absent when the step needs
+ * no supporting file. Mirrors the wire {@code PipelineStep.fileParameters}.
+ */
+ fileParameters?: SupportingFileBindings;
}
/** A step being edited in a UI that maps to a known tool: parameters are in the tool's frontend shape. */
@@ -70,6 +78,12 @@ export interface KnownToolStep {
operation: ToolEndpoint;
params: ErasedToolParams;
support: ToolStepSupport;
+ /**
+ * Stored supporting-file bindings carried from a saved step (field -> `asset:`), so an edit
+ * round-trips them without the user re-picking. A field the user re-picks lands in `params` as a
+ * File and takes precedence on save.
+ */
+ fileParameters?: SupportingFileBindings;
}
/** A stored step whose endpoint maps to no known tool: preserved verbatim, not editable. */
@@ -78,6 +92,8 @@ export interface UnknownToolStep {
operation: string;
params: ErasedToolParams;
support: "unknown";
+ /** Supporting-file bindings preserved verbatim, so an unknown step's files round-trip untouched. */
+ fileParameters?: SupportingFileBindings;
}
/** A step being edited in a UI, discriminated by whether its endpoint maps to a known tool. */
@@ -135,12 +151,176 @@ function isFileValue(value: unknown): boolean {
}
/**
- * True if any of a step's parameters is an uploaded file (or list of files). Such a step cannot be
- * saved into a stored pipeline yet: the file bytes are not persisted with the policy, so a later
- * (e.g. scheduled) run would have nothing to send for that named file field.
+ * A stored supporting-file id, as returned by the asset store.
*/
-export function stepRequiresUpload(step: WorkingToolStep): boolean {
- return Object.values(step.params).some(isFileValue);
+declare const ASSET_ID_BRAND: unique symbol;
+export type AssetId = string & { readonly [ASSET_ID_BRAND]: never };
+
+/**
+ * A step's supporting-file bindings: each backend file field (e.g. `stampImage`) mapped to its file.
+ * A value of `asset:[,]` names stored assets loaded at run time; any other value is a key for
+ * a file supplied with the run itself.
+ */
+export type SupportingFileBindings = Record;
+
+/**
+ * The `fileParameters` binding format shared with the backend (see PolicyAssetRefs). This module owns
+ * the frontend side of the step contract, so the format lives here and the builder/settings reuse it.
+ */
+export const ASSET_REF_PREFIX = "asset:";
+
+/** A `fileParameters` value binding one tool file field to the given stored asset ids. */
+export function assetRef(ids: readonly AssetId[]): string {
+ return ASSET_REF_PREFIX + ids.join(",");
+}
+
+/** The stored asset ids inside a binding value, or none when it isn't an `asset:` ref. */
+export function assetRefIds(binding: string): AssetId[] {
+ if (!binding.startsWith(ASSET_REF_PREFIX)) return [];
+ return binding
+ .slice(ASSET_REF_PREFIX.length)
+ .split(",")
+ .map((id) => id.trim())
+ .filter(Boolean) as AssetId[];
+}
+
+/** A throwaway primary document for probing a tool's buildFormData; never sent anywhere. */
+function dummyPrimaryFile(): File {
+ return new File([], "input.pdf", { type: "application/pdf" });
+}
+
+/**
+ * Run a tool's buildFormData so we can read the request it would produce.
+ * Returns null when File is unavailable or buildFormData throws.
+ */
+function probeFormData(
+ config: RegistryToolOperationConfig,
+ params: ErasedToolParams,
+): FormData | null {
+ if (typeof File === "undefined") return null;
+ const dummy = dummyPrimaryFile();
+ try {
+ switch (config.toolType) {
+ case ToolType.singleFile:
+ return config.buildFormData(params, dummy);
+ case ToolType.multiFile:
+ return config.buildFormData(params, [dummy]);
+ default:
+ return null;
+ }
+ } catch {
+ return null;
+ }
+}
+
+/** Defaults merged under the step's params - the shape a tool's mappers and buildFormData expect. */
+function mergedStepParams(
+ step: WorkingToolStep,
+ config: RegistryToolOperationConfig,
+): ErasedToolParams {
+ return { ...(config.defaultParameters ?? {}), ...step.params };
+}
+
+/** The backend file fields an endpoint accepts, from the generated spec-sourced table. */
+function backendFileFields(operation: string): readonly string[] {
+ return (
+ (TOOL_FILE_FIELDS as Partial>)[
+ operation
+ ] ?? []
+ );
+}
+
+/**
+ * Each backend file field the step's endpoint accepts (from {@link TOOL_FILE_FIELDS}), mapped to the
+ * tool param that holds it - the same name unless the tool declared a rename override.
+ */
+function fileFieldMappings(
+ operation: string,
+ config: RegistryToolOperationConfig,
+): { field: string; param: string }[] {
+ // The override's erased type collapses `param` to `never`; restore the real runtime shape.
+ const overrides = (config.fileParamOverrides ?? []) as readonly {
+ field: string;
+ param: string;
+ }[];
+ const paramByField = new Map(overrides.map((o) => [o.field, o.param]));
+ return backendFileFields(operation).map((field) => ({
+ field,
+ param: paramByField.get(field) ?? field,
+ }));
+}
+
+/**
+ * The step's params with a stand-in File array injected for each stored binding whose param has no
+ * fresh pick, so a tool's buildFormData/validateParams sees the supporting file as present. Stored
+ * bindings are keyed by the backend field (from {@link TOOL_FILE_FIELDS}), so each field finds its
+ * binding and the sentinel lands on its param - the two coincide unless the tool declared a rename
+ * override. The array is sized to the binding's asset count (overlay validates count == file count).
+ * Sentinels are empty and live only in this local object - never written back to step.params, so they
+ * can never be uploaded.
+ */
+function withStoredFileSentinels(
+ step: WorkingToolStep,
+ config: RegistryToolOperationConfig,
+): ErasedToolParams {
+ const merged = mergedStepParams(step, config);
+ const bindings = step.fileParameters;
+ if (!bindings || typeof File === "undefined") return merged;
+ for (const { param, field } of fileFieldMappings(step.operation, config)) {
+ const binding = bindings[field];
+ if (binding == null || isFileValue(merged[param])) continue; // unbound, or a fresh pick stands in
+ const count = Math.max(1, assetRefIds(binding).length);
+ merged[param] = Array.from({ length: count }, () => new File([], "stored"));
+ }
+ return merged;
+}
+
+/**
+ * The fresh File picks on a step, grouped by the backend file field its buildFormData sends them
+ * under (excluding the primary `fileInput`). buildFormData is the source of truth for the field name
+ * and for tool-specific selection (certSign picks files by certType), so probing it - rather than
+ * scanning params - keeps the field mapping correct. These are the files to upload on save.
+ */
+export function extractStepFiles(
+ step: WorkingToolStep,
+ registry: Partial,
+): Record {
+ if (step.toolId === null) return {};
+ const config = registry[step.toolId]?.operationConfig;
+ if (!config) return {};
+ const formData = probeFormData(config, mergedStepParams(step, config));
+ if (!formData) return {};
+ const files: Record = {};
+ formData.forEach((value, key) => {
+ if (key !== "fileInput" && value instanceof File) {
+ (files[key] ??= []).push(value);
+ }
+ });
+ return files;
+}
+
+/**
+ * The backend file fields this step actually uses right now, per its own buildFormData: fresh picks
+ * plus any stored binding the tool still emits (a stale one - e.g. a PKCS12 keystore after switching
+ * to PEM - is dropped, because buildFormData no longer sends it). Drives the stored-file chips, the
+ * save-time binding set, and the test run.
+ */
+export function activeFileFields(
+ step: WorkingToolStep,
+ registry: Partial,
+): string[] | null {
+ if (step.toolId === null) {
+ return step.fileParameters ? Object.keys(step.fileParameters) : [];
+ }
+ const config = registry[step.toolId]?.operationConfig;
+ if (!config) return null;
+ const formData = probeFormData(config, withStoredFileSentinels(step, config));
+ if (!formData) return null;
+ const fields = new Set();
+ formData.forEach((value, key) => {
+ if (key !== "fileInput" && value instanceof File) fields.add(key);
+ });
+ return [...fields];
}
/**
@@ -158,9 +338,10 @@ export function stepNeedsConfiguring(
): boolean {
if (step.toolId === null) return false;
const config = registry[step.toolId]?.operationConfig;
- if (!config?.validateParams) return false;
- const merged = { ...(config.defaultParameters ?? {}), ...step.params };
- return !config.validateParams(merged);
+ if (!config || !config.validateParams) return false;
+ // Stored supporting files satisfy their field just as a fresh pick would, so validate against the
+ // sentinel-injected params rather than the bare ones (which drop the file on reload).
+ return !config.validateParams(withStoredFileSentinels(step, config));
}
/**
@@ -240,14 +421,27 @@ export function serializeToolStep(
step.toolId !== null ? registry[step.toolId]?.operationConfig : undefined;
if (!config) {
// Unmapped step (unknown endpoint on edit): round-trip it unchanged.
- return { operation: step.operation, parameters: step.params };
+ return withFileParameters(
+ { operation: step.operation, parameters: step.params },
+ step,
+ );
}
const merged = { ...(config.defaultParameters ?? {}), ...step.params };
const operation = resolveEndpoint(config, merged) ?? step.operation;
const parameters = config.toApiParams
? (config.toApiParams(merged) as Record)
: {};
- return { operation, parameters };
+ return withFileParameters({ operation, parameters }, step);
+}
+
+/** Attach the step's supporting-file bindings to a serialized step, omitting the field when empty. */
+function withFileParameters(
+ serialized: ToolApiStep,
+ step: WorkingToolStep,
+): ToolApiStep {
+ const bindings = step.fileParameters;
+ if (!bindings || Object.keys(bindings).length === 0) return serialized;
+ return { ...serialized, fileParameters: bindings };
}
/**
@@ -308,6 +502,7 @@ function unmappedStep(step: ToolApiStep): UnknownToolStep {
operation: step.operation,
params: { ...step.parameters },
support: "unknown",
+ fileParameters: step.fileParameters,
};
}
@@ -345,5 +540,11 @@ export function deserializeToolStep(
resolveEndpoint(config, params) ??
(isToolEndpoint(step.operation) ? step.operation : undefined);
if (operation === undefined) return unmappedStep(step);
- return { toolId, operation, params, support: classifyToolStepSupport(entry) };
+ return {
+ toolId,
+ operation,
+ params,
+ support: classifyToolStepSupport(entry),
+ fileParameters: step.fileParameters,
+ };
}
diff --git a/frontend/editor/src/core/hooks/tools/shared/toolOperationTypes.ts b/frontend/editor/src/core/hooks/tools/shared/toolOperationTypes.ts
index d6dbf3b355..d01d897002 100644
--- a/frontend/editor/src/core/hooks/tools/shared/toolOperationTypes.ts
+++ b/frontend/editor/src/core/hooks/tools/shared/toolOperationTypes.ts
@@ -3,7 +3,11 @@ import { StirlingFile } from "@app/types/fileContext";
import type { ResponseHandler } from "@app/utils/toolResponseProcessor";
import { ToolId } from "@app/types/toolId";
import type { ProcessingProgress } from "@app/hooks/tools/shared/useToolState";
-import type { ToolApiParams, ToolEndpoint } from "@app/types/toolApiTypes";
+import {
+ TOOL_FILE_FIELDS,
+ type ToolApiParams,
+ type ToolEndpoint,
+} from "@app/types/toolApiTypes";
export type { ProcessingProgress, ResponseHandler };
@@ -45,6 +49,39 @@ export interface CustomProcessorResult {
consumedAllInputs?: boolean;
}
+/**
+ * The parameter keys that carry a supporting file - a `File` or `File[]` value the tool sends
+ * beyond its primary document. Derived from the tool's own parameter type, so a file field can only
+ * ever be declared against a param that genuinely holds a file.
+ */
+export type FileParamKey = {
+ [K in keyof TParams]-?: NonNullable extends File | File[]
+ ? K
+ : never;
+}[keyof TParams] &
+ string;
+
+/**
+ * The backend multipart file fields an endpoint accepts, from the generated {@link TOOL_FILE_FIELDS}
+ * (which the spec derives from the Java MultipartFile params). `never` for an endpoint that takes no
+ * supporting files. This is what makes a rename override's `field` a checked name, not a free string.
+ */
+export type BackendFileField =
+ TEndpoint extends keyof typeof TOOL_FILE_FIELDS
+ ? (typeof TOOL_FILE_FIELDS)[TEndpoint][number]
+ : never;
+
+/**
+ * A remap for the rare case where a tool's frontend file param has a different name from the backend
+ * field it is sent under. Both sides are checked: `field` must be one of the endpoint's generated
+ * backend file fields, and `param` a real file param of the tool. Same-name fields need no entry -
+ * they are derived from {@link TOOL_FILE_FIELDS} directly.
+ */
+export interface FileParamOverride {
+ field: BackendFileField;
+ param: FileParamKey;
+}
+
/**
* Configuration for tool operations defining processing behavior and API integration.
*
@@ -79,6 +116,14 @@ interface BaseToolOperationConfig {
/** Default parameter values for automation */
defaultParameters?: TParams;
+ /**
+ * Rename overrides for supporting-file params. The set of a tool's file fields is derived from the
+ * generated {@link TOOL_FILE_FIELDS} (spec-sourced), keyed by the backend field name; declare an
+ * override only when a backend field maps to a differently-named frontend param, so a step composer
+ * can bind the stored file to the right param. Omitted by the common case where field == param.
+ */
+ fileParamOverrides?: readonly FileParamOverride[];
+
/**
* Whether these parameters are complete enough to run. The same predicate a tool gives
* `useBaseParameters` as its `validateFn`, so the Run button in the editor and anything composing
diff --git a/frontend/editor/src/core/types/toolApiTypes.ts b/frontend/editor/src/core/types/toolApiTypes.ts
index bf498eb450..b2bdf7fb4b 100644
--- a/frontend/editor/src/core/types/toolApiTypes.ts
+++ b/frontend/editor/src/core/types/toolApiTypes.ts
@@ -7,7 +7,7 @@ export interface AddAttachmentRequest {
/**
* The image file to be overlaid onto the PDF.
*/
- attachments: string[];
+ attachments: File[];
/**
* Convert the resulting PDF to PDF/A-3b format after adding attachments
*/
@@ -148,7 +148,7 @@ export interface AddStampRequest {
* The rotation of the stamp in degrees
*/
rotation?: number;
- stampImage?: string;
+ stampImage?: File;
/**
* The stamp text
*/
@@ -187,7 +187,7 @@ export interface AddWatermarkRequest {
* The rotation of the watermark in degrees
*/
rotation?: number;
- watermarkImage?: string;
+ watermarkImage?: File;
/**
* The watermark text
*/
@@ -525,9 +525,7 @@ export interface FlattenRequest {
*/
renderDpi?: number;
}
-export interface GeneralExtractBookmarksRequest {
- file: string;
-}
+export type GeneralExtractBookmarksRequest = Record;
export type GeneralFile = Record;
export type GeneralPdfToSinglePageRequest = Record;
export type GeneralRemoveImagePdfRequest = Record;
@@ -788,7 +786,7 @@ export interface OverlayImageRequest {
* Whether to overlay the image onto every page of the PDF.
*/
everyPage?: boolean;
- imageFile: string;
+ imageFile: File;
/**
* The x-coordinate at which to place the top-left corner of the image.
*/
@@ -806,7 +804,7 @@ export interface OverlayPdfsRequest {
/**
* An array of PDF files to be used as overlays on the base PDF. The order in these files is applied based on the selected mode.
*/
- overlayFiles: string[];
+ overlayFiles: File[];
/**
* The mode of overlaying: 'SequentialOverlay' for sequential application, 'InterleavedOverlay' for round-robin application, 'FixedRepeatOverlay' for fixed repetition based on provided counts
*/
@@ -1276,7 +1274,6 @@ export interface ScannerEffectRequest {
yellowish?: boolean;
}
export interface SecurityCertSignSessionsRequest {
- file: string;
request?: WorkflowCreationRequest;
}
export interface WorkflowCreationRequest {
@@ -1291,8 +1288,8 @@ export interface WorkflowCreationRequest {
}
export interface SecurityCertSignValidateCertificateRequest {
certType: string;
- jksFile?: string;
- p12File?: string;
+ jksFile?: File;
+ p12File?: File;
password?: string;
}
export type SecurityGetInfoOnPdfRequest = Record;
@@ -1302,7 +1299,7 @@ export interface SignPDFWithCertRequest {
* The alias of the certificate to sign with. Required for WINDOWS_STORE and recommended for PKCS11 tokens holding multiple certificates.
*/
alias?: string;
- certFile?: string;
+ certFile?: File;
/**
* The type of the digital certificate. WINDOWS_STORE and PKCS11 are hardware-backed and only available in the desktop app.
*/
@@ -1314,7 +1311,7 @@ export interface SignPDFWithCertRequest {
| "SERVER"
| "WINDOWS_STORE"
| "PKCS11";
- jksFile?: string;
+ jksFile?: File;
/**
* The location where the PDF is signed
*/
@@ -1323,7 +1320,7 @@ export interface SignPDFWithCertRequest {
* The name of the signer
*/
name?: string;
- p12File?: string;
+ p12File?: File;
/**
* The page number where the signature should be visible. This is required if showSignature is set to true
*/
@@ -1340,7 +1337,7 @@ export interface SignPDFWithCertRequest {
* Optional PKCS#11 slot index. When omitted the first slot with a token is used.
*/
pkcs11Slot?: number;
- privateKeyFile?: string;
+ privateKeyFile?: File;
/**
* The reason for signing the PDF
*/
@@ -1355,7 +1352,7 @@ export interface SignPDFWithCertRequest {
showSignature?: boolean;
}
export interface SignatureValidationRequest {
- certFile?: string;
+ certFile?: File;
}
export interface SplitPagesRequest {
/**
@@ -1741,5 +1738,22 @@ export const TOOL_ENDPOINTS = [
"/api/v1/security/verify-pdf",
] as const satisfies readonly ToolEndpoint[];
+/** The supporting-file parameters each endpoint accepts beyond its primary fileInput, by name. */
+export const TOOL_FILE_FIELDS = {
+ "/api/v1/general/overlay-pdfs": ["overlayFiles"],
+ "/api/v1/misc/add-attachments": ["attachments"],
+ "/api/v1/misc/add-image": ["imageFile"],
+ "/api/v1/misc/add-stamp": ["stampImage"],
+ "/api/v1/security/add-watermark": ["watermarkImage"],
+ "/api/v1/security/cert-sign": [
+ "privateKeyFile",
+ "certFile",
+ "p12File",
+ "jksFile",
+ ],
+ "/api/v1/security/cert-sign/validate-certificate": ["p12File", "jksFile"],
+ "/api/v1/security/validate-signature": ["certFile"],
+} as const satisfies Partial>;
+
/** Union of every generated tool request model. */
export type ToolApiRequest = ToolApiParams[ToolEndpoint];
diff --git a/frontend/editor/src/portal/api/pipelineAssets.ts b/frontend/editor/src/portal/api/pipelineAssets.ts
new file mode 100644
index 0000000000..b68c95fda5
--- /dev/null
+++ b/frontend/editor/src/portal/api/pipelineAssets.ts
@@ -0,0 +1,41 @@
+import { apiClient } from "@portal/api/http";
+import { type AssetId } from "@app/hooks/tools/shared/toolAutomation";
+
+export { type AssetId };
+
+/**
+ * Stored supporting files for pipeline steps (backend PolicyAssetController).
+ *
+ * A pipeline step that needs more than the document stream - a signing
+ * certificate, a watermark/stamp image, overlay PDFs, attachments - references
+ * its file by id from the step's `fileParameters` as `asset:`. The bytes are
+ * uploaded here first (the save-time validator rejects a policy that binds an
+ * asset id that doesn't yet exist), then a triggered or scheduled run loads the
+ * file server-side without anyone re-supplying it. Assets are team-scoped exactly
+ * like the policies that reference them, and unreferenced uploads are cleaned up
+ * server-side, so the builder never has to delete what a cancelled edit left.
+ */
+
+/** Metadata for one stored supporting file. Mirrors the Java `PolicyAsset` record. */
+export interface PolicyAsset {
+ id: AssetId;
+ fileName: string;
+ contentType: string | null;
+ size: number;
+ createdAt: number;
+}
+
+/** POST /api/v1/policies/assets: store a supporting file, returning its metadata (with the id). */
+export async function uploadPipelineAsset(file: File): Promise {
+ const form = new FormData();
+ form.append("file", file);
+ return apiClient.local.multipart(
+ "/api/v1/policies/assets",
+ form,
+ );
+}
+
+/** GET /api/v1/policies/assets: the team's stored supporting files (metadata only). */
+export async function listPipelineAssets(): Promise {
+ return apiClient.local.json("/api/v1/policies/assets");
+}
diff --git a/frontend/editor/src/portal/api/pipelines.ts b/frontend/editor/src/portal/api/pipelines.ts
index 50bdc173c4..e441fcdac8 100644
--- a/frontend/editor/src/portal/api/pipelines.ts
+++ b/frontend/editor/src/portal/api/pipelines.ts
@@ -1,5 +1,8 @@
import { apiClient } from "@portal/api/http";
-import { type ToolApiStep } from "@app/hooks/tools/shared/toolAutomation";
+import {
+ type SupportingFileBindings,
+ type ToolApiStep,
+} from "@app/hooks/tools/shared/toolAutomation";
/**
* Pipelines service layer: the backend contract.
@@ -15,7 +18,7 @@ import { type ToolApiStep } from "@app/hooks/tools/shared/toolAutomation";
export interface PipelineStep {
operation: string;
parameters: Record;
- fileParameters?: Record;
+ fileParameters?: SupportingFileBindings;
}
/** When a policy input fires automatically. `type` keys a trigger bean (e.g. "schedule"). */
@@ -217,14 +220,28 @@ export interface TestRunDefinition {
output: OutputSpec;
}
+/**
+ * A fresh, in-memory supporting file sent inline with a test run, bound to the run key a test step's
+ * `fileParameters` references. Only unsaved picks ride along here; a stored file keeps its
+ * `asset:` binding, which the backend resolves from the saved policy (see `runPipelineTest`).
+ */
+export interface TestRunAsset {
+ key: string;
+ file: File;
+}
+
/**
* POST /api/v1/policies/run: run a definition against one uploaded file now. The builder's test
* path - callers force an inline output so nothing reaches the pipeline's real destination, and
- * the pipeline need not be saved first.
+ * the pipeline need not be saved first. Fresh supporting files travel as keyed `assets[i]` parts;
+ * a stored file keeps its `asset:` binding, and `policyId` lets the backend resolve it from that
+ * saved policy (so its bytes need not be re-sent).
*/
export async function runPipelineTest(
definition: TestRunDefinition,
file: File,
+ assets: TestRunAsset[] = [],
+ policyId?: string,
): Promise<{ runId: string }> {
const form = new FormData();
form.append(
@@ -232,6 +249,11 @@ export async function runPipelineTest(
new Blob([JSON.stringify(definition)], { type: "application/json" }),
);
form.append("fileInput", file);
+ if (policyId) form.append("policyId", policyId);
+ assets.forEach((asset, i) => {
+ form.append(`assets[${i}].key`, asset.key);
+ form.append(`assets[${i}].file`, asset.file);
+ });
// The POST returns the identifier as `jobId`, but it is the same run id every other endpoint
// (fetchRun, fetchRunOutput) calls `runId`; normalise to that here so callers see one name.
const res = await apiClient.local.multipart<{ jobId: string }>(
diff --git a/frontend/editor/src/portal/components/pipelines/PipelineStepSettings.css b/frontend/editor/src/portal/components/pipelines/PipelineStepSettings.css
new file mode 100644
index 0000000000..46bb72a736
--- /dev/null
+++ b/frontend/editor/src/portal/components/pipelines/PipelineStepSettings.css
@@ -0,0 +1,17 @@
+.portal-step-settings__files {
+ display: flex;
+ flex-direction: column;
+ gap: 0.375rem;
+ margin-bottom: 0.75rem;
+}
+
+.portal-step-settings__files-label {
+ font-size: 0.75rem;
+ color: var(--c-text-muted);
+}
+
+.portal-step-settings__files-chips {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 0.375rem;
+}
diff --git a/frontend/editor/src/portal/components/pipelines/PipelineStepSettings.stories.tsx b/frontend/editor/src/portal/components/pipelines/PipelineStepSettings.stories.tsx
index d2bd6bc6f2..823d0ae4fe 100644
--- a/frontend/editor/src/portal/components/pipelines/PipelineStepSettings.stories.tsx
+++ b/frontend/editor/src/portal/components/pipelines/PipelineStepSettings.stories.tsx
@@ -52,6 +52,8 @@ const meta = {
step: editableStep,
registry,
onChange: () => {},
+ assetNames: {},
+ onClearBinding: () => {},
},
} satisfies Meta;
export default meta;
diff --git a/frontend/editor/src/portal/components/pipelines/PipelineStepSettings.test.tsx b/frontend/editor/src/portal/components/pipelines/PipelineStepSettings.test.tsx
index 55a52b496e..0b15b7233a 100644
--- a/frontend/editor/src/portal/components/pipelines/PipelineStepSettings.test.tsx
+++ b/frontend/editor/src/portal/components/pipelines/PipelineStepSettings.test.tsx
@@ -116,6 +116,8 @@ describe("PipelineStepSettings", () => {
step={step}
registry={registry}
onChange={() => {}}
+ assetNames={{}}
+ onClearBinding={() => {}}
/>
,
),
@@ -131,6 +133,8 @@ describe("PipelineStepSettings", () => {
step={convertStep}
registry={convertRegistry}
onChange={() => {}}
+ assetNames={{}}
+ onClearBinding={() => {}}
/>
,
),
@@ -146,6 +150,8 @@ describe("PipelineStepSettings", () => {
step={changeMetadataStep}
registry={changeMetadataRegistry}
onChange={() => {}}
+ assetNames={{}}
+ onClearBinding={() => {}}
/>
,
),
@@ -161,6 +167,8 @@ describe("PipelineStepSettings", () => {
step={overlayStep}
registry={overlayRegistry}
onChange={() => {}}
+ assetNames={{}}
+ onClearBinding={() => {}}
/>
,
),
@@ -205,6 +213,8 @@ describe("PipelineStepSettings", () => {
typeof update === "function" ? update(prev) : update,
)
}
+ assetNames={{}}
+ onClearBinding={() => {}}
/>
{JSON.stringify(params)}
>
diff --git a/frontend/editor/src/portal/components/pipelines/PipelineStepSettings.tsx b/frontend/editor/src/portal/components/pipelines/PipelineStepSettings.tsx
index 58fb2a96fa..3d17dbf652 100644
--- a/frontend/editor/src/portal/components/pipelines/PipelineStepSettings.tsx
+++ b/frontend/editor/src/portal/components/pipelines/PipelineStepSettings.tsx
@@ -1,15 +1,22 @@
import { Suspense } from "react";
import { useTranslation } from "react-i18next";
-import { Banner } from "@app/ui";
+import InsertDriveFileOutlinedIcon from "@mui/icons-material/InsertDriveFileOutlined";
+import { Banner, Chip } from "@app/ui";
import { PreferencesProvider } from "@app/contexts/PreferencesContext";
import { SidebarProvider } from "@app/contexts/SidebarContext";
import { type ToolRegistry } from "@app/data/toolsTaxonomy";
import { type ErasedToolParams } from "@app/hooks/tools/shared/toolOperationTypes";
-import { type WorkingToolStep } from "@app/hooks/tools/shared/toolAutomation";
+import {
+ activeFileFields,
+ assetRefIds,
+ extractStepFiles,
+ type WorkingToolStep,
+} from "@app/hooks/tools/shared/toolAutomation";
import { PolicyExternalApiConfig } from "@portal/components/policies/PolicyExternalApiConfig";
import { isIntegrationStep } from "@portal/components/pipelines/integrationStep";
import type { ExternalApiStepParams } from "@portal/components/policies/stepOperations";
+import "@portal/components/pipelines/PipelineStepSettings.css";
/**
* A params update: the next params outright, or a merge from the latest params. Settings UIs fire
@@ -25,17 +32,61 @@ interface PipelineStepSettingsProps {
step: WorkingToolStep;
registry: Partial;
onChange: (update: ParamsUpdate) => void;
+ /** Stored asset id -> file name, for labelling the supporting-file chips on a reopened pipeline. */
+ assetNames: Record;
+ /** Drop a field's stored supporting-file binding (the user re-picks a file if the step still needs one). */
+ onClearBinding: (field: string) => void;
+}
+
+/** One reopened supporting file shown as a chip: the field it binds and the stored file name(s). */
+interface StoredFileChip {
+ field: string;
+ label: string;
+}
+
+/**
+ * The supporting files this step is reusing from a previous save: an active binding whose field has
+ * no fresh pick (a fresh pick shows in the tool's own file picker instead). Labelled by the resolved
+ * asset name so the user sees "using cert.pfx" rather than an empty picker.
+ */
+function storedFileChips(
+ step: WorkingToolStep,
+ registry: Partial,
+ assetNames: Record,
+): StoredFileChip[] {
+ const bindings = step.fileParameters;
+ if (!bindings) return [];
+ // A null active set means the tool couldn't be probed; show every stored binding rather than hide
+ // the user's files (mirrors the save path, which keeps them too).
+ const active = activeFileFields(step, registry);
+ const activeSet = active === null ? null : new Set(active);
+ const fresh = extractStepFiles(step, registry);
+ return Object.entries(bindings)
+ .filter(
+ ([field]) =>
+ (activeSet === null || activeSet.has(field)) && !fresh[field],
+ )
+ .map(([field, binding]) => ({
+ field,
+ label:
+ assetRefIds(binding)
+ .map((id) => assetNames[id] ?? id)
+ .join(", ") || binding,
+ }));
}
/**
* Renders the parameter editor for one pipeline step, chosen by the tool's capability:
* the tool's own settings UI when editable, an explanatory note when it has no parameters,
- * or a "not supported yet" fallback for tools not yet migrated to the mapper seam.
+ * or a "not supported yet" fallback for tools not yet migrated to the mapper seam. Reopened
+ * supporting files appear as removable chips above the tool's own settings.
*/
export function PipelineStepSettings({
step,
registry,
onChange,
+ assetNames,
+ onClearBinding,
}: PipelineStepSettingsProps) {
// Hooks first: selecting a different step re-renders this same instance, so an early return
// above useTranslation would change the hook count between renders and crash.
@@ -52,41 +103,70 @@ export function PipelineStepSettings({
);
}
- if (step.support === "noSettings") {
- return (
-
- );
- }
+ const chips = storedFileChips(step, registry, assetNames);
- const entry = step.toolId ? registry[step.toolId] : undefined;
- const Settings =
- step.support === "editable" ? entry?.automationSettings : null;
-
- if (!Settings) {
+ function toolBody() {
+ if (step.support === "noSettings") {
+ return (
+
+ );
+ }
+ const entry = step.toolId ? registry[step.toolId] : undefined;
+ const Settings =
+ step.support === "editable" ? entry?.automationSettings : null;
+ if (!Settings) {
+ return (
+
+ );
+ }
return (
-
+
+
+
+
+ onChange((prev) => ({ ...prev, [key]: value }))
+ }
+ disabled={false}
+ />
+
+
+
);
}
return (
-
-
-
-
- onChange((prev) => ({ ...prev, [key]: value }))
- }
- disabled={false}
- />
-
-
-
+ <>
+ {chips.length > 0 && (
+