Merge remote-tracking branch 'origin/main' into feature/failure-notifications

# Conflicts:
#	frontend/editor/src/core/components/layout/Workbench.tsx
This commit is contained in:
EthanHealy01
2026-08-20 12:09:27 +01:00
60 changed files with 1777 additions and 289 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
# Maintainer: Stirling PDF Inc <contact@stirlingpdf.com>
pkgname=stirling-pdf-desktop
pkgver=2.14.2
pkgver=2.14.3
pkgrel=1
pkgdesc="Locally hosted, web-based PDF manipulation tool (Tauri desktop app, official Stirling PDF Inc build)"
arch=('x86_64')
+1 -1
View File
@@ -1,6 +1,6 @@
# Maintainer: Stirling PDF Inc <contact@stirlingpdf.com>
pkgname=stirling-pdf-server-bin
pkgver=2.14.2
pkgver=2.14.3
pkgrel=1
pkgdesc="Locally hosted, web-based PDF manipulation tool (server JAR, prebuilt)"
arch=('any')
+4 -1
View File
@@ -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:
+4 -1
View File
@@ -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:
+3 -1
View File
@@ -20,7 +20,9 @@ permissions:
jobs:
build:
environment: ci-unsigned
environment:
name: ci-unsigned
deployment: false
runs-on: ubuntu-latest
strategy:
fail-fast: false
+6 -2
View File
@@ -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: >-
+3 -1
View File
@@ -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
+3 -1
View File
@@ -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
+3 -1
View File
@@ -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
+3 -1
View File
@@ -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:
+3 -1
View File
@@ -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
+3 -1
View File
@@ -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:
@@ -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
+6 -2
View File
@@ -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:
+3 -1
View File
@@ -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.
+3 -1
View File
@@ -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:
+3 -1
View File
@@ -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
+5
View File
@@ -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
+49 -6
View File
@@ -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:
+64 -10
View File
@@ -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"
+18 -3
View File
@@ -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
+35 -17
View File
@@ -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 <ref>.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.<project-ref>.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=
@@ -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();
@@ -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 <em>configured sources</em> (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
* <em>own</em> 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
@@ -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<PolicyTrigger> policyTriggers;
private final ApplicationProperties applicationProperties;
@@ -125,12 +128,13 @@ public class PolicyController {
+ " endpoint and download outputs via /api/v1/general/files/{id}.")
public ResponseEntity<JobResponse<Void>> 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());
@@ -432,21 +437,47 @@ 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()) {
return;
}
if (!policyManagementAuthority.canEditPolicies()) {
if (!policyEditingAllowed()) {
throw new ResponseStatusException(
HttpStatus.FORBIDDEN,
"Policies may only be created or modified by a team leader");
}
}
/**
* 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");
}
}
/**
* 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",
@@ -580,8 +611,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<SweepOutcome> trigger(@PathVariable String policyId) {
requirePolicySweepAllowed();
Policy policy =
policyStore
.get(policyId)
@@ -659,6 +692,25 @@ public class PolicyController {
inputs.primary().size());
}
/**
* Resolve a test run's stored {@code asset:<id>} 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
@@ -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();
@@ -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;
@@ -84,6 +85,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;
// Real, not mocked: the run endpoints spool uploads through it.
@@ -119,6 +122,7 @@ class PolicyControllerTest {
policyTriggerManager,
policyOverviewService,
assetCleaner,
assetResolver,
processedLedger,
policyTriggers,
applicationProperties,
@@ -236,7 +240,7 @@ class PolicyControllerTest {
.thenReturn(handle("run-1"));
ResponseEntity<JobResponse<Void>> 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");
@@ -249,7 +253,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);
}
@@ -259,7 +263,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 ->
@@ -281,7 +285,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 ->
@@ -289,6 +293,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
@@ -300,7 +336,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();
}
@@ -310,7 +347,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);
}
}
@@ -805,5 +842,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<SweepOutcome> 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), any()))
.thenReturn(handle("run-9"));
ResponseEntity<JobResponse<Void>> response =
assertDoesNotThrow(() -> controller.runStoredPolicy("a", new PolicyRunFiles()));
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.ACCEPTED);
verify(policyManagementAuthority, never()).canTriggerPolicies();
verify(policyManagementAuthority, never()).canEditPolicies();
}
}
}
@@ -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);
}
}
@@ -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();
@@ -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
@@ -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
@@ -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);
+1 -1
View File
@@ -108,7 +108,7 @@ springBoot {
allprojects {
group = 'stirling.software'
version = '2.14.2'
version = '2.14.3'
configurations.configureEach {
exclude group: "org.springframework.boot", module: "spring-boot-starter-tomcat"
@@ -7733,7 +7733,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"
@@ -7741,11 +7740,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"
@@ -7758,7 +7757,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"
@@ -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<void> {
const usedClassNames = new Set<string>();
const pendingComponents = new Set<string>();
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<string, string[]> = {};
for (const path of Object.keys(paths).sort()) {
if (
@@ -408,7 +425,32 @@ async function main(): Promise<void> {
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<void> {
await compileAndWrite(
tools,
definitions,
fileFieldsByClass,
outputPath,
values.check ?? false,
skipped,
@@ -473,6 +516,7 @@ async function main(): Promise<void> {
async function compileAndWrite(
tools: DiscoveredTool[],
definitions: Record<string, Json>,
fileFieldsByClass: Record<string, string[]>,
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<ToolEndpoint, readonly string[]>\n>;`,
"",
"/** Union of every generated tool request model. */",
`export type ToolApiRequest = ToolApiParams[ToolEndpoint];`,
].join("\n");
+1 -1
View File
@@ -2,7 +2,7 @@
"$schema": "../node_modules/@tauri-apps/cli/config.schema.json",
"productName": "Stirling PDF",
"mainBinaryName": "Stirling-PDF",
"version": "2.14.2",
"version": "2.14.3",
"identifier": "stirling.pdf.dev",
"build": {
"frontendDist": "../dist",
@@ -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",
@@ -18,6 +18,7 @@ import { useCookieConsent } from "@app/hooks/useCookieConsent";
import styles from "@app/components/layout/Workbench.module.css";
import WorkbenchBar from "@app/components/shared/WorkbenchBar";
import WorkbenchFloatingSearch from "@app/components/shared/WorkbenchFloatingSearch";
import LandingPage from "@app/components/shared/LandingPage";
import DismissAllErrorsButton from "@app/components/shared/DismissAllErrorsButton";
import { ChatFAB } from "@app/components/chat/ChatFAB";
@@ -78,6 +79,22 @@ export default function Workbench() {
const [viewerToolbarCollapsed, setViewerToolbarCollapsed] = useState(false);
const showReopenTab = currentView === "viewer" && viewerToolbarCollapsed;
// The WorkbenchBar carries file-scoped actions, so it only shows once a file
// is open or a custom view supplies content; otherwise the search floats.
const activeCustomView = customWorkbenchViews.find(
(v) => v.workbenchId === currentView,
);
const topControlsAvailable =
currentView !== "myFiles" && !activeCustomView?.hideTopControls;
const hasWorkbenchContent =
hasFiles ||
fileIds.length > 0 ||
!isBaseWorkbench(currentView) ||
// Shared signing drives the viewer from the sidebar with no file in context.
(currentView === "viewer" && !!signingOverlay?.file);
const showWorkbenchBar = topControlsAvailable && hasWorkbenchContent;
const showFloatingSearch = topControlsAvailable && !hasWorkbenchContent;
const handlePreviewClose = () => {
setPreviewFile(null);
const previousMode = sessionStorage.getItem("previousMode");
@@ -232,50 +249,44 @@ export default function Workbench() {
data-tour="workbench"
style={{ backgroundColor: "var(--c-bg)", minWidth: 0 }}
>
{/* The bell normally rides in the workbench bar. My Files is the one view that
renders no bar, so it gets its own, rather than being the one place a user
cannot see that something of theirs failed. */}
{currentView === "myFiles" && (
{/* The bell normally rides in the workbench bar. Wherever that bar is not shown - My Files,
an empty workbench, a custom view without top controls - it gets its own corner, rather
than those being the places a user cannot see that something of theirs failed. */}
{!showWorkbenchBar && (
<div style={{ position: "absolute", top: 12, right: 12, zIndex: 20 }}>
<NotificationBell />
</div>
)}
{/* Workbench Bar — always visible outside My Files (it hosts the
global search), even with no files loaded. */}
{currentView !== "myFiles" &&
!customWorkbenchViews.find((v) => v.workbenchId === currentView)
?.hideTopControls && (
<div className={styles.workbenchBarShell}>
<div className={styles.workbenchBarWrapper}>
<div className={styles.workbenchBarInner}>
<WorkbenchBar
currentView={currentView}
setCurrentView={setCurrentView}
hasFiles={hasFiles}
viewerToolbarCollapsed={viewerToolbarCollapsed}
onCollapseViewerToolbar={setViewerToolbarCollapsed}
/>
</div>
</div>
{/* Reopen tab: a little handle hanging off the bar's bottom-right
while the viewer tool row is retracted. */}
{showReopenTab && (
<Button
type="button"
variant="quiet"
className={styles.workbenchBarReopenTab}
onClick={() => setViewerToolbarCollapsed(false)}
aria-expanded={false}
aria-label={t("workbenchBar.showToolbar", "Show toolbar")}
title={t("workbenchBar.showToolbar", "Show toolbar")}
leftSection={
<KeyboardArrowDownIcon sx={{ fontSize: "1rem" }} />
}
{showWorkbenchBar && (
<div className={styles.workbenchBarShell}>
<div className={styles.workbenchBarWrapper}>
<div className={styles.workbenchBarInner}>
<WorkbenchBar
currentView={currentView}
setCurrentView={setCurrentView}
hasFiles={hasFiles}
viewerToolbarCollapsed={viewerToolbarCollapsed}
onCollapseViewerToolbar={setViewerToolbarCollapsed}
/>
)}
</div>
</div>
)}
{/* Reopen tab for the retracted viewer tool row. */}
{showReopenTab && (
<Button
type="button"
variant="quiet"
className={styles.workbenchBarReopenTab}
onClick={() => setViewerToolbarCollapsed(false)}
aria-expanded={false}
aria-label={t("workbenchBar.showToolbar", "Show toolbar")}
title={t("workbenchBar.showToolbar", "Show toolbar")}
leftSection={<KeyboardArrowDownIcon sx={{ fontSize: "1rem" }} />}
/>
)}
</div>
)}
{showFloatingSearch && <WorkbenchFloatingSearch />}
{/* Dismiss All Errors Button */}
<DismissAllErrorsButton />
@@ -0,0 +1,31 @@
/* Unpainted floating search for the empty workbench. Height + top margin match
the WorkbenchBar's band so opening a file swaps it in without a shift. */
.workbench-floating-search {
display: flex;
align-items: center;
justify-content: center;
min-height: 38px;
margin-top: var(--nav-gutter);
padding: 0 1rem;
flex-shrink: 0;
}
.workbench-floating-search .super-search {
flex: 0 1 24rem;
width: min(100%, 24rem);
max-width: 24rem;
}
.workbench-floating-search .super-search input {
background-color: transparent;
padding-top: 4px;
padding-bottom: 4px;
font-size: 12.5px;
}
[data-mantine-color-scheme="dark"]
.workbench-floating-search
.super-search
input {
background-color: transparent;
}
@@ -0,0 +1,15 @@
import SuperSearch from "@app/components/shared/superSearch/SuperSearch";
import { useEditorSearchScopes } from "@app/hooks/useSuperSearch";
import "@app/components/shared/WorkbenchFloatingSearch.css";
// The editor's global search, floated while no file is open (mirrors the
// processor's PortalSearchBar). Renders only when the WorkbenchBar doesn't, so
// reusing the default input id is safe.
export default function WorkbenchFloatingSearch() {
const scopes = useEditorSearchScopes();
return (
<div className="workbench-floating-search">
<SuperSearch scopes={scopes} />
</div>
);
}
@@ -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" }),
@@ -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,
@@ -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>): ToolRegistryEntry {
return {
@@ -419,18 +426,167 @@ describe("convert (format-routed custom tool)", () => {
});
});
describe("stepRequiresUpload", () => {
const step = (params: Record<string, unknown>): WorkingToolStep => ({
toolId: "compress" as ToolId,
operation: "/api/v1/misc/compress-pdf",
params,
describe("supporting files", () => {
const fileRegistry: Partial<ToolRegistry> = {
overlayPdfs: entry({
name: "Overlay",
automationSettings: NoopSettings,
operationConfig: asRegistryConfig(overlayPdfsOperationConfig),
}),
certSign: entry({
name: "Cert sign",
automationSettings: NoopSettings,
operationConfig: asRegistryConfig(certSignOperationConfig),
}),
};
const overlayStep = (
params: Record<string, unknown>,
fileParameters?: Record<string, string>,
): 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<string, unknown>,
fileParameters?: Record<string, string>,
): 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<ToolRegistry> = {
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<ToolRegistry> = {
certSign: entry({ name: "Sign", operationConfig: config }),
};
const step = (
fileParameters?: Record<string, string>,
): 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",
]);
});
});
@@ -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<string, unknown>;
/**
* Supporting-file bindings: a backend file field (e.g. `stampImage`, `overlayFiles`) mapped to
* `asset:<id>[,<id>]` (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:<id>`), 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:<id>[,<id>]` 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<string, string>;
/**
* 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<Record<string, readonly string[]>>)[
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<ToolRegistry>,
): Record<string, File[]> {
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<string, File[]> = {};
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<ToolRegistry>,
): 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<string>();
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<string, unknown>)
: {};
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,
};
}
@@ -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<TParams> = {
[K in keyof TParams]-?: NonNullable<TParams[K]> 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> =
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<TParams, TEndpoint> {
field: BackendFileField<TEndpoint>;
param: FileParamKey<TParams>;
}
/**
* Configuration for tool operations defining processing behavior and API integration.
*
@@ -79,6 +116,14 @@ interface BaseToolOperationConfig<TParams, TEndpoint extends ToolEndpoint> {
/** 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<TParams, TEndpoint>[];
/**
* 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
@@ -38,7 +38,7 @@ const FREE_LICENSE_INFO: LicenseInfo = {
const BASE_NO_LOGIN_CONFIG: AppConfig = {
enableAnalytics: true,
appVersion: "2.14.2",
appVersion: "2.14.3",
serverCertificateEnabled: false,
enableAlphaFunctionality: false,
serverPort: 8080,
+30 -16
View File
@@ -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<string, never>;
export type GeneralFile = Record<string, never>;
export type GeneralPdfToSinglePageRequest = Record<string, never>;
export type GeneralRemoveImagePdfRequest = Record<string, never>;
@@ -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<string, never>;
@@ -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<Record<ToolEndpoint, readonly string[]>>;
/** Union of every generated tool request model. */
export type ToolApiRequest = ToolApiParams[ToolEndpoint];
@@ -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:<id>`. 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<PolicyAsset> {
const form = new FormData();
form.append("file", file);
return apiClient.local.multipart<PolicyAsset>(
"/api/v1/policies/assets",
form,
);
}
/** GET /api/v1/policies/assets: the team's stored supporting files (metadata only). */
export async function listPipelineAssets(): Promise<PolicyAsset[]> {
return apiClient.local.json<PolicyAsset[]>("/api/v1/policies/assets");
}
+25 -3
View File
@@ -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<string, unknown>;
fileParameters?: Record<string, string>;
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:<id>` 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:<id>` 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 }>(
@@ -1,10 +1,11 @@
/* Slim strip at the top of the main column hosting the shared search bar.
Deliberately unpainted: only the input itself shows, on the page ground. */
/* Unpainted strip at the top of the main column. Height matches the sidebar's
logo row (.portal-sidebar__logo, 51px) so the search lines up with the brand. */
.portal-searchbar {
display: flex;
align-items: center;
justify-content: center;
padding: 0.22rem 1rem;
min-height: 3.1875rem;
padding: 0 1rem;
flex-shrink: 0;
}
@@ -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;
}
@@ -52,6 +52,8 @@ const meta = {
step: editableStep,
registry,
onChange: () => {},
assetNames: {},
onClearBinding: () => {},
},
} satisfies Meta<typeof PipelineStepSettings>;
export default meta;
@@ -116,6 +116,8 @@ describe("PipelineStepSettings", () => {
step={step}
registry={registry}
onChange={() => {}}
assetNames={{}}
onClearBinding={() => {}}
/>
</PortalTestProviders>,
),
@@ -131,6 +133,8 @@ describe("PipelineStepSettings", () => {
step={convertStep}
registry={convertRegistry}
onChange={() => {}}
assetNames={{}}
onClearBinding={() => {}}
/>
</PortalTestProviders>,
),
@@ -146,6 +150,8 @@ describe("PipelineStepSettings", () => {
step={changeMetadataStep}
registry={changeMetadataRegistry}
onChange={() => {}}
assetNames={{}}
onClearBinding={() => {}}
/>
</PortalTestProviders>,
),
@@ -161,6 +167,8 @@ describe("PipelineStepSettings", () => {
step={overlayStep}
registry={overlayRegistry}
onChange={() => {}}
assetNames={{}}
onClearBinding={() => {}}
/>
</PortalTestProviders>,
),
@@ -205,6 +213,8 @@ describe("PipelineStepSettings", () => {
typeof update === "function" ? update(prev) : update,
)
}
assetNames={{}}
onClearBinding={() => {}}
/>
<span data-testid="out">{JSON.stringify(params)}</span>
</>
@@ -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<ToolRegistry>;
onChange: (update: ParamsUpdate) => void;
/** Stored asset id -> file name, for labelling the supporting-file chips on a reopened pipeline. */
assetNames: Record<string, string>;
/** 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<ToolRegistry>,
assetNames: Record<string, string>,
): 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 (
<Banner
tone="info"
description={t("portal.pipelines.composer.noToolSettings")}
/>
);
}
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 (
<Banner
tone="info"
description={t("portal.pipelines.composer.noToolSettings")}
/>
);
}
const entry = step.toolId ? registry[step.toolId] : undefined;
const Settings =
step.support === "editable" ? entry?.automationSettings : null;
if (!Settings) {
return (
<Banner
tone="warning"
description={t("portal.pipelines.composer.editingUnsupported")}
/>
);
}
return (
<Banner
tone="warning"
description={t("portal.pipelines.composer.editingUnsupported")}
/>
<PreferencesProvider>
<SidebarProvider>
<Suspense fallback={null}>
<Settings
parameters={step.params}
onParameterChange={(key, value) =>
onChange((prev) => ({ ...prev, [key]: value }))
}
disabled={false}
/>
</Suspense>
</SidebarProvider>
</PreferencesProvider>
);
}
return (
<PreferencesProvider>
<SidebarProvider>
<Suspense fallback={null}>
<Settings
parameters={step.params}
onParameterChange={(key, value) =>
onChange((prev) => ({ ...prev, [key]: value }))
}
disabled={false}
/>
</Suspense>
</SidebarProvider>
</PreferencesProvider>
<>
{chips.length > 0 && (
<div className="portal-step-settings__files">
<span className="portal-step-settings__files-label">
{t("portal.pipelines.builder.supportingFiles")}
</span>
<div className="portal-step-settings__files-chips">
{chips.map((chip) => (
<Chip
key={chip.field}
leadingIcon={
<InsertDriveFileOutlinedIcon
style={{ fontSize: "0.875rem" }}
/>
}
onRemove={() => onClearBinding(chip.field)}
>
{chip.label}
</Chip>
))}
</div>
</div>
)}
{toolBody()}
</>
);
}
@@ -116,6 +116,21 @@ function nextId(): string {
return `plc_${Date.now().toString(36)}_${idCounter}`;
}
/** Stored supporting files a step binds as `asset:<id>` (PolicyAssetController), for mock mode. */
interface StoredAsset {
id: string;
fileName: string;
contentType: string | null;
size: number;
createdAt: number;
}
let assetStore: StoredAsset[] = [];
let assetCounter = 0;
function nextAssetId(): string {
assetCounter += 1;
return `ast_${Date.now().toString(36)}_${assetCounter}`;
}
function deriveStatus(policy: StoredPolicy): PipelineStatus {
return policy.enabled ? "active" : "paused";
}
@@ -197,6 +212,33 @@ export const pipelinesHandlers = [
]);
}),
// Supporting files. Registered before the `/policies/:id` matcher so "assets" isn't read as an id.
http.get("/api/v1/policies/assets", async () => {
await delay(80);
return HttpResponse.json(assetStore);
}),
http.post("/api/v1/policies/assets", async ({ request }) => {
const form = await request.formData();
const file = form.get("file");
if (!(file instanceof File)) {
return HttpResponse.json(
{ detail: "Uploaded file is empty" },
{ status: 400 },
);
}
await delay(120);
const asset: StoredAsset = {
id: nextAssetId(),
fileName: file.name || "asset",
contentType: file.type || null,
size: file.size,
createdAt: Date.now(),
};
assetStore = [...assetStore, asset];
return HttpResponse.json(asset);
}),
// Run status: the mock completes runs immediately, so polling resolves at once.
http.get("/api/v1/policies/run/:runId", async ({ params }) => {
await delay(120);
@@ -43,6 +43,13 @@ vi.mock("@portal/api/pipelines", () => ({
fetchRun: (runId: string) => fetchRun(runId),
}));
const uploadPipelineAsset = vi.fn();
const listPipelineAssets = vi.fn();
vi.mock("@portal/api/pipelineAssets", () => ({
uploadPipelineAsset: (file: File) => uploadPipelineAsset(file),
listPipelineAssets: () => listPipelineAssets(),
}));
const fetchSources = vi.fn();
vi.mock("@portal/api/sources", () => ({
fetchSources: () => fetchSources(),
@@ -149,8 +156,21 @@ vi.mock("@app/contexts/ToolRegistryContext", () => {
toolType: 0,
endpoint: "/api/v1/misc/compress-pdf",
defaultParameters: {},
buildFormData: () => new FormData(),
toApiParams: (params: Record<string, unknown>) => ({ ...params }),
// Sends the supporting file under a named field, like a real file tool, so the upload path
// has a field to bind. The scalar mapper drops the File (files never ride in parameters).
buildFormData: (params: Record<string, unknown>, file: File | File[]) => {
const fd = new FormData();
fd.append("fileInput", Array.isArray(file) ? file[0] : file);
if (params.watermarkImage instanceof File) {
fd.append("watermarkImage", params.watermarkImage);
}
return fd;
},
toApiParams: (params: Record<string, unknown>) => {
const scalars = { ...params };
delete scalars.watermarkImage;
return scalars;
},
fromApiParams: (params: Record<string, unknown>) => ({ ...params }),
},
} as unknown as ToolRegistryEntry;
@@ -203,10 +223,32 @@ vi.mock("@app/contexts/ToolRegistryContext", () => {
fromApiParams: (params: Record<string, unknown>) => ({ ...params }),
},
} as unknown as ToolRegistryEntry;
// A tool whose buildFormData throws, so it can't be probed: exercises the "activeFileFields is
// null" path where a reopened step's stored binding must be kept, not dropped.
const sign = {
name: "Sign",
icon: null,
component: null,
description: "",
categoryId: "recommendedTools",
subcategoryId: "general",
operationConfig: {
operationType: "certSign",
toolType: 0,
endpoint: "/api/v1/security/cert-sign",
defaultParameters: {},
buildFormData: () => {
throw new Error("cannot build");
},
toApiParams: (params: Record<string, unknown>) => ({ ...params }),
fromApiParams: (params: Record<string, unknown>) => ({ ...params }),
},
} as unknown as ToolRegistryEntry;
const allTools = {
compress,
extractImages,
ocr,
sign,
} as unknown as ToolRegistryCatalog["allTools"];
const catalog: ToolRegistryCatalog = {
regularTools: allTools,
@@ -288,6 +330,16 @@ describe("PipelineBuilder", () => {
fetchS3Connections.mockReset();
fetchS3Connections.mockResolvedValue([]);
createIntegration.mockReset();
uploadPipelineAsset.mockReset();
uploadPipelineAsset.mockResolvedValue({
id: "ast-1",
fileName: "logo.png",
contentType: "image/png",
size: 1,
createdAt: 0,
});
listPipelineAssets.mockReset();
listPipelineAssets.mockResolvedValue([]);
});
// The settings of a node are reached by selecting it in the graph, so every helper below opens
@@ -798,7 +850,7 @@ describe("PipelineBuilder", () => {
).toBeInTheDocument();
});
it("blocks saving a step that needs an uploaded file", async () => {
it("uploads a step's supporting file and saves it as an asset binding", async () => {
renderBuilder("/processor/pipelines/new");
fireEvent.change(
@@ -810,15 +862,65 @@ describe("PipelineBuilder", () => {
},
);
await addTool("Compress");
// The tool's settings upload a file, which a stored pipeline can't persist yet.
// The tool's settings attach a supporting file.
fireEvent.click(await screen.findByText("upload logo"));
expect(
await screen.findByText("portal.pipelines.builder.uploadUnsupported"),
).toBeInTheDocument();
expect(
screen.getByText("portal.pipelines.composer.create").closest("button"),
).toBeDisabled();
await pickInputSource("Claims intake");
await pickDestination();
fireEvent.click(screen.getByText("portal.pipelines.composer.create"));
// The file is uploaded to the asset store first, then the policy is saved binding that asset.
await waitFor(() => expect(uploadPipelineAsset).toHaveBeenCalledTimes(1));
expect(uploadPipelineAsset.mock.calls[0][0]).toBeInstanceOf(File);
await waitFor(() => expect(savePipeline).toHaveBeenCalledTimes(1));
expect(savePipeline).toHaveBeenCalledWith(
expect.objectContaining({
steps: [
expect.objectContaining({
operation: "/api/v1/misc/compress-pdf",
fileParameters: { watermarkImage: "asset:ast-1" },
}),
],
}),
);
});
it("keeps a step's stored file binding on save when the tool can't be probed", async () => {
// buildFormData throws for `sign`, so activeFileFields is null. The stored binding must survive
// the save unchanged - dropping it would let the server GC the user's uploaded file - and no
// re-upload should happen.
fetchPipeline.mockResolvedValue({
id: "plc-sign",
name: "Signed",
enabled: true,
inputs: [{ sourceId: "src-in", trigger: null }],
steps: [
{
operation: "/api/v1/security/cert-sign",
parameters: {},
fileParameters: { certFile: "asset:x" },
},
],
output: { type: "inline", options: {} },
outputIds: ["src-1"],
});
renderBuilder("/processor/pipelines/plc-sign");
fireEvent.click(await screen.findByText("portal.pipelines.composer.save"));
await waitFor(() => expect(savePipeline).toHaveBeenCalledTimes(1));
expect(savePipeline).toHaveBeenCalledWith(
expect.objectContaining({
steps: [
expect.objectContaining({
operation: "/api/v1/security/cert-sign",
fileParameters: { certFile: "asset:x" },
}),
],
}),
);
expect(uploadPipelineAsset).not.toHaveBeenCalled();
});
it("blocks saving an integration step with no account chosen", async () => {
@@ -17,14 +17,17 @@ import {
} from "@app/ui";
import { useToolRegistry } from "@app/contexts/ToolRegistryContext";
import {
activeFileFields,
assetRef,
deserializeToolStep,
extractStepFiles,
getExecutableTools,
newWorkingToolStep,
serializeToolStep,
stepNeedsConfiguring,
stepRequiresUpload,
updateWorkingStepParams,
type ExecutableTool,
type SupportingFileBindings,
type WorkingToolStep,
} from "@app/hooks/tools/shared/toolAutomation";
import {
@@ -48,13 +51,20 @@ import {
runPipelineTest,
savePipeline,
triggerPipeline,
type PipelineStep,
type Policy,
type PolicyRunView,
type RunOutputFile,
type TestRunAsset,
type TriggerConfig,
type TriggerInfo,
type TriggerOutcome,
} from "@portal/api/pipelines";
import {
listPipelineAssets,
uploadPipelineAsset,
type PolicyAsset,
} from "@portal/api/pipelineAssets";
import { clearProcessedHistory } from "@portal/api/policies";
import { DestinationPicker } from "@portal/components/pipelines/DestinationPicker";
import { availableOutputModes } from "@portal/components/pipelines/outputModes";
@@ -207,6 +217,17 @@ export function PipelineBuilder() {
[allTools],
);
// Stored supporting files from earlier saves, so a reopened step can label its bindings by name.
const assetsState = useAsync<PolicyAsset[]>(
async () => await listPipelineAssets(),
[],
);
const assetNames = useMemo(() => {
const map: Record<string, string> = {};
for (const asset of assetsState.data ?? []) map[asset.id] = asset.fileName;
return map;
}, [assetsState.data]);
const policyState = useAsync<Policy | null>(
async () => (id ? await fetchPipeline(id) : null),
[id],
@@ -486,6 +507,21 @@ export function PipelineBuilder() {
);
}
/** Drop a step's stored supporting-file binding for one field (the chip's remove action). */
function clearStepBinding(index: number, field: string) {
setSteps((current) =>
current.map((step, i) => {
if (i !== index || !step.fileParameters) return step;
const next = { ...step.fileParameters };
delete next[field];
return {
...step,
fileParameters: Object.keys(next).length > 0 ? next : undefined,
};
}),
);
}
function stepLabel(step: WorkingToolStep): string {
// An integration step's endpoint is the same for every vendor, so the raw path would read
// "External api call" for all of them. Name it by the operation instead.
@@ -512,11 +548,6 @@ export function PipelineBuilder() {
return step.toolId ? allTools[step.toolId]?.icon : undefined;
}
// Steps whose params carry an uploaded file can't be saved: the bytes aren't persisted with the
// policy, so a later run would send null for that field (see stepRequiresUpload).
const uploadStepLabels = steps.filter(stepRequiresUpload).map(stepLabel);
const hasUploadSteps = uploadStepLabels.length > 0;
// A step still missing a choice - an integration with no operation or account, a tool whose
// mandatory parameters are unset - would fail at run time with a raw backend rejection, so block
// saving on it here where the fix is one click away.
@@ -601,11 +632,30 @@ export function PipelineBuilder() {
// seeding, so leaving the builder can prompt to save or discard. `enabled` is deliberately left
// out: in edit it is toggled and persisted at once (never an unsaved edit), and in create it is
// chosen at submit - so it can never be the thing that makes the form dirty.
// Per-step dirty signature: the serialized step plus a stable identity (name/size/mtime) of its
// fresh file picks - a raw File JSON-stringifies to `{}`, so serializeToolStep (which excludes
// Files) can't see a file added or swapped. Memoized on the steps because it probes each tool's
// buildFormData; without this it would re-run for every step on any render (e.g. each keystroke in
// the name field). Stored bindings are covered by the serialized step.
const stepSnapshot = useMemo(
() =>
steps.map((step) => {
const files: Record<string, string[]> = {};
for (const [field, picks] of Object.entries(
extractStepFiles(step, allTools),
)) {
files[field] = picks.map(
(file) => `${file.name}:${file.size}:${file.lastModified}`,
);
}
return { step: serializeToolStep(step, allTools), files };
}),
[steps, allTools],
);
const snapshot = JSON.stringify({
name: name.trim(),
input,
steps: steps.map((step) => serializeToolStep(step, allTools)),
uploads: steps.map(stepRequiresUpload),
steps: stepSnapshot,
outputIds: [...outputIds].sort(),
});
const baseline = useRef<string | null>(null);
@@ -639,12 +689,6 @@ export function PipelineBuilder() {
tools: unconfiguredStepLabels.join(", "),
}),
);
if (hasUploadSteps)
blockers.push(
t("portal.pipelines.builder.blocker.upload", {
tools: uploadStepLabels.join(", "),
}),
);
if (hasIncompatibleSteps)
blockers.push(
t("portal.pipelines.builder.blocker.incompatible", {
@@ -667,23 +711,84 @@ export function PipelineBuilder() {
else navigate(destination);
}
/**
* The active supporting-file fields of a step, each paired with its fresh in-memory pick(s) and its
* stored `asset:<id>` binding (either may be absent). The single source both saving and test-running
* read, so the two agree on which fields are active and how a binding is chosen; they differ only in
* how a fresh pick is emitted - uploaded as an asset vs. sent inline.
*/
function stepFileFields(
step: WorkingToolStep,
): { field: string; fresh: File[] | null; stored: string | null }[] {
const fresh = extractStepFiles(step, allTools);
const stored = step.fileParameters ?? {};
const fields = activeFileFields(step, allTools) ?? Object.keys(stored);
return fields.map((field) => ({
field,
fresh: fresh[field] ?? null,
stored: stored[field] ?? null,
}));
}
/** A wire step, attaching fileParameters only when it has any. */
function toWireStep(
operation: string,
parameters: Record<string, unknown>,
bindings: SupportingFileBindings,
): PipelineStep {
return Object.keys(bindings).length > 0
? { operation, parameters, fileParameters: bindings }
: { operation, parameters };
}
/**
* The wire steps for saving: scalar params from serialization, plus supporting-file bindings. A
* fresh pick is uploaded to the asset store (the save-time validator rejects a policy that binds an
* asset id that doesn't yet exist); a stored binding the tool still uses is kept when the user
* didn't replace it. Uploads run in parallel; any abandoned by a later failure are GC'd server-side.
*/
async function serializeStepsForSave(): Promise<PipelineStep[]> {
return Promise.all(
steps.map(async (step) => {
const { operation, parameters } = serializeToolStep(step, allTools);
const entries = await Promise.all(
stepFileFields(step).map(async ({ field, fresh, stored }) => {
if (fresh?.length) {
const ids = await Promise.all(
fresh.map((file) =>
uploadPipelineAsset(file).then((a) => a.id),
),
);
return [field, assetRef(ids)] as const;
}
return stored ? ([field, stored] as const) : null;
}),
);
const bindings: SupportingFileBindings = Object.fromEntries(
entries.filter((e): e is readonly [string, string] => e !== null),
);
return toWireStep(operation, parameters, bindings);
}),
);
}
async function save(destination: string, enabledOverride?: boolean) {
if (!canSave) return;
setSubmitting(true);
setError(null);
const policy: Policy = {
id: policyState.data?.id ?? undefined,
name: name.trim(),
enabled: enabledOverride ?? enabled,
// The wire shape stays a list; canSave guarantees the one input has a source.
inputs: [{ sourceId: input.sourceId, trigger: buildTriggerFor(input) }],
steps: steps.map((step) => serializeToolStep(step, allTools)),
// Destinations are the referenced saved sources; the inline output field is
// preserved as-is (e.g. an editor policy's membership metadata) or defaults to inline.
output: policyState.data?.output ?? { type: "inline", options: {} },
outputIds,
};
try {
const policy: Policy = {
id: policyState.data?.id ?? undefined,
name: name.trim(),
enabled: enabledOverride ?? enabled,
// The wire shape stays a list; canSave guarantees the one input has a source.
inputs: [{ sourceId: input.sourceId, trigger: buildTriggerFor(input) }],
steps: await serializeStepsForSave(),
// Destinations are the referenced saved sources; the inline output field is
// preserved as-is (e.g. an editor policy's membership metadata) or defaults to inline.
output: policyState.data?.output ?? { type: "inline", options: {} },
outputIds,
};
await savePipeline(policy);
await invalidatePipelines();
navigate(destination);
@@ -741,6 +846,32 @@ export function PipelineBuilder() {
return null;
}
/**
* The steps + inline supporting files for a test run. A fresh (in-memory) pick rides along as a
* keyed `assets[i]` under a per-step run key; a stored file keeps its `asset:<id>` binding, which
* the backend resolves from the pipeline's saved policy (passed as policyId) - no re-fetch needed.
*/
function buildTestSteps(): { steps: PipelineStep[]; assets: TestRunAsset[] } {
const assets: TestRunAsset[] = [];
const outSteps = steps.map((step, i) => {
const { operation, parameters } = serializeToolStep(step, allTools);
const bindings: SupportingFileBindings = {};
for (const { field, fresh, stored } of stepFileFields(step)) {
if (fresh?.length) {
// In-memory pick: inline the bytes under a run key.
const key = `s${i}_${field}`;
bindings[field] = key;
for (const file of fresh) assets.push({ key, file });
} else if (stored) {
// Already an asset: keep its ref for the backend to resolve from the saved policy.
bindings[field] = stored;
}
}
return toWireStep(operation, parameters, bindings);
});
return { steps: outSteps, assets };
}
/**
* Run the steps as they stand against one uploaded file. Output is forced inline so nothing
* reaches the pipeline's real destination, and the pipeline need not be saved first - this is
@@ -752,13 +883,17 @@ export function PipelineBuilder() {
setTestRun(null);
setRunResult(null);
try {
const { steps: testSteps, assets } = buildTestSteps();
const { runId } = await runPipelineTest(
{
name: name.trim() || t("portal.pipelines.builder.testRun"),
steps: steps.map((step) => serializeToolStep(step, allTools)),
steps: testSteps,
output: { type: "inline", options: {} },
},
file,
assets,
// Lets the backend resolve any stored `asset:<id>` refs from this saved policy.
policyState.data?.id,
);
const final = await awaitRun(runId, (view) => {
if (mounted.current) setTestRun(view);
@@ -934,8 +1069,6 @@ export function PipelineBuilder() {
return t("portal.pipelines.builder.chooseAccount");
return undefined;
}
if (stepRequiresUpload(step))
return t("portal.pipelines.builder.needsUpload");
if (stepNeedsConfiguring(step, allTools))
return t("portal.pipelines.builder.needsConfiguring");
return undefined;
@@ -1120,6 +1253,8 @@ export function PipelineBuilder() {
step={selectedStep}
registry={allTools}
onChange={(params) => updateStepParams(chosenSteps[0], params)}
assetNames={assetNames}
onClearBinding={(field) => clearStepBinding(chosenSteps[0], field)}
/>
);
}
@@ -1165,14 +1300,6 @@ export function PipelineBuilder() {
{runResult && (
<Banner tone={runResult.tone} description={runResult.text} />
)}
{hasUploadSteps && (
<Banner
tone="warning"
description={t("portal.pipelines.builder.uploadUnsupported", {
tools: uploadStepLabels.join(", "),
})}
/>
)}
{hasUnconfiguredSteps && (
<Banner
tone="warning"
@@ -48,7 +48,7 @@ const FREE_LICENSE_INFO: LicenseInfo = {
const BASE_NO_LOGIN_CONFIG: AppConfig = {
enableAnalytics: true,
appVersion: "2.14.2",
appVersion: "2.14.3",
serverCertificateEnabled: false,
enableAlphaFunctionality: false,
enableDesktopInstallSlide: true,
+26 -12
View File
@@ -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": {
+2 -1
View File
@@ -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"
}