mirror of
https://github.com/Stirling-Tools/Stirling-PDF.git
synced 2026-09-03 05:10:16 +03:00
Merge remote-tracking branch 'origin/main' into feature/failure-notifications
# Conflicts: # frontend/editor/src/core/components/layout/Workbench.tsx
This commit is contained in:
@@ -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,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')
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -20,7 +20,9 @@ permissions:
|
||||
|
||||
jobs:
|
||||
build:
|
||||
environment: ci-unsigned
|
||||
environment:
|
||||
name: ci-unsigned
|
||||
deployment: false
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
fail-fast: false
|
||||
|
||||
@@ -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: >-
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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=
|
||||
|
||||
+5
@@ -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();
|
||||
|
||||
+11
@@ -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
|
||||
|
||||
+62
-10
@@ -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
|
||||
|
||||
+12
@@ -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();
|
||||
|
||||
+116
-6
@@ -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);
|
||||
}
|
||||
}
|
||||
+5
@@ -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
|
||||
+12
@@ -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
@@ -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");
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
Generated
+26
-12
@@ -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": {
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user