Merge branch 'main' into feat/auto-form-detection-server-only

This commit is contained in:
Anthony Stirling
2026-08-28 11:45:47 +01:00
committed by GitHub
318 changed files with 11080 additions and 2097 deletions
-5
View File
@@ -8,11 +8,6 @@ updates:
- package-ecosystem: "gradle" # See documentation for possible values
directories:
- "/" # Location of package manifests
- "/app/common"
- "/app/core"
- "/app/proprietary"
- "/app/saas"
- "/buildSrc"
schedule:
interval: "weekly"
cooldown:
+1
View File
@@ -67,6 +67,7 @@ labels:
- 'frontend/**'
- 'frontend/.*'
- 'frontend/**/.*'
- '.taskfiles/frontend.yml'
- label: 'Tauri'
files:
+246
View File
@@ -0,0 +1,246 @@
name: Auto SaaS Dev Deployment
on:
push:
branches:
- saas-prod
workflow_dispatch:
permissions:
contents: read
env:
FRONTEND_PORT: "901"
BACKEND_PORT: "902"
DEPLOY_DIR: /stirling/SAAS-DEV
jobs:
deploy-saas-dev:
runs-on: ubuntu-latest
environment: saas-dev
concurrency:
group: saas-dev-deploy
cancel-in-progress: true
permissions:
contents: read
packages: write
steps:
- name: Harden Runner
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
with:
egress-policy: audit
- name: Check SaaS configuration
id: config
env:
PROJECT_REF: ${{ secrets.SAAS_DB_PROJECT_REF }}
run: |
echo "supabase_url=https://${PROJECT_REF}.supabase.co" >> "$GITHUB_OUTPUT"
echo "meter_endpoint=https://${PROJECT_REF}.supabase.co/functions/v1/meter-payg-units" >> "$GITHUB_OUTPUT"
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
fetch-depth: 0
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0
- name: Login to GitHub Container Registry
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ github.token }}
- name: Convert repository owner to lowercase
id: repoowner
run: echo "lowercase=$(echo ${{ github.repository_owner }} | awk '{print tolower($0)}')" >> $GITHUB_OUTPUT
- name: Get commit hash
id: commit-hash
run: echo "app_short=$(git rev-parse --short=8 HEAD)" >> $GITHUB_OUTPUT
- name: Build and push backend image
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
with:
context: .
file: ./docker/backend/Dockerfile
push: true
cache-from: type=gha,scope=stirling-saas-backend
cache-to: type=gha,mode=max,scope=stirling-saas-backend
tags: |
ghcr.io/${{ steps.repoowner.outputs.lowercase }}/stirling-pdf-test:saas-backend-${{ steps.commit-hash.outputs.app_short }}
ghcr.io/${{ steps.repoowner.outputs.lowercase }}/stirling-pdf-test:saas-backend-latest
build-args: |
VERSION_TAG=v2-alpha
STIRLING_FLAVOR=saas
platforms: linux/amd64
- name: Build and push frontend image
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
with:
context: .
file: ./docker/frontend/Dockerfile
push: true
cache-from: type=gha,scope=stirling-saas-frontend
cache-to: type=gha,mode=max,scope=stirling-saas-frontend
tags: |
ghcr.io/${{ steps.repoowner.outputs.lowercase }}/stirling-pdf-test:saas-frontend-${{ steps.commit-hash.outputs.app_short }}
ghcr.io/${{ steps.repoowner.outputs.lowercase }}/stirling-pdf-test:saas-frontend-latest
build-args: |
VERSION_TAG=v2-alpha
STIRLING_FLAVOR=saas
VITE_BUILD_MODE=development
VITE_SUPABASE_URL=${{ steps.config.outputs.supabase_url }}
VITE_SUPABASE_PUBLISHABLE_DEFAULT_KEY=${{ secrets.SAAS_SUPABASE_PUBLISHABLE_KEY }}
platforms: linux/amd64
- name: Build and push AI engine image
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
with:
context: .
file: ./engine/Dockerfile
push: true
cache-from: type=gha,scope=stirling-saas-engine
cache-to: type=gha,mode=max,scope=stirling-saas-engine
tags: |
ghcr.io/${{ steps.repoowner.outputs.lowercase }}/stirling-pdf-test:saas-engine-${{ steps.commit-hash.outputs.app_short }}
ghcr.io/${{ steps.repoowner.outputs.lowercase }}/stirling-pdf-test:saas-engine-latest
platforms: linux/amd64
- name: Set up SSH
env:
SSH_KEY: ${{ secrets.NEW_VPS_SSH_KEY }}
run: |
mkdir -p ~/.ssh/
echo "$SSH_KEY" > ../private.key
sudo chmod 600 ../private.key
- name: Deploy to VPS
env:
IMAGE_BASE: ghcr.io/${{ steps.repoowner.outputs.lowercase }}/stirling-pdf-test
IMAGE_TAG: ${{ steps.commit-hash.outputs.app_short }}
GHCR_USER: ${{ github.actor }}
GHCR_TOKEN: ${{ github.token }}
VPS_USERNAME: ${{ secrets.NEW_VPS_USERNAME }}
VPS_HOST: ${{ secrets.NEW_VPS_HOST }}
SAAS_DB_URL: ${{ secrets.SAAS_DB_URL }}
SAAS_DB_USERNAME: ${{ secrets.SAAS_DB_USERNAME || 'postgres' }}
SAAS_DB_PASSWORD: ${{ secrets.SAAS_DB_PASSWORD }}
SAAS_DB_PROJECT_REF: ${{ secrets.SAAS_DB_PROJECT_REF }}
SUPABASE_EDGE_FUNCTION_SECRET: ${{ secrets.SUPABASE_EDGE_FUNCTION_SECRET }}
PAYG_METER_ENDPOINT: ${{ steps.config.outputs.meter_endpoint }}
STIRLING_KEYGEN_ENABLED: ${{ secrets.KEYGEN_ACCOUNT_ID != '' && secrets.KEYGEN_API_TOKEN != '' && secrets.KEYGEN_POLICY_ID != '' }}
KEYGEN_ACCOUNT_ID: ${{ secrets.KEYGEN_ACCOUNT_ID }}
KEYGEN_API_TOKEN: ${{ secrets.KEYGEN_API_TOKEN }}
KEYGEN_POLICY_ID: ${{ secrets.KEYGEN_POLICY_ID }}
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
VOYAGE_API_KEY: ${{ secrets.VOYAGE_API_KEY }}
run: |
set -euo pipefail
BASE_URL="http://${VPS_HOST}:${FRONTEND_PORT}"
yaml() {
printf "'%s'" "$(printf '%s' "$1" | sed -e "s/'/''/g" -e 's/\$/$$/g')"
}
ENGINE_SECRET="$(openssl rand -hex 32)"
AI_BACKEND_VARS="
SYSTEM_AIENGINE_ENABLED: \"true\"
SYSTEM_AIENGINE_URL: \"http://saas-engine:5001\"
APP_AI_SERVICEBASEURL: \"http://saas-engine:5001\"
STIRLING_ENGINE_SHARED_SECRET: $(yaml "$ENGINE_SECRET")"
AI_SERVICE="
saas-engine:
container_name: stirling-saas-dev-engine
image: ${IMAGE_BASE}:saas-engine-${IMAGE_TAG}
environment:
ANTHROPIC_API_KEY: $(yaml "$ANTHROPIC_API_KEY")
VOYAGE_API_KEY: $(yaml "$VOYAGE_API_KEY")
STIRLING_ENGINE_SHARED_SECRET: $(yaml "$ENGINE_SECRET")
restart: on-failure:5"
cat > docker-compose.yml << EOF
version: '3.3'
services:
saas-backend:
container_name: stirling-saas-dev-backend
image: ${IMAGE_BASE}:saas-backend-${IMAGE_TAG}
ports:
- "${BACKEND_PORT}:8080"
volumes:
- ${DEPLOY_DIR}/config:/configs:rw
- ${DEPLOY_DIR}/logs:/logs:rw
- ${DEPLOY_DIR}/storage:/storage:rw
environment:
SPRING_PROFILES_ACTIVE: "saas"
DISABLE_ADDITIONAL_FEATURES: "false"
SAAS_DB_URL: $(yaml "$SAAS_DB_URL")
SAAS_DB_USERNAME: $(yaml "$SAAS_DB_USERNAME")
SAAS_DB_PASSWORD: $(yaml "$SAAS_DB_PASSWORD")
SAAS_DB_PROJECT_REF: $(yaml "$SAAS_DB_PROJECT_REF")
SUPABASE_EDGE_FUNCTION_SECRET: $(yaml "$SUPABASE_EDGE_FUNCTION_SECRET")
PAYG_METER_ENDPOINT: $(yaml "$PAYG_METER_ENDPOINT")
STIRLING_KEYGEN_ENABLED: $(yaml "$STIRLING_KEYGEN_ENABLED")
KEYGEN_ACCOUNT_ID: $(yaml "$KEYGEN_ACCOUNT_ID")
KEYGEN_API_TOKEN: $(yaml "$KEYGEN_API_TOKEN")
KEYGEN_POLICY_ID: $(yaml "$KEYGEN_POLICY_ID")
SYSTEM_DEFAULTLOCALE: en-US
SYSTEM_MAXFILESIZE: "100"
METRICS_ENABLED: "true"
SYSTEM_GOOGLEVISIBILITY: "false"
SWAGGER_SERVER_URL: "${BASE_URL}"
baseUrl: "${BASE_URL}"${AI_BACKEND_VARS}
restart: on-failure:5
saas-frontend:
container_name: stirling-saas-dev-frontend
image: ${IMAGE_BASE}:saas-frontend-${IMAGE_TAG}
ports:
- "${FRONTEND_PORT}:80"
environment:
VITE_API_BASE_URL: "http://saas-backend:8080"
depends_on:
- saas-backend
restart: on-failure:5${AI_SERVICE}
EOF
SSH_OPTS=(-i ../private.key -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null)
scp "${SSH_OPTS[@]}" docker-compose.yml "${VPS_USERNAME}@${VPS_HOST}:/tmp/saas-dev-docker-compose.yml"
ssh "${SSH_OPTS[@]}" -T "${VPS_USERNAME}@${VPS_HOST}" << ENDSSH
set -e
mkdir -p ${DEPLOY_DIR}/{config,logs,storage}
mv /tmp/saas-dev-docker-compose.yml ${DEPLOY_DIR}/docker-compose.yml
chmod 600 ${DEPLOY_DIR}/docker-compose.yml
cd ${DEPLOY_DIR}
printf '%s' "${GHCR_TOKEN}" | docker login ghcr.io -u "${GHCR_USER}" --password-stdin
docker-compose down --remove-orphans 2>/dev/null || true
docker-compose pull
docker-compose up -d
docker logout ghcr.io >/dev/null 2>&1 || true
docker image prune -af --filter "until=336h" --filter "label!=keep=true" || true
ENDSSH
- name: Wait for the backend to answer
env:
VPS_HOST: ${{ secrets.NEW_VPS_HOST }}
run: |
URL="http://${VPS_HOST}:${BACKEND_PORT}/api/v1/info/status"
for i in $(seq 1 60); do
code=$(curl -s -o /dev/null -w '%{http_code}' --max-time 5 "$URL" || true)
if [ "$code" = "200" ]; then echo "Healthy after $((i * 10))s"; exit 0; fi
sleep 10
done
echo "::error::SaaS dev backend did not become healthy within 10 minutes"
exit 1
- name: Cleanup temporary files
if: always()
run: rm -f ../private.key docker-compose.yml
continue-on-error: true
+62 -3
View File
@@ -40,12 +40,15 @@ tasks:
AIENGINE_ENABLED: '{{.AIENGINE_ENABLED | default "false"}}'
AIENGINE_TIMEOUTSECONDS: '{{.AIENGINE_TIMEOUTSECONDS | default "120"}}'
SECURITY_ENABLELOGIN: '{{.SECURITY_ENABLELOGIN | default ""}}'
# Set by dev:linked. Inline rather than in `env:` so an empty value emits nothing
# and cannot blank the committed default.
ACCOUNT_LINK_SAAS_BASE_URL: '{{.ACCOUNT_LINK_SAAS_BASE_URL | default ""}}'
env:
SERVER_PORT: '{{.PORT}}'
cmds:
- cmd: '{{if .AIENGINE_URL}}AIENGINE_URL={{.AIENGINE_URL}} AIENGINE_ENABLED={{.AIENGINE_ENABLED}} AIENGINE_TIMEOUTSECONDS={{.AIENGINE_TIMEOUTSECONDS}} {{end}}{{if .SECURITY_ENABLELOGIN}}SECURITY_ENABLELOGIN={{.SECURITY_ENABLELOGIN}} {{end}}cmd /c ".\gradlew.bat :stirling-pdf:bootRun"'
- cmd: '{{if .AIENGINE_URL}}AIENGINE_URL={{.AIENGINE_URL}} AIENGINE_ENABLED={{.AIENGINE_ENABLED}} AIENGINE_TIMEOUTSECONDS={{.AIENGINE_TIMEOUTSECONDS}} {{end}}{{if .SECURITY_ENABLELOGIN}}SECURITY_ENABLELOGIN={{.SECURITY_ENABLELOGIN}} {{end}}{{if .ACCOUNT_LINK_SAAS_BASE_URL}}STIRLING_BILLING_ACCOUNT_LINK_ENABLED=true STIRLING_BILLING_ACCOUNT_LINK_SAAS_BASE_URL={{.ACCOUNT_LINK_SAAS_BASE_URL}} {{end}}cmd /c ".\gradlew.bat :stirling-pdf:bootRun"'
platforms: [windows]
- cmd: '{{if .AIENGINE_URL}}AIENGINE_URL={{.AIENGINE_URL}} AIENGINE_ENABLED={{.AIENGINE_ENABLED}} AIENGINE_TIMEOUTSECONDS={{.AIENGINE_TIMEOUTSECONDS}} {{end}}{{if .SECURITY_ENABLELOGIN}}SECURITY_ENABLELOGIN={{.SECURITY_ENABLELOGIN}} {{end}}./gradlew :stirling-pdf:bootRun'
- cmd: '{{if .AIENGINE_URL}}AIENGINE_URL={{.AIENGINE_URL}} AIENGINE_ENABLED={{.AIENGINE_ENABLED}} AIENGINE_TIMEOUTSECONDS={{.AIENGINE_TIMEOUTSECONDS}} {{end}}{{if .SECURITY_ENABLELOGIN}}SECURITY_ENABLELOGIN={{.SECURITY_ENABLELOGIN}} {{end}}{{if .ACCOUNT_LINK_SAAS_BASE_URL}}STIRLING_BILLING_ACCOUNT_LINK_ENABLED=true STIRLING_BILLING_ACCOUNT_LINK_SAAS_BASE_URL={{.ACCOUNT_LINK_SAAS_BASE_URL}} {{end}}./gradlew :stirling-pdf:bootRun'
platforms: [linux, darwin]
dev:bundled:
@@ -84,6 +87,8 @@ tasks:
AIENGINE_URL: '{{.AIENGINE_URL}}'
AIENGINE_ENABLED: '{{.AIENGINE_ENABLED}}'
AIENGINE_TIMEOUTSECONDS: '{{.AIENGINE_TIMEOUTSECONDS}}'
APP_BASE_URL: '{{.APP_BASE_URL}}'
BASE_PATH: '{{.BASE_PATH}}'
staging:saas:
desc: "Start SaaS backend against the shared v3 staging project"
@@ -95,10 +100,47 @@ tasks:
AIENGINE_URL: '{{.AIENGINE_URL}}'
AIENGINE_ENABLED: '{{.AIENGINE_ENABLED}}'
AIENGINE_TIMEOUTSECONDS: '{{.AIENGINE_TIMEOUTSECONDS}}'
APP_BASE_URL: '{{.APP_BASE_URL}}'
BASE_PATH: '{{.BASE_PATH}}'
dev:linked:
desc: "Self-hosted backend linked to a locally running SaaS backend (see task linked:*)"
ignore_error: true
vars:
PORT: '{{.PORT | default "8080"}}'
SAAS_BASE_URL: '{{.SAAS_BASE_URL | default "http://localhost:8081"}}'
cmds:
- 'echo ">> self-hosted :{{.PORT}} linking to SaaS at {{.SAAS_BASE_URL}}"'
# The two backends run different STIRLING_FLAVOURs, which are different Gradle
# project graphs sharing one build/ tree. Waiting avoids overlapping builds; it
# does not make the sharing safe, so avoid rebuilding one while the other runs.
- cmd: |
n=0
while [ "$n" -lt 150 ]; do
if curl -s -m 2 "{{.SAAS_BASE_URL}}" >/dev/null 2>&1; then
echo ">> SaaS backend is up, starting self-hosted"
break
fi
n=$((n + 1))
{{if eq OS "windows"}}powershell -NoProfile -Command "Start-Sleep -Seconds 2"{{else}}sleep 2{{end}}
done
if [ "$n" -ge 150 ]; then
echo ">> SaaS backend never answered; starting anyway"
fi
- task: dev:proprietary
vars:
PORT: '{{.PORT}}'
ACCOUNT_LINK_SAAS_BASE_URL: '{{.SAAS_BASE_URL}}'
_run:saas:
internal: true
dotenv: ['app/.env.saas.local', 'app/.env.saas']
# The frontend files are here only for RUN_SUBPATH, which the authorize URL needs.
# Last, because dotenv is set-if-absent: app/* still decides everything else.
dotenv:
- 'app/.env.saas.local'
- 'app/.env.saas'
- 'frontend/editor/.env.saas.local'
- 'frontend/editor/.env.saas'
ignore_error: true
vars:
PORT: '{{.PORT | default "8080"}}'
@@ -111,12 +153,29 @@ tasks:
AIENGINE_URL: '{{.AIENGINE_URL | default ""}}'
AIENGINE_ENABLED: '{{.AIENGINE_ENABLED | default "false"}}'
AIENGINE_TIMEOUTSECONDS: '{{.AIENGINE_TIMEOUTSECONDS | default "120"}}'
# Empty is the same as unset: the property defaults to empty and is blank-checked.
APP_BASE_URL: '{{.APP_BASE_URL | default ""}}'
# Relocates configs/pipeline/logs, for a second backend in the same directory.
# Empty is the same as unset: the reader blank-checks it.
BASE_PATH: '{{.BASE_PATH | default ""}}'
env:
SERVER_PORT: '{{.PORT}}'
STIRLING_FLAVOR: saas
STIRLING_BASE_PATH: '{{.BASE_PATH}}'
AIENGINE_URL: '{{.AIENGINE_URL}}'
AIENGINE_ENABLED: '{{.AIENGINE_ENABLED}}'
AIENGINE_TIMEOUTSECONDS: '{{.AIENGINE_TIMEOUTSECONDS}}'
# Appends RUN_SUBPATH: the approval page is at <base>/link, so a subpath build
# serves it at <base>/app/link. An explicit value still wins.
SYSTEM_FRONTENDURL:
sh: |
if [ -n "${SYSTEM_FRONTENDURL:-}" ]; then
echo "${SYSTEM_FRONTENDURL}"
elif [ -n "{{.APP_BASE_URL}}" ] && [ -n "${RUN_SUBPATH:-}" ]; then
echo "{{.APP_BASE_URL}}/${RUN_SUBPATH}"
else
echo "{{.APP_BASE_URL}}"
fi
cmds:
# PROFILE_ARGS is empty when PROFILES=none, i.e. the bare `saas` profile
# against SAAS_DB_* (production).
+14 -4
View File
@@ -23,7 +23,7 @@ tasks:
- package-lock.json
- package.json
status:
- test -d node_modules
- npm ls --depth=0
env:
CI: '{{ .CI | default "false" }}'
@@ -121,17 +121,17 @@ tasks:
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}" ;;
*) ref="${SAAS_DEV_PROJECT_REF:?set it in app/.env.saas.local, or pass SAAS_ENV=staging}" ;;
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}" ;;
*) echo "${SAAS_DEV_PUBLISHABLE_KEY:?set it in app/.env.saas.local, or pass SAAS_ENV=staging}" ;;
esac
cmds:
- 'echo ">> frontend Supabase target: $VITE_SUPABASE_URL"'
- 'echo ">> frontend {{.SAAS_ENV}}: Supabase $VITE_SUPABASE_URL, backend $BACKEND_URL"'
- npx vite editor --mode saas --port {{.PORT}}{{if .OPEN}} --open{{end}}
dev:
@@ -173,6 +173,16 @@ tasks:
OPEN: '{{.OPEN}}'
SAAS_ENV: '{{.SAAS_ENV}}'
staging:saas:
desc: "Start frontend dev server against the shared v3 staging project"
cmds:
- task: dev:saas
vars:
SAAS_ENV: staging
PORT: '{{.PORT}}'
BACKEND_URL: '{{.BACKEND_URL}}'
OPEN: '{{.OPEN}}'
dev:desktop:
desc: "Start frontend dev server in desktop mode"
deps:
+86
View File
@@ -121,6 +121,92 @@ tasks:
cmds:
- task: dev:_all
# No engine: linking never calls it.
linked:staging:
desc: "SaaS on the shared v3 project + a self-hosted instance linked to it"
cmds:
- task: linked:_all
vars: { SAAS_ENV: staging }
linked:dev:
desc: "SaaS on the current PR's preview branch + a self-hosted instance linked to it"
cmds:
- task: linked:_all
vars: { SAAS_ENV: dev }
linked:_all:
internal: true
vars:
SAAS_ENV: '{{.SAAS_ENV | default "staging"}}'
PORTS:
sh: '{{if eq OS "windows"}}{{.FIND_FREE_PORT_PS}} 8081 5174 8080 5173{{else}}{{.FIND_FREE_PORT_SH}} 8081 5174 8080 5173{{end}}'
SAAS_BACKEND_PORT: '{{index (splitList "\n" .PORTS) 0}}'
SAAS_FRONTEND_PORT: '{{index (splitList "\n" .PORTS) 1}}'
APP_BACKEND_PORT: '{{index (splitList "\n" .PORTS) 2}}'
APP_FRONTEND_PORT: '{{index (splitList "\n" .PORTS) 3}}'
deps:
# APP_BASE_URL is the SaaS *frontend*: the approval page is served by vite, not
# by the API. BASE_PATH moves this backend's configs/pipeline aside so it does not
# race the self-hosted one, which keeps ./configs and its existing database.
- task: 'backend:{{.SAAS_ENV}}:saas'
vars:
PORT: '{{.SAAS_BACKEND_PORT}}'
APP_BASE_URL: 'http://localhost:{{.SAAS_FRONTEND_PORT}}'
BASE_PATH: 'tmp/linked-saas'
- task: frontend:dev:saas
vars:
PORT: '{{.SAAS_FRONTEND_PORT}}'
BACKEND_URL: 'http://localhost:{{.SAAS_BACKEND_PORT}}'
SAAS_ENV: '{{.SAAS_ENV}}'
- task: backend:dev:linked
vars:
PORT: '{{.APP_BACKEND_PORT}}'
SAAS_BASE_URL: 'http://localhost:{{.SAAS_BACKEND_PORT}}'
- task: frontend:dev:proprietary
vars:
PORT: '{{.APP_FRONTEND_PORT}}'
BACKEND_URL: 'http://localhost:{{.APP_BACKEND_PORT}}'
OPEN: "true"
- task: linked:_ready
vars:
SAAS_BACKEND_PORT: '{{.SAAS_BACKEND_PORT}}'
SAAS_FRONTEND_PORT: '{{.SAAS_FRONTEND_PORT}}'
APP_BACKEND_PORT: '{{.APP_BACKEND_PORT}}'
APP_FRONTEND_PORT: '{{.APP_FRONTEND_PORT}}'
# Waits for all four to answer, then prints where they landed.
linked:_ready:
internal: true
cmds:
- cmd: |
n=0
ok=0
while [ "$n" -lt 150 ]; do
ok=1
for u in "http://localhost:{{.SAAS_BACKEND_PORT}}" \
"http://localhost:{{.SAAS_FRONTEND_PORT}}" \
"http://localhost:{{.APP_BACKEND_PORT}}" \
"http://localhost:{{.APP_FRONTEND_PORT}}"; do
# Not -o /dev/null: Windows curl.exe treats it as a real path and exits 23.
curl -s -m 2 "$u" >/dev/null 2>&1 || ok=0
done
if [ "$ok" = 1 ]; then break; fi
n=$((n + 1))
# `sleep` is a binary, not a builtin, and Windows has none.
{{if eq OS "windows"}}powershell -NoProfile -Command "Start-Sleep -Seconds 2"{{else}}sleep 2{{end}}
done
echo ""
if [ "$ok" = 1 ]; then
echo ">> all four answering"
else
echo ">> still waiting on one or more after 5 minutes; addresses below anyway"
fi
echo ">> self-hosted UI http://localhost:{{.APP_FRONTEND_PORT}}/processor"
echo ">> self-hosted api http://localhost:{{.APP_BACKEND_PORT}}"
echo ">> saas UI http://localhost:{{.SAAS_FRONTEND_PORT}}"
echo ">> saas api http://localhost:{{.SAAS_BACKEND_PORT}}"
echo ""
dev:_all:
internal: true
vars:
+32 -7
View File
@@ -3,6 +3,10 @@ bootRun {
enabled = false
}
dependencies {
// Security-hardening utilities (zip-slip, SSRF, filename sanitization, command injection).
// Declared as api here so core + proprietary (which depend on common) get it transitively,
// keeping it off modules that don't need it (e.g. saas).
api 'io.github.pixee:java-security-toolkit:1.2.3'
api "com.google.guava:guava:${guavaVersion}"
api 'org.springframework.boot:spring-boot-starter-webmvc'
api 'org.springframework.boot:spring-boot-starter-aspectj'
@@ -22,7 +26,10 @@ dependencies {
api "org.springdoc:springdoc-openapi-starter-webmvc-ui:3.0.3"
// Simple Java Mail for EML/MSG parsing (replaces direct Angus Mail usage)
api 'org.simplejavamail:simple-java-mail:9.3.2'
api 'org.simplejavamail:outlook-module:9.3.2' // MSG file support
// MSG file support; exclude commons-math3 (only HSSF/formula needs it, MSG parsing doesn't)
api('org.simplejavamail:outlook-module:9.3.2') {
exclude group: 'org.apache.commons', module: 'commons-math3'
}
api 'jakarta.mail:jakarta.mail-api:2.1.5'
runtimeOnly 'org.eclipse.angus:angus-mail:2.0.5'
@@ -36,12 +43,30 @@ dependencies {
api "com.stirling:jpdfium:${jpdfiumVersion}"
// -PjpdfiumPlatforms=all|none|<csv of linux-x64,linux-arm64,darwin-x64,darwin-arm64,windows-x64>
// 'none' skips natives entirely (windows-arm64 builds, until JPDFium ships that platform).
def jpdfiumPlatformsProp = (project.findProperty('jpdfiumPlatforms') ?: 'all').toString().trim()
def jpdfiumAllPlatforms = ['linux-x64', 'linux-arm64', 'darwin-x64', 'darwin-arm64', 'windows-x64']
// -PjpdfiumPlatforms=auto|all|none|<csv of linux-x64,linux-arm64,linux-musl-x64,linux-musl-arm64,darwin-x64,darwin-arm64,windows-x64> (windows-arm64 natives not published yet)
def jpdfiumPlatformsProp = (project.findProperty('jpdfiumPlatforms') ?: 'auto').toString().trim()
def jpdfiumAllPlatforms = ['linux-x64', 'linux-arm64', 'linux-musl-x64', 'linux-musl-arm64', 'darwin-x64', 'darwin-arm64', 'windows-x64']
def jpdfiumPlatforms
if (jpdfiumPlatformsProp == 'all') {
if (jpdfiumPlatformsProp == 'auto') {
def osName = System.getProperty('os.name').toLowerCase()
def osArch = System.getProperty('os.arch').toLowerCase()
def isArm64 = osArch.contains('aarch64') || osArch.contains('arm64')
if (osName.contains('linux')) {
jpdfiumPlatforms = isArm64 ? ['linux-arm64'] : ['linux-x64']
} else if (osName.contains('mac')) {
jpdfiumPlatforms = isArm64 ? ['darwin-arm64'] : ['darwin-x64']
} else if (osName.contains('win')) {
if (isArm64) {
logger.lifecycle("JPDFium natives are not available for windows-arm64; set -PjpdfiumPlatforms=none to skip bundling natives.")
jpdfiumPlatforms = []
} else {
jpdfiumPlatforms = ['windows-x64']
}
} else {
// Fallback: bundle all platforms when host can't be determined
jpdfiumPlatforms = jpdfiumAllPlatforms
}
} else if (jpdfiumPlatformsProp == 'all') {
jpdfiumPlatforms = jpdfiumAllPlatforms
} else if (jpdfiumPlatformsProp == 'none') {
jpdfiumPlatforms = []
@@ -51,7 +76,7 @@ dependencies {
def jpdfiumInvalid = jpdfiumPlatforms.findAll { !jpdfiumAllPlatforms.contains(it) }
if (jpdfiumInvalid) {
throw new GradleException("Unknown jpdfiumPlatforms value(s): ${jpdfiumInvalid.join(', ')}. " +
"Valid: ${jpdfiumAllPlatforms.join(', ')}, 'all' or 'none'.")
"Valid: ${jpdfiumAllPlatforms.join(', ')}, 'auto', 'all' or 'none'.")
}
logger.lifecycle("JPDFium native platforms: ${jpdfiumPlatforms ? jpdfiumPlatforms.join(', ') : 'none'}")
jpdfiumPlatforms.each { platform ->
@@ -113,6 +113,16 @@ class RequestUriUtilsTest {
assertTrue(RequestUriUtils.isFrontendRoute("", "/split-pdf"));
}
@Test
void testIsFrontendRoute_editorRouteOwnedByFrontend() {
// /editor (and its tool routes) is an SPA route: a direct-nav/refresh must
// serve index.html, not the auth filter's 302-to-/login. Regression test for
// the editor moving from / to /editor, whose refresh bounced processor users
// to the processor because the redirect dropped the return path.
assertTrue(RequestUriUtils.isFrontendRoute("", "/editor"));
assertTrue(RequestUriUtils.isFrontendRoute("/app", "/app/editor"));
}
@Test
void testIsFrontendRoute_filesRouteOwnedByFrontend() {
// /files and /files/<folder-uuid> are FileManagerView routes - they
+1
View File
@@ -106,6 +106,7 @@ SwaggerDoc.json
# Log file
*.log
*.log.gz
# BlueJ files
*.ctxt
+14 -2
View File
@@ -62,8 +62,16 @@ dependencies {
// CVE-2022-25647: Explicit gson to prevent unsafe deserialization (tabula would pull 2.8.7)
implementation "com.google.code.gson:gson:${gsonVersion}"
implementation 'org.apache.pdfbox:jbig2-imageio:3.0.5'
implementation 'com.opencsv:opencsv:5.12.0' // https://mvnrepository.com/artifact/com.opencsv/opencsv
implementation 'org.apache.poi:poi-ooxml:5.5.1'
// OpenCSV: Stirling-PDF only uses CSVWriter, not the opencsv-bean module.
// Exclude commons-beanutils + commons-collections.
implementation('com.opencsv:opencsv:5.12.0') {
exclude group: 'commons-beanutils', module: 'commons-beanutils'
exclude group: 'commons-collections', module: 'commons-collections'
}
// POI: only XSSF (modern Excel) is used, not HSSF/FormulaEvaluator which need commons-math3.
implementation('org.apache.poi:poi-ooxml:5.5.1') {
exclude group: 'org.apache.commons', module: 'commons-math3'
}
// Batik only bridge module needed (transitively pulls anim, gvt, util, css, dom, svg-dom)
// Replaces batik-all which included unused codec, svggen, transcoder, script modules
@@ -129,6 +137,10 @@ bootJar {
exclude 'META-INF/*.RSA'
exclude 'META-INF/*.EC'
// Exclude source maps from production JAR, dev-only debugging artifacts, not needed at runtime
exclude 'static/pdfjs-legacy/**/*.map'
exclude 'static/**/*.map'
manifest {
attributes(
'Implementation-Title': 'Stirling-PDF',
@@ -114,6 +114,7 @@ public class OCRController {
List<String> selectedLanguages = request.getLanguages();
boolean sidecar = request.isSidecar();
Boolean deskew = request.isDeskew();
Boolean rotatePages = request.isRotatePages();
Boolean clean = request.isClean();
Boolean cleanFinal = request.isCleanFinal();
String ocrType = request.getOcrType();
@@ -154,6 +155,7 @@ public class OCRController {
selectedLanguages,
sidecar,
deskew,
rotatePages,
clean,
cleanFinal,
ocrType,
@@ -236,6 +238,7 @@ public class OCRController {
List<String> selectedLanguages,
Boolean sidecar,
Boolean deskew,
Boolean rotatePages,
Boolean clean,
Boolean cleanFinal,
String ocrType,
@@ -268,6 +271,10 @@ public class OCRController {
if (deskew != null && deskew) {
command.add("--deskew");
}
if (rotatePages != null && rotatePages) {
// Tesseract OSD-based automatic page orientation correction (90/180/270)
command.add("--rotate-pages");
}
if (clean != null && clean) {
command.add("--clean");
}
@@ -221,6 +221,10 @@ public class RedactController {
.normalizeFonts(false)
.fixToUnicode(false)
.glyphAware(true)
.ligatureAware(true)
.bidiAware(true)
.graphemeSafe(true)
.sanitizeStructure(false) // WIP/Experimental API
.redactMetadata(true)
.build();
@@ -110,6 +110,10 @@ class TextRedactionService {
.fixToUnicode(false)
.repairWidths(false)
.glyphAware(true)
.ligatureAware(true)
.bidiAware(true)
.graphemeSafe(true)
.sanitizeStructure(false)
.build();
try (PdfDocument checkDoc = PdfDocument.open(tempIn.toPath())) {
@@ -25,6 +25,11 @@ public class ProcessPdfWithOcrRequest extends PDFFile {
@Schema(description = "Deskew the input file if set to true")
private boolean deskew;
@Schema(
description =
"Auto-correct page orientation (90/180/270) using Tesseract OSD if set to true")
private boolean rotatePages;
@Schema(description = "Clean the input file if set to true")
private boolean clean;
+6 -4
View File
@@ -16,8 +16,9 @@
<pattern>%d %p %c{1} [%thread] %m%n</pattern>
</encoder>
<rollingPolicy class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy">
<fileNamePattern>${LOG_PATH}/auth-%d{yyyy-MM-dd}.log</fileNamePattern>
<maxHistory>1</maxHistory>
<fileNamePattern>${LOG_PATH}/auth-%d{yyyy-MM-dd}.log.gz</fileNamePattern>
<maxHistory>7</maxHistory>
<totalSizeCap>64MB</totalSizeCap>
</rollingPolicy>
</appender>
@@ -28,8 +29,9 @@
<pattern>%d %p %c{1} [%thread] %m%n</pattern>
</encoder>
<rollingPolicy class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy">
<fileNamePattern>${LOG_PATH}/info-%d{yyyy-MM-dd}.log</fileNamePattern>
<maxHistory>1</maxHistory>
<fileNamePattern>${LOG_PATH}/info-%d{yyyy-MM-dd}.log.gz</fileNamePattern>
<maxHistory>7</maxHistory>
<totalSizeCap>256MB</totalSizeCap>
</rollingPolicy>
</appender>
@@ -186,7 +186,7 @@ system:
maxDPI: 500 # Maximum allowed DPI for PDF to image conversion
corsAllowedOrigins: [] # List of allowed origins for CORS (e.g. ['http://localhost:5173', 'https://app.example.com']). WARNING: leaving this empty falls back to allowing ALL origins (with credentials), it does NOT disable CORS. Set explicit origins to lock it down.
backendUrl: "" # Backend base URL for SAML/OAuth/API callbacks (e.g. 'http://localhost:8080' for dev, 'https://api.example.com' for production). REQUIRED for SSO authentication to work correctly. This is where your IdP will send SAML responses and OAuth callbacks. Leave empty to default to 'http://localhost:8080' in development.
frontendUrl: "" # Frontend URL for invite email links (e.g. 'https://app.example.com'). Optional - if not set, will use backendUrl. This is the URL users click in invite emails.
frontendUrl: "" # Base URL of the web app, as a browser reaches it (e.g. 'https://app.example.com', or 'https://example.com/app' if served under a base path). Optional - if not set, will use backendUrl. Used for any link handed to a browser: invite emails, share links, mobile QR codes, and the account-link handshake.
enableMobileScanner: true # Enable mobile phone QR code upload feature. Requires frontendUrl to be configured.
enableMobileSignature: true # Enable drawing signatures on a phone via QR code from the Sign tool. Requires frontendUrl to be configured.
mobileScannerSettings:
@@ -24,22 +24,6 @@ import tools.jackson.databind.node.ObjectNode;
/**
* Outbound calls from a self-hosted instance to its linked SaaS backend (combined-billing "Mode
* A").
*
* <p>Calls:
*
* <ul>
* <li>{@link #register} — relays the admin's short-lived Supabase JWT to {@code POST
* /api/v1/account-link/register}; the SaaS side mints + returns a device credential.
* <li>{@link #fetchEntitlement} — authenticates with the stored device credential against {@code
* GET /api/v1/instance/entitlement}; what the local gate consults.
* <li>{@link #reportUsage} — daily usage sync ({@code POST /api/v1/instance/sync}); reports
* cumulative units and returns the refreshed entitlement.
* <li>{@link #revokeSelf} — self-revokes the credential on local unlink ({@code POST
* /api/v1/instance/revoke-self}).
* </ul>
*
* <p>Uses {@code java.net.http.HttpClient} (the established self-hosted outbound pattern; see
* {@code AiEngineClient}); base URL + client are injectable so tests can stub SaaS.
*/
@Slf4j
@Service
@@ -72,13 +56,7 @@ public class AccountLinkClient {
this.httpClient = httpClient;
}
/** The device credential a successful {@link #register} returns. */
public record RegisterResult(String deviceId, String deviceSecret, Long teamId) {}
/**
* A non-2xx reply from the SaaS account-link API. Carries the upstream status so the caller can
* map auth failures (401/403) through rather than masking everything as a 502.
*/
/** A non-2xx reply from the SaaS account-link API. */
public static class UpstreamException extends IOException {
private final int status;
@@ -92,11 +70,7 @@ public class AccountLinkClient {
}
}
/**
* Authoritative deny (401/403) — the device credential is revoked or invalid. Unlike a
* transport/server failure (which returns {@code null} and fails open), the cache must BLOCK on
* this. Unchecked so it propagates through {@link #fetchEntitlement}'s transport try/catch.
*/
/** Authoritative deny (401/403) — the device credential is revoked or invalid. */
public static final class RevokedException extends RuntimeException {
private final int status;
@@ -110,46 +84,142 @@ public class AccountLinkClient {
}
}
/** What the SaaS side hands back when it records a connect handshake. */
public record ConnectRequestResult(
String requestId, int expiresInSeconds, String authorizeUrl) {}
public enum ConnectClaimOutcome {
/** Approved and collected; the credential fields are populated. */
GRANTED,
/** A re-authentication was approved. */
CONFIRMED,
/** No human decision yet. */
PENDING,
/** Declined, expired or already used. */
REJECTED,
/** SaaS unreachable or erroring. */
UNAVAILABLE
}
public record ConnectClaimResult(
ConnectClaimOutcome outcome, String deviceId, String deviceSecret, Long teamId) {
static ConnectClaimResult of(ConnectClaimOutcome outcome) {
return new ConnectClaimResult(outcome, null, null, null);
}
}
/** Opens a connect handshake. */
public ConnectRequestResult connectRequest(
String name, String callbackUrl, String nonce, String claimSecret) throws IOException {
return connectRequest(name, callbackUrl, nonce, claimSecret, null);
}
/**
* Relays the admin Supabase JWT to the SaaS register endpoint and returns the minted
* credential.
*
* @throws IOException on transport failure or a non-2xx response (caller surfaces to the
* admin).
* As {@link #connectRequest}, but presenting an existing device credential so the SaaS side
* treats this as a re-authentication and pins the handshake to the team we already belong to.
*/
public RegisterResult register(String supabaseJwt, String instanceName) throws IOException {
String body =
instanceName == null || instanceName.isBlank()
? "{}"
: "{\"name\":" + mapper.writeValueAsString(instanceName) + "}";
HttpRequest request =
public ConnectRequestResult connectRequest(
String name,
String callbackUrl,
String nonce,
String claimSecret,
DeviceCredential credential)
throws IOException {
ObjectNode root = mapper.createObjectNode();
if (name != null && !name.isBlank()) {
root.put("name", name);
}
root.put("callbackUrl", callbackUrl);
root.put("nonce", nonce);
root.put("claimSecret", claimSecret);
HttpRequest.Builder builder =
HttpRequest.newBuilder()
.uri(uri("/api/v1/account-link/register"))
.header("Authorization", "Bearer " + supabaseJwt)
.uri(uri("/api/v1/account-link/connect/request"))
.header("Content-Type", "application/json")
.header("Accept", "application/json")
.timeout(timeout())
.POST(HttpRequest.BodyPublishers.ofString(body))
.build();
.POST(HttpRequest.BodyPublishers.ofString(mapper.writeValueAsString(root)));
if (credential != null) {
builder.header(HEADER_DEVICE_ID, credential.getDeviceId())
.header(HEADER_DEVICE_SECRET, credential.getDeviceSecret());
}
HttpResponse<String> response = send(request);
HttpResponse<String> response = send(builder.build());
if (response.statusCode() / 100 != 2) {
throw new UpstreamException(response.statusCode(), response.body());
}
JsonNode root = mapper.readTree(response.body());
String deviceId = text(root, "deviceId");
String deviceSecret = text(root, "deviceSecret");
if (deviceId == null || deviceSecret == null) {
throw new IOException("SaaS register response missing deviceId/deviceSecret");
JsonNode body = mapper.readTree(response.body());
String requestId = text(body, "requestId");
if (requestId == null) {
throw new IOException("SaaS connect response missing requestId");
}
String authorizeUrl = text(body, "authorizeUrl");
if (authorizeUrl == null || !isAbsoluteHttpUrl(authorizeUrl)) {
throw new IOException("SaaS connect response carried no usable authorizeUrl");
}
return new ConnectRequestResult(requestId, body.path("expiresIn").asInt(0), authorizeUrl);
}
/**
* Collects the device credential for an approved handshake, proving possession of the claim
* secret.
*/
public ConnectClaimResult connectClaim(String requestId, String claimSecret) {
HttpResponse<String> response;
try {
ObjectNode root = mapper.createObjectNode();
root.put("requestId", requestId);
root.put("claimSecret", claimSecret);
HttpRequest request =
HttpRequest.newBuilder()
.uri(uri("/api/v1/account-link/connect/claim"))
.header("Content-Type", "application/json")
.header("Accept", "application/json")
.timeout(timeout())
.POST(
HttpRequest.BodyPublishers.ofString(
mapper.writeValueAsString(root)))
.build();
response = send(request);
} catch (Exception e) {
log.debug("Connect claim failed (transport): {}", e.getMessage());
return ConnectClaimResult.of(ConnectClaimOutcome.UNAVAILABLE);
}
int status = response.statusCode();
if (status == 202) {
return ConnectClaimResult.of(ConnectClaimOutcome.PENDING);
}
if (status >= 500 && status <= 599) {
return ConnectClaimResult.of(ConnectClaimOutcome.UNAVAILABLE);
}
if (status < 200 || status > 299) {
return ConnectClaimResult.of(ConnectClaimOutcome.REJECTED);
}
try {
JsonNode body = mapper.readTree(response.body());
Long teamId = body.hasNonNull("teamId") ? body.get("teamId").asLong() : null;
// A re-authentication says so explicitly and carries no credential, so an absent
// credential is only an error when we were expecting one.
if ("confirmed".equals(text(body, "status"))) {
return new ConnectClaimResult(ConnectClaimOutcome.CONFIRMED, null, null, teamId);
}
String deviceId = text(body, "deviceId");
String deviceSecret = text(body, "deviceSecret");
if (deviceId == null || deviceSecret == null) {
log.warn("Connect claim succeeded but the reply carried no credential");
return ConnectClaimResult.of(ConnectClaimOutcome.REJECTED);
}
return new ConnectClaimResult(
ConnectClaimOutcome.GRANTED, deviceId, deviceSecret, teamId);
} catch (RuntimeException e) {
log.debug("Connect claim parse failed: {}", e.getMessage());
return ConnectClaimResult.of(ConnectClaimOutcome.REJECTED);
}
Long teamId = root.hasNonNull("teamId") ? root.get("teamId").asLong() : null;
return new RegisterResult(deviceId, deviceSecret, teamId);
}
/**
* Revokes this instance's own credential on the SaaS side, authenticated by that credential.
* Best-effort: returns {@code false} if SaaS is unreachable or rejects, so the caller (local
* unlink) can still clear locally and log the orphan for follow-up. Idempotent on SaaS.
*/
public boolean revokeSelf(String deviceId, String deviceSecret) {
try {
@@ -174,17 +244,7 @@ public class AccountLinkClient {
}
}
/**
* Fetches the current entitlement using the stored device credential. Three outcomes:
*
* <ul>
* <li>2xx → the parsed snapshot.
* <li>401/403 → {@link RevokedException} (authoritative deny — revoked/invalid credential);
* the caller must BLOCK, not fail open.
* <li>transport failure, other non-2xx (e.g. 5xx), or a malformed body → {@code null}
* ("unknown" — the caller fails open).
* </ul>
*/
/** Fetches the current entitlement using the stored device credential. */
public InstanceEntitlement fetchEntitlement(String deviceId, String deviceSecret) {
HttpResponse<String> response;
try {
@@ -224,9 +284,6 @@ public class AccountLinkClient {
/**
* Reports the period's cumulative per-category units to {@code POST /api/v1/instance/sync} and
* returns the fresh entitlement in the same reply — one round-trip both reports and refreshes.
* SaaS bills the delta against its last-seen cumulative, so resending the same totals is
* idempotent. Same three outcomes as {@link #fetchEntitlement}; on {@code null} the caller must
* not advance its last-synced markers so the usage retries next sync.
*/
public InstanceEntitlement reportUsage(
String deviceId,
@@ -360,4 +417,19 @@ public class AccountLinkClient {
private static String text(JsonNode node, String field) {
return node.hasNonNull(field) ? node.get(field).asText() : null;
}
/** Absolute http(s) with a host. */
static boolean isAbsoluteHttpUrl(String candidate) {
try {
URI uri = URI.create(candidate.strip());
String scheme = uri.getScheme();
return uri.isAbsolute()
&& scheme != null
&& ("http".equalsIgnoreCase(scheme) || "https".equalsIgnoreCase(scheme))
&& uri.getHost() != null
&& !uri.getHost().isBlank();
} catch (IllegalArgumentException e) {
return false;
}
}
}
@@ -16,21 +16,11 @@ import org.springframework.web.bind.annotation.RestController;
import io.swagger.v3.oas.annotations.Hidden;
import jakarta.servlet.http.HttpServletRequest;
import lombok.extern.slf4j.Slf4j;
/**
* Same-origin account-link surface on the self-hosted instance (combined-billing "Mode A").
*
* <p>The portal (served from this same origin, admin authenticated by the existing self-hosted
* security chain) calls these. {@code POST /link} relays the admin's Supabase JWT to the SaaS
* backend, which mints + returns a device credential we store locally. {@code GET /status} backs
* the portal's link card; {@code GET /usage} exposes locally-accrued unsynced usage the portal adds
* to SaaS-synced spend; {@code POST /sync-now} forces an immediate usage sync (ops "reconcile now"
* / test aid).
*
* <p>Admin-only, {@code @Profile("!saas")}, gated behind {@code
* stirling.billing.account-link.enabled} — off → bean absent → 404.
*/
/** Same-origin account-link surface on the self-hosted instance (combined billing). */
@Slf4j
@Hidden
@RestController
@@ -41,51 +31,110 @@ import lombok.extern.slf4j.Slf4j;
public class AccountLinkController {
private final AccountLinkService service;
private final ConnectService connectService;
private final LocalUsageService localUsageService;
// Present only when metering is on (its own flag); absent → /sync-now reports 409.
private final ObjectProvider<UsageSyncService> syncServiceProvider;
public AccountLinkController(
AccountLinkService service,
ConnectService connectService,
LocalUsageService localUsageService,
ObjectProvider<UsageSyncService> syncServiceProvider) {
this.service = service;
this.connectService = connectService;
this.localUsageService = localUsageService;
this.syncServiceProvider = syncServiceProvider;
}
/** {@code supabaseJwt} is the admin's short-lived token the portal already holds. */
public record LinkRequest(String supabaseJwt, String name) {}
/** {@code callbackUrl} is the portal telling us where its own callback route lives. */
public record ConnectStartRequest(String name, String callbackUrl) {}
@PostMapping("/link")
public ResponseEntity<?> link(@RequestBody LinkRequest req) {
if (req == null || req.supabaseJwt() == null || req.supabaseJwt().isBlank()) {
return ResponseEntity.badRequest()
.body(java.util.Map.of("error", "supabaseJwt is required"));
}
/** {@code nonce} comes from the callback fragment the approval page redirected to. */
public record ConnectCompleteRequest(String nonce) {}
/**
* Opens a browser-mediated link handshake and returns the approval URL to send the admin to.
*/
@PostMapping("/connect/start")
public ResponseEntity<?> connectStart(
@RequestBody(required = false) ConnectStartRequest req, HttpServletRequest http) {
try {
return ResponseEntity.ok(service.link(req.supabaseJwt(), req.name()));
return ResponseEntity.ok(
connectService.start(req != null ? req.name() : null, callbackHint(req, http)));
} catch (AccountLinkClient.UpstreamException e) {
// Auth failures are the admin's token, not a gateway fault: surface 401/403 as-is so
// the portal can prompt a re-sign-in. Anything else upstream → 502. Don't echo the
// raw upstream body back to the browser.
HttpStatus status =
e.status() == HttpStatus.UNAUTHORIZED.value()
|| e.status() == HttpStatus.FORBIDDEN.value()
? HttpStatus.valueOf(e.status())
: HttpStatus.BAD_GATEWAY;
log.warn("Account-link register rejected upstream: HTTP {}", e.status());
return ResponseEntity.status(status).body(java.util.Map.of("error", "LINK_FAILED"));
} catch (IOException e) {
// Don't echo e.getMessage() to the browser: a DNS/connection/TLS failure can carry the
// configured SaaS host/IP. Log it server-side; return the same opaque body the
// UpstreamException branch does.
log.warn("Account-link failed (transport): {}", e.getMessage());
log.warn("Account-link connect rejected upstream: HTTP {}", e.status());
return ResponseEntity.status(HttpStatus.BAD_GATEWAY)
.body(java.util.Map.of("error", "LINK_FAILED"));
.body(java.util.Map.of("error", "CONNECT_FAILED"));
} catch (IOException e) {
// Same reasoning as /link: a transport message can carry the configured SaaS host.
log.warn("Account-link connect failed (transport): {}", e.getMessage());
return ResponseEntity.status(HttpStatus.BAD_GATEWAY)
.body(java.util.Map.of("error", "CONNECT_FAILED"));
}
}
/** Re-establishes the admin's SaaS session for a server that is already linked. */
@PostMapping("/connect/reauth")
public ResponseEntity<?> connectReauth(
@RequestBody(required = false) ConnectStartRequest req, HttpServletRequest http) {
try {
return ResponseEntity.ok(connectService.startReauth(callbackHint(req, http)));
} catch (AccountLinkClient.UpstreamException e) {
log.warn("Account-link reauth rejected upstream: HTTP {}", e.status());
return ResponseEntity.status(HttpStatus.BAD_GATEWAY)
.body(java.util.Map.of("error", "CONNECT_FAILED"));
} catch (IOException e) {
log.warn("Account-link reauth failed: {}", e.getMessage());
return ResponseEntity.status(HttpStatus.BAD_GATEWAY)
.body(java.util.Map.of("error", "CONNECT_FAILED"));
}
}
/** Called by the callback page with the nonce it found in the fragment. */
@PostMapping("/connect/complete")
public ResponseEntity<ConnectService.ConnectStatus> connectComplete(
@RequestBody(required = false) ConnectCompleteRequest req) {
return ResponseEntity.ok(connectService.complete(req != null ? req.nonce() : null));
}
/** Everything we know about where the admin's browser is, for the callback. */
private static ConnectService.CallbackHint callbackHint(
ConnectStartRequest req, HttpServletRequest http) {
return new ConnectService.CallbackHint(
req != null ? req.callbackUrl() : null, http.getHeader("Origin"), baseUrlOf(http));
}
/**
* This instance's base URL as the browser reached it, including any context path so a subpath
* deployment builds a callback that actually resolves.
*/
private static String baseUrlOf(HttpServletRequest request) {
String forwardedProto = firstHop(request.getHeader("X-Forwarded-Proto"));
String forwardedHost = firstHop(request.getHeader("X-Forwarded-Host"));
String scheme = forwardedProto != null ? forwardedProto : request.getScheme();
String hostPort;
if (forwardedHost != null) {
hostPort = forwardedHost;
} else {
int port = request.getServerPort();
boolean defaultPort =
("http".equals(scheme) && port == 80)
|| ("https".equals(scheme) && port == 443);
hostPort = defaultPort ? request.getServerName() : request.getServerName() + ":" + port;
}
String context = request.getContextPath() == null ? "" : request.getContextPath();
return scheme + "://" + hostPort + context;
}
private static String firstHop(String headerValue) {
if (headerValue == null || headerValue.isBlank()) {
return null;
}
String first = headerValue.split(",")[0].strip();
return first.isEmpty() ? null : first;
}
@GetMapping("/status")
public ResponseEntity<AccountLinkService.LinkStatus> status() {
return ResponseEntity.ok(service.status());
@@ -106,12 +155,7 @@ public class AccountLinkController {
return ResponseEntity.ok(localUsageService.currentPeriodUnsynced());
}
/**
* Forces an immediate usage sync to SaaS — the same work the daily scheduler does. An admin
* "reconcile now" action (and a test aid so you don't wait on the scheduler). Idempotent:
* re-reports the current cumulative, so a repeat trigger bills nothing. {@code 204} once run;
* {@code 409} when metering is off (the sync bean is absent).
*/
/** Forces an immediate usage sync to SaaS — the same work the daily scheduler does. */
@PostMapping("/sync-now")
public ResponseEntity<Void> syncNow() {
UsageSyncService sync = syncServiceProvider.getIfAvailable();
@@ -8,29 +8,17 @@ import org.springframework.stereotype.Component;
import lombok.Getter;
import lombok.Setter;
/**
* Self-hosted side of combined-billing "Mode A" (connected self-hosted).
*
* <p>Binds the {@code stirling.billing.account-link.*} keys. {@link #enabled} mirrors the same flag
* the gated beans test with {@code @ConditionalOnProperty}; it is kept here only so non-conditional
* code (e.g. the gate's flag-off short-circuit, exposed status) can read it. The whole feature is
* <b>off by default</b> and <b>dark</b> — when off nothing gates and the link endpoints 404.
*/
/** Self-hosted side of combined billing: this instance bills through a linked SaaS team. */
@Getter
@Setter
@Component
@ConfigurationProperties(prefix = "stirling.billing.account-link")
public class AccountLinkProperties {
/** Master switch. When {@code false} (default) the feature is fully inert. */
/** Master switch. */
private boolean enabled = false;
/**
* Base URL of the SaaS backend this instance links to (register + entitlement live there).
*
* <p>STUB: defaults to the public cloud host; an operator overrides it for staging. There is no
* existing SaaS-base-url property in the self-hosted profile, so this is introduced here.
*/
/** Base URL of the SaaS backend this instance links to (register + entitlement live there). */
private String saasBaseUrl = "https://stirling.com/app";
/** Cached entitlement is reused for this long before a refresh is attempted. */
@@ -39,20 +27,18 @@ public class AccountLinkProperties {
/** Connect/read timeout for the outbound SaaS calls. */
private int requestTimeoutSeconds = 10;
/** Phase 2 usage metering + daily sync. Keyed under {@code …account-link.metering.*}. */
/** Phase 2 usage metering + daily sync. */
private final Metering metering = new Metering();
/**
* Dedicated billing switch, <b>separate</b> from {@link #enabled} so the link plumbing can be
* enabled (e.g. to test linking) without ever turning on real usage metering, reporting, or cap
* enforcement. Both default off; metering requires the master flag too. This is the production
* safety key — flipping it on is what actually bills linked instances.
* Separate from {@link #enabled} so linking can be exercised without billing anything. Both
* default off, and metering needs the master flag as well.
*/
@Getter
@Setter
public static class Metering {
/** Turns on usage metering, the daily sync, and cap enforcement. Default off. */
/** Turns on usage metering, the daily sync, and cap enforcement. */
private boolean enabled = false;
/**
@@ -65,12 +51,7 @@ public class AccountLinkProperties {
*/
private int graceDays = 3;
/**
* Dedup window for identical input sets. A re-run of the same inputs within this window is
* treated as workflow chaining and not re-charged; the same inputs run again after it are
* billed afresh. Mirrors the cloud's {@code payg.lineage.workflow-window} so the same op
* costs the same on the instance and in the cloud.
*/
/** Dedup window for identical input sets. */
private Duration workflowWindow = Duration.ofMinutes(5);
}
}
@@ -1,6 +1,5 @@
package stirling.software.proprietary.accountlink;
import java.io.IOException;
import java.util.Optional;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
@@ -9,13 +8,7 @@ import org.springframework.stereotype.Service;
import lombok.extern.slf4j.Slf4j;
/**
* Linking orchestrator (self-hosted side of combined-billing "Mode A").
*
* <p>{@link #link} is the same-origin action the portal triggers: it relays the admin's Supabase
* JWT to the SaaS register endpoint, then persists the returned device credential secure-at-rest.
* The credential — not the JWT — authenticates all later unattended entitlement calls.
*/
/** Linking orchestrator (self-hosted side of combined billing). */
@Slf4j
@Service
@Profile("!saas")
@@ -38,24 +31,9 @@ public class AccountLinkService {
/** Status of this instance's link, for the portal's "Account link" card. */
public record LinkStatus(boolean linked, String deviceId, Long teamId, String linkedAt) {}
/**
* Registers this instance with the SaaS team behind {@code supabaseJwt} and stores the
* credential.
*
* @throws IOException if the SaaS register call fails (surfaced to the admin as a link error).
*/
public LinkStatus link(String supabaseJwt, String instanceName) throws IOException {
AccountLinkClient.RegisterResult result = client.register(supabaseJwt, instanceName);
credentialStore.save(result.deviceId(), result.deviceSecret(), result.teamId());
entitlementCache.invalidate();
log.info("Account-link: instance linked to team {}", result.teamId());
return status();
}
/**
* Unlinks this instance — best-effort tells SaaS to revoke first (so the row gets {@code
* revoked_at} set), then clears locally regardless. If SaaS is unreachable the local clear
* still proceeds (admin's intent must win); the orphan row can be revoked from the portal.
* revoked_at} set), then clears locally regardless.
*/
public void unlink() {
credentialStore
@@ -12,7 +12,7 @@ import lombok.NoArgsConstructor;
import lombok.Setter;
/**
* Singleton row holding this instance's daily-sync bookkeeping (combined-billing "Mode A").
* Singleton row holding this instance's daily-sync bookkeeping (combined billing).
*
* <p>{@link #lastSyncSeq} is reserved (incremented + persisted) <em>before</em> each report so it
* is strictly monotonic across restarts and partial failures — SaaS dedups replays by comparing it,
@@ -2,5 +2,5 @@ package stirling.software.proprietary.accountlink;
import org.springframework.data.jpa.repository.JpaRepository;
/** Persistence for the singleton {@link AccountLinkSyncState} (combined-billing "Mode A"). */
/** Persistence for the singleton {@link AccountLinkSyncState} (combined billing). */
public interface AccountLinkSyncStateRepository extends JpaRepository<AccountLinkSyncState, Long> {}
@@ -0,0 +1,276 @@
package stirling.software.proprietary.accountlink;
import java.io.IOException;
import java.net.URI;
import java.net.URISyntaxException;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.SecureRandom;
import java.time.Duration;
import java.time.LocalDateTime;
import java.util.Base64;
import java.util.Locale;
import java.util.Optional;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.context.annotation.Profile;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import lombok.extern.slf4j.Slf4j;
import stirling.software.common.model.ApplicationProperties;
/** Browser-mediated account linking, instance side. */
@Slf4j
@Service
@Profile("!saas")
@ConditionalOnProperty(name = "stirling.billing.account-link.enabled", havingValue = "true")
public class ConnectService {
/** Frontend route that consumes the callback fragment. */
static final String CALLBACK_PATH = "/account-link/callback";
private static final int SECRET_BYTES = 32;
private final AccountLinkClient client;
private final ConnectStateRepository stateRepo;
private final DeviceCredentialStore credentialStore;
private final EntitlementCache entitlementCache;
private final ApplicationProperties applicationProperties;
private final SecureRandom random = new SecureRandom();
public ConnectService(
AccountLinkClient client,
ConnectStateRepository stateRepo,
DeviceCredentialStore credentialStore,
EntitlementCache entitlementCache,
ApplicationProperties applicationProperties) {
this.client = client;
this.stateRepo = stateRepo;
this.credentialStore = credentialStore;
this.entitlementCache = entitlementCache;
this.applicationProperties = applicationProperties;
}
public enum Phase {
/** Nothing in flight and not linked. */
NONE,
/** A handshake is open, waiting for a leader to approve it on the SaaS site. */
PENDING,
/** Linked. */
LINKED,
/** The handshake outlived its window; start a new one. */
EXPIRED,
/** Declined or already used; start a new one. */
REJECTED,
/** SaaS could not be reached; the handshake is still valid and can be retried. */
UNAVAILABLE
}
/** What the portal renders. */
public record ConnectStatus(
Phase phase, String authorizeUrl, Long secondsRemaining, Long teamId) {
static ConnectStatus of(Phase phase) {
return new ConnectStatus(phase, null, null, null);
}
}
/** Everything we know about where the admin's browser actually is, in decreasing authority. */
public record CallbackHint(
String requestedCallbackUrl, String browserOrigin, String derivedBaseUrl) {}
/** Opens a handshake and returns where to send the admin. */
@Transactional
public ConnectStatus start(String name, CallbackHint hint) throws IOException {
if (credentialStore.isLinked()) {
return status();
}
return open(name, hint, null);
}
/**
* Opens a handshake that only re-establishes the admin's browser session, for an instance that
* is already linked.
*/
@Transactional
public ConnectStatus startReauth(CallbackHint hint) throws IOException {
DeviceCredential credential =
credentialStore
.get()
.orElseThrow(
() ->
new IOException(
"This server is not linked, so there is no session"
+ " to re-establish"));
return open(credential.getDeviceId(), hint, credential);
}
private ConnectStatus open(String name, CallbackHint hint, DeviceCredential credential)
throws IOException {
String callbackUrl = resolveCallbackUrl(hint);
if (callbackUrl == null) {
throw new IOException(
"Cannot determine where to send the admin back to; set system.frontendUrl");
}
String nonce = randomSecret();
String claimSecret = randomSecret();
AccountLinkClient.ConnectRequestResult created =
client.connectRequest(name, callbackUrl, nonce, claimSecret, credential);
LocalDateTime now = LocalDateTime.now();
ConnectState state = new ConnectState();
state.setId(ConnectState.SINGLETON_ID);
state.setRequestId(created.requestId());
state.setNonce(nonce);
state.setClaimSecret(claimSecret);
state.setCallbackUrl(callbackUrl);
state.setAuthorizeUrl(created.authorizeUrl());
state.setCreatedAt(now);
state.setExpiresAt(
now.plusSeconds(created.expiresInSeconds() > 0 ? created.expiresInSeconds() : 900));
stateRepo.save(state);
log.info("Account-link connect: handshake {} opened", created.requestId());
return pendingStatus(state, now);
}
/** Finishes a handshake from the callback the approval page redirected to. */
@Transactional
public ConnectStatus complete(String nonce) {
Optional<ConnectState> found = stateRepo.findById(ConnectState.SINGLETON_ID);
if (found.isEmpty()) {
// Already finished (a double-submitted callback) or never started.
return status();
}
ConnectState state = found.get();
if (state.isExpired(LocalDateTime.now())) {
stateRepo.delete(state);
return ConnectStatus.of(Phase.EXPIRED);
}
if (nonce == null || !nonceMatches(nonce, state.getNonce())) {
log.warn(
"Account-link connect: callback for handshake {} had a bad nonce",
state.getRequestId());
return ConnectStatus.of(Phase.REJECTED);
}
AccountLinkClient.ConnectClaimResult claim =
client.connectClaim(state.getRequestId(), state.getClaimSecret());
return switch (claim.outcome()) {
case GRANTED -> {
credentialStore.save(claim.deviceId(), claim.deviceSecret(), claim.teamId());
entitlementCache.invalidate();
stateRepo.delete(state);
log.info("Account-link connect: linked to team {}", claim.teamId());
yield new ConnectStatus(Phase.LINKED, null, null, claim.teamId());
}
case CONFIRMED -> {
stateRepo.delete(state);
log.info(
"Account-link connect: session re-established for team {}", claim.teamId());
yield new ConnectStatus(Phase.LINKED, null, null, claim.teamId());
}
case PENDING ->
// The admin reached the callback before the approval committed. The row stays,
// so a retry finishes it.
ConnectStatus.of(Phase.PENDING);
case REJECTED -> {
stateRepo.delete(state);
yield ConnectStatus.of(Phase.REJECTED);
}
case UNAVAILABLE -> ConnectStatus.of(Phase.UNAVAILABLE);
};
}
@Transactional(readOnly = true)
public ConnectStatus status() {
Optional<DeviceCredential> credential = credentialStore.get();
if (credential.isPresent()) {
return new ConnectStatus(Phase.LINKED, null, null, credential.get().getTeamId());
}
Optional<ConnectState> state = stateRepo.findById(ConnectState.SINGLETON_ID);
if (state.isEmpty()) {
return ConnectStatus.of(Phase.NONE);
}
LocalDateTime now = LocalDateTime.now();
if (state.get().isExpired(now)) {
return ConnectStatus.of(Phase.EXPIRED);
}
return pendingStatus(state.get(), now);
}
private static ConnectStatus pendingStatus(ConnectState state, LocalDateTime now) {
long remaining = Duration.between(now, state.getExpiresAt()).toSeconds();
return new ConnectStatus(
Phase.PENDING, state.getAuthorizeUrl(), Math.max(remaining, 0), null);
}
/** Decides the callback, preferring knowledge over inference. */
String resolveCallbackUrl(CallbackHint hint) {
String configured = applicationProperties.getSystem().getFrontendUrl();
if (configured != null && !configured.isBlank()) {
return trimTrailingSlash(configured.strip()) + CALLBACK_PATH;
}
String browserOrigin = originOf(hint.browserOrigin());
if (browserOrigin != null) {
String requested = hint.requestedCallbackUrl();
if (requested != null && browserOrigin.equals(originOf(requested))) {
return requested.strip();
}
return browserOrigin + CALLBACK_PATH;
}
return hint.derivedBaseUrl() == null || hint.derivedBaseUrl().isBlank()
? null
: trimTrailingSlash(hint.derivedBaseUrl().strip()) + CALLBACK_PATH;
}
/** Scheme, host and port of an absolute http(s) URL; null if it is not one. */
private static String originOf(String candidate) {
if (candidate == null || candidate.isBlank()) {
return null;
}
URI uri;
try {
uri = new URI(candidate.strip());
} catch (URISyntaxException e) {
return null;
}
if (uri.getScheme() == null || uri.getHost() == null) {
return null;
}
String scheme = uri.getScheme().toLowerCase(Locale.ROOT);
if (!"http".equals(scheme) && !"https".equals(scheme)) {
return null;
}
int port = uri.getPort();
boolean defaultPort =
port == -1
|| ("http".equals(scheme) && port == 80)
|| ("https".equals(scheme) && port == 443);
return defaultPort
? scheme + "://" + uri.getHost()
: scheme + "://" + uri.getHost() + ":" + port;
}
private static String trimTrailingSlash(String value) {
return value.replaceAll("/+$", "");
}
private String randomSecret() {
byte[] buf = new byte[SECRET_BYTES];
random.nextBytes(buf);
return Base64.getUrlEncoder().withoutPadding().encodeToString(buf);
}
/** Constant-time so a caller cannot probe the nonce a character at a time. */
private static boolean nonceMatches(String candidate, String expected) {
if (expected == null) {
return false;
}
return MessageDigest.isEqual(
candidate.getBytes(StandardCharsets.UTF_8),
expected.getBytes(StandardCharsets.UTF_8));
}
}
@@ -0,0 +1,60 @@
package stirling.software.proprietary.accountlink;
import java.io.Serializable;
import java.time.LocalDateTime;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
/** The one in-flight "connect this server" handshake, instance side. */
@Entity
@Table(name = "account_link_connect_state")
@NoArgsConstructor
@Getter
@Setter
public class ConnectState implements Serializable {
private static final long serialVersionUID = 1L;
public static final Long SINGLETON_ID = 1L;
@Id
@Column(name = "id")
private Long id = SINGLETON_ID;
/** Opaque handle the SaaS side gave us; identifies the handshake on both sides. */
@Column(name = "request_id", nullable = false, length = 64)
private String requestId;
/** Correlator we minted. */
@Column(name = "nonce", nullable = false, length = 128)
private String nonce;
/** Secret we minted and sent to SaaS server to server. */
@Column(name = "claim_secret", nullable = false, length = 128)
private String claimSecret;
/** Where we asked the approval page to send the admin back to. */
@Column(name = "callback_url", nullable = false, length = 2048)
private String callbackUrl;
/** The approval URL handed to the browser, so a reload can offer it again. */
@Column(name = "authorize_url", nullable = false, length = 2048)
private String authorizeUrl;
@Column(name = "created_at", nullable = false)
private LocalDateTime createdAt;
@Column(name = "expires_at", nullable = false)
private LocalDateTime expiresAt;
public boolean isExpired(LocalDateTime now) {
return expiresAt != null && expiresAt.isBefore(now);
}
}
@@ -0,0 +1,6 @@
package stirling.software.proprietary.accountlink;
import org.springframework.data.jpa.repository.JpaRepository;
/** Data access for the singleton {@link ConnectState} row. */
public interface ConnectStateRepository extends JpaRepository<ConnectState, Long> {}
@@ -13,8 +13,8 @@ import lombok.NoArgsConstructor;
import lombok.Setter;
/**
* The device credential this self-hosted instance received when it linked a SaaS account
* (combined-billing "Mode A"). Singleton — one instance links to exactly one SaaS team.
* The device credential this self-hosted instance received when it linked a SaaS account (combined
* billing). Singleton — one instance links to exactly one SaaS team.
*
* <p>Unlike the SaaS side (which stores only a hash), the instance must keep the plaintext {@code
* deviceSecret} so it can present it on every unattended entitlement call. It lives in the local
@@ -8,7 +8,7 @@ import org.springframework.context.annotation.Profile;
import org.springframework.stereotype.Service;
/**
* Decides whether a request may proceed under combined-billing "Mode A" on a self-hosted instance.
* Decides whether a request may proceed under combined billing on a self-hosted instance.
*
* <p>Rules (in order):
*
@@ -39,8 +39,8 @@ import stirling.software.proprietary.policy.controller.PolicyRunRoutes;
import stirling.software.proprietary.security.model.ApiKeyAuthenticationToken;
/**
* Request-time gate + meter for combined-billing "Mode A". {@code preHandle} blocks billable (API /
* AI / automation) work when the instance is unlinked or over its limit; manual tools pass through.
* Request-time gate + meter for combined billing. {@code preHandle} blocks billable (API / AI /
* automation) work when the instance is unlinked or over its limit; manual tools pass through.
* {@code afterCompletion} meters a successful billable op into the per-period cumulative counter.
*
* <p>Blocking responds {@code 402} with a machine-readable body the FE maps to a "link to activate"
@@ -16,11 +16,11 @@ import lombok.NoArgsConstructor;
/**
* The last time the instance metered a given input set this period — the local equivalent of the
* cloud's lineage join (combined-billing "Mode A"). The meter dedups on a rolling <b>workflow
* window</b>: an identical input set re-submitted within the window (see {@link
* AccountLinkProperties.Metering}) is treated as workflow chaining and not re-charged, while the
* same inputs run again after the window are billed afresh — matching the cloud's 5-minute open-job
* window so the same operation costs the same on the instance and in the cloud.
* cloud's lineage join (combined billing). The meter dedups on a rolling <b>workflow window</b>: an
* identical input set re-submitted within the window (see {@link AccountLinkProperties.Metering})
* is treated as workflow chaining and not re-charged, while the same inputs run again after the
* window are billed afresh — matching the cloud's 5-minute open-job window so the same operation
* costs the same on the instance and in the cloud.
*
* <p>{@code lastMeteredAt} is refreshed on every sighting (the window slides, as recording a cloud
* artifact touches its job). One row per {@code (period, signature)}; the unique constraint also
@@ -5,7 +5,7 @@ import java.util.Optional;
import org.springframework.data.jpa.repository.JpaRepository;
/** Persistence for the per-period metered input-set signatures (combined-billing "Mode A"). */
/** Persistence for the per-period metered input-set signatures (combined billing). */
public interface MeteredInputSignatureRepository
extends JpaRepository<MeteredInputSignature, Long> {
@@ -17,10 +17,10 @@ import lombok.NoArgsConstructor;
import stirling.software.proprietary.billing.BillingCategory;
/**
* Durable per-(billing period, category) cumulative usage counter for combined-billing "Mode A".
* Each successful billable op increments its row; the daily sync reports the cumulative totals and
* SaaS bills the delta since the last sync. The cumulative model is idempotent (a resend bills
* nothing) and tamper-evident (a counter that drops is a signal). One row per {@code (period_start,
* Durable per-(billing period, category) cumulative usage counter for combined billing. Each
* successful billable op increments its row; the daily sync reports the cumulative totals and SaaS
* bills the delta since the last sync. The cumulative model is idempotent (a resend bills nothing)
* and tamper-evident (a counter that drops is a signal). One row per {@code (period_start,
* category)}, auto-created by Hibernate; only the flag-gated {@link UsageMeterService} writes it.
*/
@Entity
@@ -9,7 +9,7 @@ import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import org.springframework.transaction.annotation.Transactional;
/** Persistence for the per-period/per-category usage counters (combined-billing "Mode A"). */
/** Persistence for the per-period/per-category usage counters (combined billing). */
public interface UsageCounterRepository extends JpaRepository<UsageCounter, Long> {
/**
@@ -18,8 +18,8 @@ import lombok.extern.slf4j.Slf4j;
import stirling.software.proprietary.billing.BillingCategory;
/**
* Daily usage sender for combined-billing "Mode A". Reports each period's cumulative per-category
* usage to SaaS, which bills the delta against its own last-seen totals.
* Daily usage sender for combined billing. Reports each period's cumulative per-category usage to
* SaaS, which bills the delta against its own last-seen totals.
*
* <p>Resilience: the sync seq is persisted before the report so it never regresses across
* restarts/failures; a transport failure leaves the {@code lastSyncedUnits} markers untouched so
@@ -11,9 +11,9 @@ import java.util.HexFormat;
/**
* SHA-256 content fingerprint shared by the SaaS charge path and the linked self-hosted instance's
* meter (combined-billing "Mode A"), so both derive an <em>identical</em> signature for the same
* bytes — the basis for lineage dedup. Pure, no Spring: fixed 64 KiB buffer (allocation independent
* of file size), hardware-accelerated by the JVM where available.
* meter (combined billing), so both derive an <em>identical</em> signature for the same bytes — the
* basis for lineage dedup. Pure, no Spring: fixed 64 KiB buffer (allocation independent of file
* size), hardware-accelerated by the JVM where available.
*
* <p>Lives in {@code :proprietary} (not {@code :common}) so it stays out of the community core
* build yet is reachable from {@code :saas} (which depends on {@code :proprietary}).
@@ -357,12 +357,12 @@ public class SecurityConfiguration {
req -> {
String uri = req.getRequestURI();
String contextPath = req.getContextPath();
// Check if it's a public auth endpoint or static
// resource
return RequestUriUtils.isStaticResource(
contextPath, uri)
|| RequestUriUtils.isPublicAuthEndpoint(
uri, contextPath);
uri, contextPath)
|| RequestUriUtils.isFrontendRoute(
contextPath, uri);
})
.permitAll()
.anyRequest()
@@ -21,9 +21,9 @@ import org.mockito.ArgumentCaptor;
import tools.jackson.databind.ObjectMapper;
/**
* Stubs the {@link HttpClient} so the SaaS endpoint is never actually called. Confirms register
* relays the JWT and parses the credential, and that entitlement parsing + the fail-open (null on
* unreachable) behaviour hold.
* Stubs the {@link HttpClient} so the SaaS endpoint is never actually called. Confirms the connect
* handshake refuses an authorize URL it would not navigate to and carries no user token, and that
* entitlement parsing + the fail-open (null on unreachable) behaviour hold.
*/
class AccountLinkClientTest {
@@ -48,39 +48,79 @@ class AccountLinkClientTest {
return resp;
}
// register() is gone with the JWT relay, and with it the two tests that asserted this client
// sends an Authorization: Bearer header. Nothing here carries a user token any more.
@Test
@SuppressWarnings("unchecked")
void registerRelaysJwtAndParsesCredential() throws Exception {
// Build the stub response first: nesting response() inside when() trips Mockito's
// unfinished-stubbing check (inner when() runs mid outer when()).
void connectRequestRefusesAnAuthorizeUrlItWouldNotNavigateTo() throws Exception {
// The reply drives a browser navigation, so a non-absolute or non-http(s) value must fail
// loudly here rather than reach the admin.
HttpResponse<String> resp =
response(201, "{\"deviceId\":\"dev-1\",\"deviceSecret\":\"sec-1\",\"teamId\":42}");
ArgumentCaptor<HttpRequest> captor = ArgumentCaptor.forClass(HttpRequest.class);
when(httpClient.send(captor.capture(), any(HttpResponse.BodyHandler.class)))
.thenReturn(resp);
response(201, "{\"requestId\":\"req-1\",\"authorizeUrl\":\"/link?request=req-1\"}");
when(httpClient.send(any(), any(HttpResponse.BodyHandler.class))).thenReturn(resp);
AccountLinkClient.RegisterResult result = client.register("jwt-token", "My Server");
assertEquals("dev-1", result.deviceId());
assertEquals("sec-1", result.deviceSecret());
assertEquals(42L, result.teamId());
HttpRequest sent = captor.getValue();
assertEquals("Bearer jwt-token", sent.headers().firstValue("Authorization").orElse(null));
assertEquals(
"https://saas.example.com/api/v1/account-link/register", sent.uri().toString());
assertThrows(
java.io.IOException.class,
() -> client.connectRequest("n", "https://pdf.example.com/cb", "nonce", "secret"));
}
@Test
@SuppressWarnings("unchecked")
void registerThrowsUpstreamExceptionWithStatusOnNon2xx() throws Exception {
HttpResponse<String> resp = response(401, "{\"error\":\"unauthorized\"}");
void connectRequestParsesTheAuthorizeUrlItIsGiven() throws Exception {
HttpResponse<String> resp =
response(
201,
"{\"requestId\":\"req-1\",\"expiresIn\":900,"
+ "\"authorizeUrl\":\"https://app.example.com/link?request=req-1\"}");
ArgumentCaptor<HttpRequest> captor = ArgumentCaptor.forClass(HttpRequest.class);
when(httpClient.send(captor.capture(), any(HttpResponse.BodyHandler.class)))
.thenReturn(resp);
AccountLinkClient.ConnectRequestResult result =
client.connectRequest("n", "https://pdf.example.com/cb", "nonce", "secret");
assertEquals("req-1", result.requestId());
assertEquals("https://app.example.com/link?request=req-1", result.authorizeUrl());
// No user token on this call, by design.
assertEquals(null, captor.getValue().headers().firstValue("Authorization").orElse(null));
}
@Test
@SuppressWarnings("unchecked")
void connectClaimGrantsTheCredentialOnSuccess() throws Exception {
HttpResponse<String> resp =
response(200, "{\"deviceId\":\"dev-1\",\"deviceSecret\":\"sec-1\",\"teamId\":7}");
when(httpClient.send(any(), any(HttpResponse.BodyHandler.class))).thenReturn(resp);
AccountLinkClient.UpstreamException ex =
assertThrows(
AccountLinkClient.UpstreamException.class,
() -> client.register("jwt", null));
assertEquals(401, ex.status());
AccountLinkClient.ConnectClaimResult result = client.connectClaim("req-1", "secret");
assertEquals(AccountLinkClient.ConnectClaimOutcome.GRANTED, result.outcome());
assertEquals("dev-1", result.deviceId());
assertEquals("sec-1", result.deviceSecret());
}
@Test
@SuppressWarnings("unchecked")
void connectClaimMapsTheStatusItIsGiven() throws Exception {
// The whole point of these four: a claim consumes the request server-side, so
// reading 200 as anything but success loses the credential irrecoverably.
assertEquals(AccountLinkClient.ConnectClaimOutcome.PENDING, claimOutcome(202, "{}"));
assertEquals(AccountLinkClient.ConnectClaimOutcome.UNAVAILABLE, claimOutcome(503, "{}"));
assertEquals(AccountLinkClient.ConnectClaimOutcome.REJECTED, claimOutcome(400, "{}"));
assertEquals(
AccountLinkClient.ConnectClaimOutcome.CONFIRMED,
claimOutcome(200, "{\"status\":\"confirmed\",\"teamId\":7}"));
}
@SuppressWarnings("unchecked")
private AccountLinkClient.ConnectClaimOutcome claimOutcome(int status, String body)
throws Exception {
// Built before the when(), not inside it: response() stubs a mock of its own, and
// Mockito cannot have that happen mid-stubbing.
HttpResponse<String> resp = response(status, body);
when(httpClient.send(any(), any(HttpResponse.BodyHandler.class))).thenReturn(resp);
return client.connectClaim("req-1", "secret").outcome();
}
@Test
@@ -1,6 +1,7 @@
package stirling.software.proprietary.accountlink;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
@@ -14,16 +15,15 @@ import org.springframework.beans.factory.ObjectProvider;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import stirling.software.proprietary.accountlink.AccountLinkController.LinkRequest;
/**
* The local (self-hosted) account-link controller's error mapping: an upstream auth rejection
* surfaces as 401/403 (so the portal can prompt a re-sign-in) while other upstream / transport
* faults are a 502.
* The local (self-hosted) account-link controller's error mapping. Every upstream or transport
* failure is a 502, and the response body never echoes the exception, because a DNS or TLS message
* can carry the configured SaaS host.
*/
class AccountLinkControllerTest {
private AccountLinkService service;
private ConnectService connectService;
private UsageSyncService syncService;
private ObjectProvider<UsageSyncService> syncProvider;
private AccountLinkController controller;
@@ -32,47 +32,54 @@ class AccountLinkControllerTest {
@SuppressWarnings("unchecked")
void setUp() {
service = mock(AccountLinkService.class);
connectService = mock(ConnectService.class);
syncService = mock(UsageSyncService.class);
syncProvider = mock(ObjectProvider.class);
controller =
new AccountLinkController(service, mock(LocalUsageService.class), syncProvider);
new AccountLinkController(
service, connectService, mock(LocalUsageService.class), syncProvider);
}
@Test
void link_missingJwt_returns400() {
ResponseEntity<?> resp = controller.link(new LinkRequest(" ", null));
assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST);
}
// These asserted POST /link's error mapping, which distinguished 401/403 so the portal could
// prompt a re-sign-in. That endpoint is gone with the JWT relay, and the distinction went with
// it: connect/start carries no user token, so an upstream refusal is never the admin's session
// and everything non-transport is a plain gateway failure.
@Test
void link_upstreamUnauthorized_maps401() throws Exception {
when(service.link("jwt", null))
.thenThrow(new AccountLinkClient.UpstreamException(401, "bad token"));
ResponseEntity<?> resp = controller.link(new LinkRequest("jwt", null));
assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED);
}
@Test
void link_upstreamForbidden_maps403() throws Exception {
when(service.link("jwt", null))
.thenThrow(new AccountLinkClient.UpstreamException(403, "forbidden"));
ResponseEntity<?> resp = controller.link(new LinkRequest("jwt", null));
assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.FORBIDDEN);
}
@Test
void link_upstreamServerError_maps502() throws Exception {
when(service.link("jwt", null))
void connectStart_upstreamFailure_maps502() throws Exception {
when(connectService.start(any(), any()))
.thenThrow(new AccountLinkClient.UpstreamException(500, "boom"));
ResponseEntity<?> resp = controller.link(new LinkRequest("jwt", null));
ResponseEntity<?> resp = controller.connectStart(null, request());
assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.BAD_GATEWAY);
}
@Test
void link_transportFailure_maps502() throws Exception {
when(service.link("jwt", null)).thenThrow(new IOException("connection refused"));
ResponseEntity<?> resp = controller.link(new LinkRequest("jwt", null));
void connectStart_transportFailure_maps502WithoutLeakingTheHost() throws Exception {
when(connectService.start(any(), any()))
.thenThrow(new IOException("connection refused to saas.internal:8081"));
ResponseEntity<?> resp = controller.connectStart(null, request());
assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.BAD_GATEWAY);
// The body must not echo the exception: a DNS/TLS message can carry the configured SaaS
// host.
assertThat(String.valueOf(resp.getBody())).doesNotContain("saas.internal");
}
@Test
void connectReauth_onAnUnlinkedServer_maps502() throws Exception {
when(connectService.startReauth(any())).thenThrow(new IOException("not linked"));
ResponseEntity<?> resp = controller.connectReauth(null, request());
assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.BAD_GATEWAY);
}
/** Minimal request: the controller only reads Origin and the forwarded/host details from it. */
private static jakarta.servlet.http.HttpServletRequest request() {
return new org.springframework.mock.web.MockHttpServletRequest();
}
@Test
@@ -3,12 +3,10 @@ package stirling.software.proprietary.accountlink;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import java.io.IOException;
import java.time.LocalDateTime;
import java.util.Optional;
@@ -30,33 +28,25 @@ class AccountLinkServiceTest {
service = new AccountLinkService(client, store, cache);
}
// The two link() tests here are gone with the JWT relay. Storing a credential and invalidating
// the entitlement cache is now ConnectService's job and is covered by ConnectServiceTest; what
// remains in this service is status and unlink.
@Test
void link_storesCredentialAndInvalidatesCache() throws IOException {
when(client.register("jwt", "name"))
.thenReturn(new AccountLinkClient.RegisterResult("dev-1", "sec-1", 7L));
void status_linkedFromTheStoredCredential() {
DeviceCredential stored = new DeviceCredential();
stored.setDeviceId("dev-1");
stored.setTeamId(7L);
stored.setLinkedAt(LocalDateTime.now());
when(store.get()).thenReturn(Optional.of(stored));
AccountLinkService.LinkStatus status = service.link("jwt", "name");
AccountLinkService.LinkStatus status = service.status();
verify(store).save("dev-1", "sec-1", 7L);
verify(cache).invalidate();
assertTrue(status.linked());
assertEquals("dev-1", status.deviceId());
assertEquals(7L, status.teamId());
}
@Test
void link_propagatesRegisterFailure() throws IOException {
when(client.register(any(), any())).thenThrow(new IOException("boom"));
org.junit.jupiter.api.Assertions.assertThrows(
IOException.class, () -> service.link("jwt", null));
verify(cache, org.mockito.Mockito.never()).invalidate();
}
@Test
void status_unlinkedWhenNoCredential() {
when(store.get()).thenReturn(Optional.empty());
@@ -0,0 +1,408 @@
package stirling.software.proprietary.accountlink;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyNoInteractions;
import static org.mockito.Mockito.when;
import java.time.LocalDateTime;
import java.util.Optional;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.mockito.junit.jupiter.MockitoSettings;
import org.mockito.quality.Strictness;
import stirling.software.common.model.ApplicationProperties;
import stirling.software.proprietary.accountlink.AccountLinkClient.ConnectClaimOutcome;
import stirling.software.proprietary.accountlink.AccountLinkClient.ConnectClaimResult;
import stirling.software.proprietary.accountlink.AccountLinkClient.ConnectRequestResult;
import stirling.software.proprietary.accountlink.ConnectService.Phase;
/** Unit tests for the instance half of the connect handshake. */
@ExtendWith(MockitoExtension.class)
@MockitoSettings(strictness = Strictness.LENIENT)
class ConnectServiceTest {
private static final String NONCE = "the-nonce";
private static final String CLAIM_SECRET = "the-claim-secret";
private static final String AUTHORIZE_URL = "https://app.example.com/link?request=req-1";
@Mock private AccountLinkClient client;
@Mock private ConnectStateRepository stateRepo;
@Mock private DeviceCredentialStore credentialStore;
@Mock private EntitlementCache entitlementCache;
private ApplicationProperties applicationProperties;
private ConnectService service;
@BeforeEach
void setUp() {
applicationProperties = new ApplicationProperties();
service =
new ConnectService(
client,
stateRepo,
credentialStore,
entitlementCache,
applicationProperties);
}
private void configureFrontendUrl(String url) {
applicationProperties.getSystem().setFrontendUrl(url);
}
@Test
void start_advertisesTheConfiguredFrontendUrlInPreferenceToTheRequest() throws Exception {
configureFrontendUrl("https://pdf.example.com/");
stubCreate();
service.start("prod-1", fromRequest("http://10.0.0.5:8080"));
verify(client)
.connectRequest(
anyString(),
// Trailing slash trimmed, and the request's own view ignored.
org.mockito.ArgumentMatchers.eq(
"https://pdf.example.com" + ConnectService.CALLBACK_PATH),
anyString(),
anyString(),
// A first link carries no credential; that is what makes it a first link.
org.mockito.ArgumentMatchers.isNull());
}
@Test
void start_fallsBackToTheAddressTheRequestArrivedOn() throws Exception {
stubCreate();
service.start(null, fromRequest("https://pdf.internal:8443/stirling"));
ArgumentCaptor<String> callback = ArgumentCaptor.forClass(String.class);
verify(client).connectRequest(any(), callback.capture(), anyString(), anyString(), any());
// Context path preserved, so a subpath deployment gets a callback that resolves.
assertThat(callback.getValue())
.isEqualTo("https://pdf.internal:8443/stirling" + ConnectService.CALLBACK_PATH);
}
@Test
void start_withNoAddressAtAllFailsRatherThanGuessing() {
assertThat(catchIo(() -> service.start(null, fromRequest(null))))
.hasMessageContaining("system.frontendUrl");
verifyNoInteractions(client);
}
@Test
void resolveCallback_honoursThePortalsOwnCallbackWhenTheBrowserOriginAgrees() {
// The frontend is the only party that knows its router's base path.
String requested = "http://localhost:5173/app/account-link/callback";
assertThat(
service.resolveCallbackUrl(
new ConnectService.CallbackHint(
requested,
"http://localhost:5173",
"http://localhost:8080")))
.isEqualTo(requested);
}
@Test
void resolveCallback_ignoresACallbackFromADifferentOrigin() {
assertThat(
service.resolveCallbackUrl(
new ConnectService.CallbackHint(
"https://evil.example.com/steal",
"http://localhost:5173",
"http://localhost:8080")))
.isEqualTo("http://localhost:5173" + ConnectService.CALLBACK_PATH);
}
@Test
void resolveCallback_prefersTheBrowserOriginOverTheApiRequest() {
// The whole point: :5173 is where the admin is, :8080 is where the call landed.
assertThat(
service.resolveCallbackUrl(
new ConnectService.CallbackHint(
null, "http://localhost:5173", "http://localhost:8080")))
.isEqualTo("http://localhost:5173" + ConnectService.CALLBACK_PATH);
}
@Test
void resolveCallback_letsConfigurationBeatEverything() {
configureFrontendUrl("https://pdf.example.com/");
assertThat(
service.resolveCallbackUrl(
new ConnectService.CallbackHint(
"http://localhost:5173/account-link/callback",
"http://localhost:5173",
"http://localhost:8080")))
.isEqualTo("https://pdf.example.com" + ConnectService.CALLBACK_PATH);
}
@Test
void resolveCallback_ignoresAnUnusableOriginHeader() {
// "null" is what a browser sends for an opaque origin; it must not become a callback.
assertThat(
service.resolveCallbackUrl(
new ConnectService.CallbackHint(
null, "null", "http://localhost:8080")))
.isEqualTo("http://localhost:8080" + ConnectService.CALLBACK_PATH);
}
@Test
void start_sendsTheAdminWhereverSaaSSaidToSendThem() throws Exception {
stubCreate();
ConnectService.ConnectStatus status =
service.start(null, fromRequest("https://pdf.example.com"));
assertThat(status.phase()).isEqualTo(Phase.PENDING);
// Not composed here: only the SaaS side knows where its approval page lives, so an
// instance configuring that could only get it wrong.
assertThat(status.authorizeUrl()).isEqualTo(AUTHORIZE_URL);
}
@Test
void start_keepsTheNonceAndClaimSecretItSent() throws Exception {
stubCreate();
service.start(null, fromRequest("https://pdf.example.com"));
ArgumentCaptor<String> nonce = ArgumentCaptor.forClass(String.class);
ArgumentCaptor<String> secret = ArgumentCaptor.forClass(String.class);
verify(client).connectRequest(any(), anyString(), nonce.capture(), secret.capture(), any());
ArgumentCaptor<ConnectState> saved = ArgumentCaptor.forClass(ConnectState.class);
verify(stateRepo).save(saved.capture());
assertThat(saved.getValue().getNonce()).isEqualTo(nonce.getValue());
assertThat(saved.getValue().getClaimSecret()).isEqualTo(secret.getValue());
// Two independent secrets, not one value used twice.
assertThat(nonce.getValue()).isNotEqualTo(secret.getValue());
}
@Test
void start_whenAlreadyLinkedDoesNothing() throws Exception {
when(credentialStore.isLinked()).thenReturn(true);
when(credentialStore.get()).thenReturn(Optional.of(credential(7L)));
ConnectService.ConnectStatus status =
service.start(null, fromRequest("https://pdf.example.com"));
assertThat(status.phase()).isEqualTo(Phase.LINKED);
verifyNoInteractions(client);
verify(stateRepo, never()).save(any());
}
@Test
void complete_withTheRightNonceStoresTheCredentialAndClearsTheHandshake() {
ConnectState state = openHandshake();
when(stateRepo.findById(ConnectState.SINGLETON_ID)).thenReturn(Optional.of(state));
when(client.connectClaim("req-1", CLAIM_SECRET))
.thenReturn(new ConnectClaimResult(ConnectClaimOutcome.GRANTED, "dev", "sec", 7L));
ConnectService.ConnectStatus status = service.complete(NONCE);
assertThat(status.phase()).isEqualTo(Phase.LINKED);
assertThat(status.teamId()).isEqualTo(7L);
verify(credentialStore).save("dev", "sec", 7L);
verify(entitlementCache).invalidate();
verify(stateRepo).delete(state);
}
@Test
void complete_withAWrongNonceClaimsNothingAndLeavesTheHandshakeAlone() {
ConnectState state = openHandshake();
when(stateRepo.findById(ConnectState.SINGLETON_ID)).thenReturn(Optional.of(state));
ConnectService.ConnectStatus status = service.complete("not-the-nonce");
assertThat(status.phase()).isEqualTo(Phase.REJECTED);
// The important half: an unverified caller cannot cancel a legitimate handshake.
verify(stateRepo, never()).delete(any());
verifyNoInteractions(credentialStore);
verify(client, never()).connectClaim(anyString(), anyString());
}
@Test
void complete_withNoNonceAtAllIsRejected() {
when(stateRepo.findById(ConnectState.SINGLETON_ID))
.thenReturn(Optional.of(openHandshake()));
assertThat(service.complete(null).phase()).isEqualTo(Phase.REJECTED);
verify(client, never()).connectClaim(anyString(), anyString());
}
@Test
void complete_whenSaaSHasNotCommittedTheApprovalKeepsTheHandshake() {
when(stateRepo.findById(ConnectState.SINGLETON_ID))
.thenReturn(Optional.of(openHandshake()));
when(client.connectClaim(anyString(), anyString()))
.thenReturn(ConnectClaimResult.of(ConnectClaimOutcome.PENDING));
assertThat(service.complete(NONCE).phase()).isEqualTo(Phase.PENDING);
verify(stateRepo, never()).delete(any());
}
@Test
void complete_whenSaaSIsUnreachableKeepsTheHandshakeForARetry() {
when(stateRepo.findById(ConnectState.SINGLETON_ID))
.thenReturn(Optional.of(openHandshake()));
when(client.connectClaim(anyString(), anyString()))
.thenReturn(ConnectClaimResult.of(ConnectClaimOutcome.UNAVAILABLE));
assertThat(service.complete(NONCE).phase()).isEqualTo(Phase.UNAVAILABLE);
verify(stateRepo, never()).delete(any());
verifyNoInteractions(credentialStore);
}
@Test
void complete_whenDeclinedClearsTheHandshake() {
ConnectState state = openHandshake();
when(stateRepo.findById(ConnectState.SINGLETON_ID)).thenReturn(Optional.of(state));
when(client.connectClaim(anyString(), anyString()))
.thenReturn(ConnectClaimResult.of(ConnectClaimOutcome.REJECTED));
assertThat(service.complete(NONCE).phase()).isEqualTo(Phase.REJECTED);
verify(stateRepo).delete(state);
verifyNoInteractions(credentialStore);
}
@Test
void complete_onAnExpiredHandshakeClearsItWithoutClaiming() {
ConnectState state = openHandshake();
state.setExpiresAt(LocalDateTime.now().minusSeconds(1));
when(stateRepo.findById(ConnectState.SINGLETON_ID)).thenReturn(Optional.of(state));
assertThat(service.complete(NONCE).phase()).isEqualTo(Phase.EXPIRED);
verify(stateRepo).delete(state);
verify(client, never()).connectClaim(anyString(), anyString());
}
@Test
void startReauth_presentsTheCredentialSoSaaSCanPinTheTeam() throws Exception {
when(credentialStore.get()).thenReturn(Optional.of(credential(7L)));
when(client.connectRequest(any(), anyString(), anyString(), anyString(), any()))
.thenReturn(new ConnectRequestResult("req-1", 900, AUTHORIZE_URL));
service.startReauth(fromRequest("https://pdf.example.com"));
// Sending the credential is what makes the pinning trustworthy: the team comes from
// something only this instance holds.
verify(client)
.connectRequest(
any(),
anyString(),
anyString(),
anyString(),
org.mockito.ArgumentMatchers.argThat(
c -> c != null && "dev".equals(c.getDeviceId())));
}
@Test
void startReauth_onAnUnlinkedServerFails() {
assertThat(catchIo(() -> service.startReauth(fromRequest("https://pdf.example.com"))))
.hasMessageContaining("not linked");
verifyNoInteractions(client);
}
@Test
void complete_onAConfirmedReauthKeepsTheExistingCredential() {
ConnectState state = openHandshake();
when(stateRepo.findById(ConnectState.SINGLETON_ID)).thenReturn(Optional.of(state));
when(client.connectClaim(anyString(), anyString()))
.thenReturn(new ConnectClaimResult(ConnectClaimOutcome.CONFIRMED, null, null, 7L));
ConnectService.ConnectStatus status = service.complete(NONCE);
assertThat(status.phase()).isEqualTo(Phase.LINKED);
assertThat(status.teamId()).isEqualTo(7L);
// Nothing to store: a second credential would orphan the one we already hold.
verify(credentialStore, never()).save(anyString(), anyString(), any());
verify(stateRepo).delete(state);
}
@Test
void status_reportsNothingInFlightWhenThereIsNoHandshakeOrCredential() {
assertThat(service.status().phase()).isEqualTo(Phase.NONE);
}
@Test
void status_reportsAnExpiredHandshakeRatherThanOfferingAStaleLink() {
ConnectState state = openHandshake();
state.setExpiresAt(LocalDateTime.now().minusSeconds(1));
when(stateRepo.findById(ConnectState.SINGLETON_ID)).thenReturn(Optional.of(state));
ConnectService.ConnectStatus status = service.status();
assertThat(status.phase()).isEqualTo(Phase.EXPIRED);
assertThat(status.authorizeUrl()).isNull();
}
@Test
void status_countsDownWhileAHandshakeIsOpen() {
when(stateRepo.findById(ConnectState.SINGLETON_ID))
.thenReturn(Optional.of(openHandshake()));
ConnectService.ConnectStatus status = service.status();
assertThat(status.phase()).isEqualTo(Phase.PENDING);
assertThat(status.secondsRemaining()).isPositive();
assertThat(status.authorizeUrl()).isEqualTo("https://app.example.com/link?request=req-1");
}
// ---------------------------------------------------------------------------------------
/** A start with nothing but the reconstructed request URL, as a headless caller would send. */
private static ConnectService.CallbackHint fromRequest(String derivedBaseUrl) {
return new ConnectService.CallbackHint(null, null, derivedBaseUrl);
}
private void stubCreate() throws Exception {
// The five-argument overload: a first link passes a null credential rather than none.
when(client.connectRequest(any(), anyString(), anyString(), anyString(), any()))
.thenReturn(new ConnectRequestResult("req-1", 900, AUTHORIZE_URL));
}
private static ConnectState openHandshake() {
ConnectState state = new ConnectState();
state.setId(ConnectState.SINGLETON_ID);
state.setRequestId("req-1");
state.setNonce(NONCE);
state.setClaimSecret(CLAIM_SECRET);
state.setCallbackUrl("https://pdf.example.com/account-link/callback");
state.setAuthorizeUrl("https://app.example.com/link?request=req-1");
state.setCreatedAt(LocalDateTime.now());
state.setExpiresAt(LocalDateTime.now().plusMinutes(10));
return state;
}
private static DeviceCredential credential(Long teamId) {
DeviceCredential credential = new DeviceCredential();
credential.setDeviceId("dev");
credential.setDeviceSecret("sec");
credential.setTeamId(teamId);
credential.setLinkedAt(LocalDateTime.now());
return credential;
}
/** Runs a throwing call and returns the exception, so the assertion reads in one line. */
private static Throwable catchIo(ThrowingCall call) {
try {
call.run();
throw new AssertionError("expected the call to fail");
} catch (Exception e) {
return e;
}
}
private interface ThrowingCall {
void run() throws Exception;
}
}
@@ -4,14 +4,12 @@ import java.util.List;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.context.annotation.Profile;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.Authentication;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@@ -19,25 +17,9 @@ import io.swagger.v3.oas.annotations.Hidden;
import lombok.extern.slf4j.Slf4j;
import stirling.software.common.model.enumeration.TeamRole;
import stirling.software.proprietary.model.TeamMembership;
import stirling.software.proprietary.security.database.repository.UserRepository;
import stirling.software.proprietary.security.model.User;
import stirling.software.proprietary.security.repository.TeamMembershipRepository;
import stirling.software.saas.util.AuthenticationUtils;
import stirling.software.saas.accountlink.LeaderTeamResolver.LeaderTeam;
/**
* Account-link registration surface (combined-billing "Mode A").
*
* <p>A self-hosted instance's local backend calls {@code POST /register} with the admin's
* short-lived Supabase JWT (validated by the existing {@code SupabaseSecurityConfig} chain — no new
* auth here). We resolve the caller's team, mint a device credential bound to it, and return the
* secret exactly once. Ongoing entitlement reads authenticate with that device credential, not this
* JWT.
*
* <p>Whole surface gated behind {@code stirling.billing.account-link.enabled}: off → beans absent →
* 404. Leader-only, and the team is always derived from the caller (never the request body).
*/
/** Team-wide management of linked instances (combined billing). */
@Slf4j
@Hidden
@RestController
@@ -47,25 +29,13 @@ import stirling.software.saas.util.AuthenticationUtils;
public class AccountLinkController {
private final AccountLinkService service;
private final TeamMembershipRepository memberRepo;
private final UserRepository userRepository;
private final LeaderTeamResolver leaderTeams;
public AccountLinkController(
AccountLinkService service,
TeamMembershipRepository memberRepo,
UserRepository userRepository) {
public AccountLinkController(AccountLinkService service, LeaderTeamResolver leaderTeams) {
this.service = service;
this.memberRepo = memberRepo;
this.userRepository = userRepository;
this.leaderTeams = leaderTeams;
}
/** Optional display name for the instance (hostname / label). */
public record RegisterRequest(String name) {}
/** {@code deviceSecret} is plaintext and returned exactly once — the caller must store it. */
public record RegisterResponse(
Long instanceId, Long teamId, String deviceId, String deviceSecret, String name) {}
public record InstanceRow(
Long instanceId,
String deviceId,
@@ -74,31 +44,10 @@ public class AccountLinkController {
String lastSeenAt,
boolean revoked) {}
@PostMapping("/register")
@PreAuthorize("isAuthenticated()")
public ResponseEntity<RegisterResponse> register(
@RequestBody(required = false) RegisterRequest req, Authentication auth) {
LeaderTeam lt = resolveLeaderTeam(auth);
if (lt.error() != null) {
return ResponseEntity.status(lt.error()).build();
}
String name = req != null ? req.name() : null;
AccountLinkService.RegisteredInstance reg =
service.register(lt.teamId(), lt.userId(), name);
return ResponseEntity.status(HttpStatus.CREATED)
.body(
new RegisterResponse(
reg.instanceId(),
lt.teamId(),
reg.deviceId(),
reg.deviceSecret(),
reg.name()));
}
@GetMapping("/instances")
@PreAuthorize("isAuthenticated()")
public ResponseEntity<List<InstanceRow>> list(Authentication auth) {
LeaderTeam lt = resolveLeaderTeam(auth);
LeaderTeam lt = leaderTeams.resolve(auth);
if (lt.error() != null) {
return ResponseEntity.status(lt.error()).build();
}
@@ -124,38 +73,11 @@ public class AccountLinkController {
@PostMapping("/instances/{instanceId}/revoke")
@PreAuthorize("isAuthenticated()")
public ResponseEntity<Void> revoke(@PathVariable Long instanceId, Authentication auth) {
LeaderTeam lt = resolveLeaderTeam(auth);
LeaderTeam lt = leaderTeams.resolve(auth);
if (lt.error() != null) {
return ResponseEntity.status(lt.error()).build();
}
boolean ok = service.revoke(lt.teamId(), instanceId);
return ok ? ResponseEntity.noContent().build() : ResponseEntity.notFound().build();
}
// ---------------------------------------------------------------------------------------
// Helpers — team always derived from the caller; instance linking is a leader (billing) action.
// ---------------------------------------------------------------------------------------
/**
* Resolved caller team, or an {@code error} status to return (teamId/userId null when error).
*/
private record LeaderTeam(Long teamId, Long userId, HttpStatus error) {}
private LeaderTeam resolveLeaderTeam(Authentication auth) {
User user;
try {
user = AuthenticationUtils.getCurrentUser(auth, userRepository);
} catch (SecurityException e) {
return new LeaderTeam(null, null, HttpStatus.UNAUTHORIZED);
}
List<TeamMembership> rows = memberRepo.findPrimaryMembership(user.getId());
if (rows.isEmpty()) {
return new LeaderTeam(null, null, HttpStatus.FORBIDDEN);
}
TeamMembership m = rows.getFirst();
if (m.getRole() != TeamRole.LEADER) {
return new LeaderTeam(null, null, HttpStatus.FORBIDDEN);
}
return new LeaderTeam(m.getTeam().getId(), user.getId(), null);
}
}
@@ -18,16 +18,7 @@ import org.springframework.transaction.annotation.Transactional;
import lombok.extern.slf4j.Slf4j;
/**
* Account-link instance registration + lifecycle (combined-billing "Mode A").
*
* <p>Mints a {@code device_id} (public) + {@code device_secret} (high-entropy, returned once) bound
* to a team, persisting only the SHA-256 hash of the secret. The instance authenticates its
* unattended entitlement reads with that credential.
*
* <p>Gated behind {@code stirling.billing.account-link.enabled}: when off the bean is absent, so
* {@link AccountLinkController} (which depends on it) drops out too and its endpoints 404.
*/
/** Account-link instance registration + lifecycle (combined billing). */
@Slf4j
@Service
@Profile("saas")
@@ -80,10 +71,7 @@ public class AccountLinkService {
return repo.findByTeamIdOrderByCreatedAtDesc(teamId);
}
/**
* Revokes an instance iff it belongs to {@code teamId}. Returns false if not found or owned by
* a different team (so a caller can never revoke another team's instance). Idempotent.
*/
/** Revokes an instance iff it belongs to {@code teamId}. */
@Transactional
public boolean revoke(Long teamId, Long instanceId) {
Optional<LinkedInstance> found = repo.findById(instanceId);
@@ -99,13 +87,30 @@ public class AccountLinkService {
return true;
}
/**
* Resolves an active instance from a device credential, or empty if it does not authenticate.
*/
@Transactional(readOnly = true)
public Optional<LinkedInstance> resolveActiveInstance(String deviceId, String deviceSecret) {
if (deviceId == null || deviceSecret == null) {
return Optional.empty();
}
return repo.findByDeviceIdAndRevokedAtIsNull(deviceId)
.filter(
instance ->
MessageDigest.isEqual(
sha256Hex(deviceSecret).getBytes(StandardCharsets.UTF_8),
instance.getDeviceSecretHash()
.getBytes(StandardCharsets.UTF_8)));
}
private String randomSecret() {
byte[] buf = new byte[SECRET_BYTES];
random.nextBytes(buf);
return Base64.getUrlEncoder().withoutPadding().encodeToString(buf);
}
/** SHA-256 hex of a value. The device secret is high-entropy, so no salt is required. */
/** SHA-256 hex of a value. */
static String sha256Hex(String value) {
try {
MessageDigest md = MessageDigest.getInstance("SHA-256");
@@ -0,0 +1,277 @@
package stirling.software.saas.accountlink;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.util.Map;
import java.util.Optional;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.context.annotation.Profile;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.Authentication;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import io.swagger.v3.oas.annotations.Hidden;
import jakarta.servlet.http.HttpServletRequest;
import lombok.extern.slf4j.Slf4j;
import stirling.software.common.model.ApplicationProperties;
import stirling.software.saas.accountlink.LeaderTeamResolver.LeaderTeam;
/** Browser-mediated "connect this server" handshake. */
@Slf4j
@Hidden
@RestController
@RequestMapping("/api/v1/account-link/connect")
@Profile("saas")
@ConditionalOnProperty(name = "stirling.billing.account-link.enabled", havingValue = "true")
public class ConnectController {
/** Same headers the device-credential filter uses on the {@code /api/v1/instance} paths. */
static final String HEADER_DEVICE_ID = "X-Device-Id";
static final String HEADER_DEVICE_SECRET = "X-Device-Secret";
/** Frontend route serving the approval page. */
static final String LINK_PATH = "/link";
private final ConnectRequestService service;
private final LeaderTeamResolver leaderTeams;
private final AccountLinkService accountLinkService;
private final ApplicationProperties applicationProperties;
public ConnectController(
ConnectRequestService service,
LeaderTeamResolver leaderTeams,
AccountLinkService accountLinkService,
ApplicationProperties applicationProperties) {
this.service = service;
this.leaderTeams = leaderTeams;
this.accountLinkService = accountLinkService;
this.applicationProperties = applicationProperties;
}
/** Sent by the instance's own backend, before it holds any credential. */
public record CreateBody(String name, String callbackUrl, String nonce, String claimSecret) {}
/** {@code authorizeUrl} is where the instance should send its admin. */
public record CreateResponse(String requestId, int expiresIn, String authorizeUrl) {}
/** What the approval page renders. */
public record ViewResponse(
String requestId,
String name,
String callbackOrigin,
boolean insecureTransport,
String mode,
String status) {}
/** Where the approver's browser goes next, and the correlator the instance is waiting on. */
public record ApproveResponse(String callbackUrl, String nonce) {}
public record ClaimBody(String requestId, String claimSecret) {}
public record ClaimResponse(String deviceId, String deviceSecret, Long teamId) {}
/** Opens a handshake. */
@PostMapping("/request")
public ResponseEntity<?> request(
@RequestBody(required = false) CreateBody body, HttpServletRequest http) {
if (body == null) {
return ResponseEntity.badRequest().body(Map.of("error", "BAD_REQUEST"));
}
String deviceId = http.getHeader(HEADER_DEVICE_ID);
String deviceSecret = http.getHeader(HEADER_DEVICE_SECRET);
boolean reauthRequested = deviceId != null || deviceSecret != null;
ConnectRequestService.CreateResult result;
if (reauthRequested) {
Long pinnedTeamId =
accountLinkService
.resolveActiveInstance(deviceId, deviceSecret)
.map(LinkedInstance::getTeamId)
.orElse(null);
result =
service.createReauth(
body.name(),
body.callbackUrl(),
body.nonce(),
body.claimSecret(),
clientIp(http),
pinnedTeamId);
} else {
result =
service.create(
body.name(),
body.callbackUrl(),
body.nonce(),
body.claimSecret(),
clientIp(http));
}
if (result.isRejected()) {
return switch (result.rejection()) {
case RATE_LIMITED ->
ResponseEntity.status(HttpStatus.TOO_MANY_REQUESTS)
.body(Map.of("error", "RATE_LIMITED"));
case BAD_CALLBACK ->
ResponseEntity.badRequest().body(Map.of("error", "BAD_CALLBACK"));
case BAD_NONCE -> ResponseEntity.badRequest().body(Map.of("error", "BAD_NONCE"));
case BAD_SECRET -> ResponseEntity.badRequest().body(Map.of("error", "BAD_SECRET"));
// A credential was offered and did not authenticate. Same answer as any other bad
// credential, and deliberately not distinguishable from "revoked".
case NOT_LINKED ->
ResponseEntity.status(HttpStatus.UNAUTHORIZED)
.body(Map.of("error", "NOT_LINKED"));
};
}
return ResponseEntity.status(HttpStatus.CREATED)
.body(
new CreateResponse(
result.requestId(),
result.expiresInSeconds(),
authorizeUrl(result.requestId(), http)));
}
/**
* Where to send the admin to approve a handshake. {@code system.frontendUrl} is the web app's
* own base URL, including any base path; without it the API's origin has to serve the app too.
*/
private String authorizeUrl(String requestId, HttpServletRequest http) {
String frontendUrl = applicationProperties.getSystem().getFrontendUrl();
String base =
frontendUrl != null && !frontendUrl.isBlank()
? frontendUrl.strip().replaceAll("/+$", "")
: requestOrigin(http);
return base
+ LINK_PATH
+ "?request="
+ URLEncoder.encode(requestId, StandardCharsets.UTF_8);
}
/** Scheme, host and context path as the browser reached us, honouring a reverse proxy. */
private static String requestOrigin(HttpServletRequest request) {
String proto = firstHop(request.getHeader("X-Forwarded-Proto"));
String host = firstHop(request.getHeader("X-Forwarded-Host"));
String scheme = proto != null ? proto : request.getScheme();
// A forwarded host already carries its own port, if it needs one.
String hostPort =
host != null
? host
: Origins.hostPort(
scheme, request.getServerName(), request.getServerPort());
String context = request.getContextPath() == null ? "" : request.getContextPath();
return scheme + "://" + hostPort + context;
}
private static String firstHop(String headerValue) {
if (headerValue == null || headerValue.isBlank()) {
return null;
}
String first = headerValue.split(",")[0].strip();
return first.isEmpty() ? null : first;
}
/** Detail for the approval page. */
@GetMapping("/{requestId}")
@PreAuthorize("isAuthenticated()")
public ResponseEntity<ViewResponse> view(@PathVariable String requestId) {
return service.lookup(requestId)
.map(
v ->
ResponseEntity.ok(
new ViewResponse(
v.requestId(),
v.name(),
v.callbackOrigin(),
v.insecureTransport(),
v.mode().name(),
v.status().name())))
.orElseGet(() -> ResponseEntity.notFound().build());
}
/** Approves a handshake. */
@PostMapping("/{requestId}/approve")
@PreAuthorize("isAuthenticated()")
public ResponseEntity<?> approve(@PathVariable String requestId, Authentication auth) {
Optional<ConnectRequestService.ConnectView> view = service.lookup(requestId);
if (view.isEmpty()) {
return ResponseEntity.notFound().build();
}
boolean reauth = view.get().mode() == ConnectRequest.Mode.REAUTH;
LeaderTeam lt = reauth ? leaderTeams.resolveMember(auth) : leaderTeams.resolve(auth);
if (lt.isError()) {
return ResponseEntity.status(lt.error()).build();
}
ConnectRequestService.ApproveResult result =
service.approve(requestId, lt.teamId(), lt.userId());
if (result.isRejected()) {
return switch (result.rejection()) {
// Named separately so the page can say "you are signed in to a different account"
// rather than implying the request itself was bad.
case WRONG_TEAM ->
ResponseEntity.status(HttpStatus.CONFLICT)
.body(Map.of("error", "WRONG_TEAM"));
case UNAVAILABLE -> ResponseEntity.notFound().build();
};
}
return ResponseEntity.ok(
new ApproveResponse(result.target().callbackUrl(), result.target().nonce()));
}
@PostMapping("/{requestId}/deny")
@PreAuthorize("isAuthenticated()")
public ResponseEntity<Void> deny(@PathVariable String requestId, Authentication auth) {
LeaderTeam lt = leaderTeams.resolve(auth);
if (lt.isError()) {
return ResponseEntity.status(lt.error()).build();
}
return service.deny(requestId)
? ResponseEntity.noContent().build()
: ResponseEntity.notFound().build();
}
/** Collects the device credential. */
@PostMapping("/claim")
public ResponseEntity<?> claim(@RequestBody(required = false) ClaimBody body) {
if (body == null) {
return ResponseEntity.badRequest().body(Map.of("error", "BAD_REQUEST"));
}
ConnectRequestService.ClaimResult result =
service.claim(body.requestId(), body.claimSecret());
return switch (result.outcome()) {
case GRANTED ->
ResponseEntity.ok(
new ClaimResponse(
result.deviceId(), result.deviceSecret(), result.teamId()));
// A re-authentication carries no credential: the instance already has one. It only
// needs to know the browser leg succeeded, and which team it was confirmed against.
case CONFIRMED ->
ResponseEntity.ok(Map.of("status", "confirmed", "teamId", result.teamId()));
case PENDING ->
ResponseEntity.status(HttpStatus.ACCEPTED).body(Map.of("status", "pending"));
case REJECTED -> ResponseEntity.badRequest().body(Map.of("error", "CONNECT_REJECTED"));
};
}
/**
* Source address for the creation cap.
*
* <p>Deliberately not reading {@code X-Forwarded-For}: the caller sets it, so keying a cap on
* it lets one rotate fake addresses and have no cap at all. {@code
* server.forward-headers-strategy} is NATIVE, so the container has already resolved the real
* client from trusted proxies.
*/
private static String clientIp(HttpServletRequest request) {
String remote = request.getRemoteAddr();
return remote == null || remote.length() <= 45 ? remote : remote.substring(0, 45);
}
}
@@ -0,0 +1,103 @@
package stirling.software.saas.accountlink;
import java.time.LocalDateTime;
import org.hibernate.annotations.CreationTimestamp;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.EnumType;
import jakarta.persistence.Enumerated;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Index;
import jakarta.persistence.Table;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
/** One in-flight "connect this server" handshake. Short lived and single use. */
@Entity
@Table(
name = "account_link_connect_request",
indexes = @Index(name = "idx_alcr_ip_created", columnList = "requester_ip,created_at"))
@Getter
@Setter
@NoArgsConstructor
public class ConnectRequest {
public enum Mode {
LINK,
REAUTH
}
public enum Status {
PENDING,
APPROVED,
DENIED,
CONSUMED
}
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "request_id", nullable = false, unique = true, length = 64)
private String requestId;
@Column(name = "name", length = 255)
private String name;
/**
* Read back from here on approval, never from the request: that is what stops an open redirect.
*/
@Column(name = "callback_url", nullable = false, length = 2048)
private String callbackUrl;
@Column(name = "callback_origin", nullable = false, length = 255)
private String callbackOrigin;
@Column(name = "nonce", nullable = false, length = 128)
private String nonce;
/** SHA-256; the secret itself is never stored. */
@Column(name = "claim_secret_hash", nullable = false, length = 64)
private String claimSecretHash;
@Enumerated(EnumType.STRING)
@Column(name = "mode", nullable = false, length = 16)
private Mode mode = Mode.LINK;
@Enumerated(EnumType.STRING)
@Column(name = "status", nullable = false, length = 16)
private Status status = Status.PENDING;
/** LINK: set on approval. REAUTH: pinned at creation, so approval can only confirm it. */
@Column(name = "team_id")
private Long teamId;
@Column(name = "approved_by_user_id")
private Long approvedByUserId;
@Column(name = "requester_ip", length = 45)
private String requesterIp;
@CreationTimestamp
@Column(name = "created_at", nullable = false, updatable = false)
private LocalDateTime createdAt;
@Column(name = "expires_at", nullable = false)
private LocalDateTime expiresAt;
@Column(name = "approved_at")
private LocalDateTime approvedAt;
@Column(name = "consumed_at")
private LocalDateTime consumedAt;
public boolean isExpired(LocalDateTime now) {
return expiresAt != null && expiresAt.isBefore(now);
}
}
@@ -0,0 +1,47 @@
package stirling.software.saas.accountlink;
import java.time.LocalDateTime;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.context.annotation.Profile;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
/**
* Removes connect requests that are past use.
*
* <p>Needed rather than merely tidy: anyone can create a row on {@code POST /connect/request}, and
* nothing else deletes one. Requests hold a callback URL and the requester's address, so they are
* swept soon after expiry rather than kept.
*/
@Slf4j
@Service
@Profile("saas")
@ConditionalOnProperty(name = "stirling.billing.account-link.enabled", havingValue = "true")
@RequiredArgsConstructor
public class ConnectRequestCleanupService {
/** Long enough to answer "what happened to my link?" the next morning, and no longer. */
private static final int RETAIN_HOURS = 24;
private final ConnectRequestRepository repo;
@Scheduled(cron = "0 30 3 * * *")
@Transactional
public void purgeExpired() {
try {
LocalDateTime cutoff = LocalDateTime.now().minusHours(RETAIN_HOURS);
int deleted = repo.deleteByExpiresAtBefore(cutoff);
if (deleted > 0) {
log.info("Account-link connect: purged {} expired requests", deleted);
}
} catch (Exception e) {
// A failed sweep must not take the scheduler down; the next run retries.
log.error("Account-link connect: purge failed", e);
}
}
}
@@ -0,0 +1,28 @@
package stirling.software.saas.accountlink;
import java.time.LocalDateTime;
import java.util.Optional;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Lock;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import jakarta.persistence.LockModeType;
/** Data access for {@link ConnectRequest}. */
public interface ConnectRequestRepository extends JpaRepository<ConnectRequest, Long> {
Optional<ConnectRequest> findByRequestId(String requestId);
/** Row-locking read used by approve, deny and claim. */
@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("SELECT r FROM ConnectRequest r WHERE r.requestId = :requestId")
Optional<ConnectRequest> findByRequestIdForUpdate(@Param("requestId") String requestId);
/** Backs the per-IP creation cap, since creating a request needs no authentication. */
long countByRequesterIpAndCreatedAtAfter(String requesterIp, LocalDateTime after);
/** Sweeps rows past use, whatever they settled as. Anyone can create these. */
int deleteByExpiresAtBefore(LocalDateTime cutoff);
}
@@ -0,0 +1,391 @@
package stirling.software.saas.accountlink;
import java.net.URI;
import java.net.URISyntaxException;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.SecureRandom;
import java.time.LocalDateTime;
import java.util.Base64;
import java.util.Locale;
import java.util.Optional;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.context.annotation.Profile;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import lombok.extern.slf4j.Slf4j;
/** The "connect this server" handshake, SaaS side. */
@Slf4j
@Service
@Profile("saas")
@ConditionalOnProperty(name = "stirling.billing.account-link.enabled", havingValue = "true")
public class ConnectRequestService {
/**
* Long enough for the approver to sign in, pick the right account and read the origin. Sized
* for the slowest real route: signing up, waiting for a confirmation email, and coming back.
*/
static final int LIFETIME_MINUTES = 30;
/** Creating a request needs no authentication, so the only brake is per-source volume. */
static final int MAX_REQUESTS_PER_IP = 10;
private static final int REQUEST_ID_BYTES = 32;
private static final int MAX_NONCE_LENGTH = 128;
private static final int MAX_CALLBACK_LENGTH = 2048;
private static final int MAX_NAME_LENGTH = 255;
private final ConnectRequestRepository repo;
private final AccountLinkService accountLinkService;
private final SecureRandom random = new SecureRandom();
public ConnectRequestService(
ConnectRequestRepository repo, AccountLinkService accountLinkService) {
this.repo = repo;
this.accountLinkService = accountLinkService;
}
/** Rejected creation attempts, so the controller can pick a status without parsing messages. */
public enum CreateRejection {
BAD_CALLBACK,
BAD_NONCE,
BAD_SECRET,
RATE_LIMITED,
/**
* A re-authentication was asked for by something that could not prove it is a linked
* instance.
*/
NOT_LINKED
}
/** Either a created request id, or the reason we would not create one. */
public record CreateResult(String requestId, int expiresInSeconds, CreateRejection rejection) {
static CreateResult ok(String requestId, int expiresInSeconds) {
return new CreateResult(requestId, expiresInSeconds, null);
}
static CreateResult rejected(CreateRejection rejection) {
return new CreateResult(null, 0, rejection);
}
public boolean isRejected() {
return rejection != null;
}
}
/** What the approval page shows. */
public record ConnectView(
String requestId,
String name,
String callbackOrigin,
boolean insecureTransport,
ConnectRequest.Mode mode,
ConnectRequest.Status status) {}
/** Where to send the browser once approved, plus the correlator the instance is expecting. */
public record ApprovalTarget(String callbackUrl, String nonce) {}
public enum ClaimOutcome {
/** Approved and collected; {@code credential} is populated. */
GRANTED,
/** A re-authentication was approved. */
CONFIRMED,
/** Still waiting on a human. */
PENDING,
/** Declined, expired, unknown, already collected, or a bad claim secret. */
REJECTED
}
public record ClaimResult(
ClaimOutcome outcome, String deviceId, String deviceSecret, Long teamId) {
static ClaimResult of(ClaimOutcome outcome) {
return new ClaimResult(outcome, null, null, null);
}
}
/** Records a handshake on behalf of an instance that has no credential yet. */
@Transactional
public CreateResult create(
String name, String callbackUrl, String nonce, String claimSecret, String requesterIp) {
return create(name, callbackUrl, nonce, claimSecret, requesterIp, null);
}
/**
* As {@link #create}, but for an instance that is already linked and only needs its admin's
* browser signed in again.
*/
@Transactional
public CreateResult createReauth(
String name,
String callbackUrl,
String nonce,
String claimSecret,
String requesterIp,
Long pinnedTeamId) {
if (pinnedTeamId == null) {
return CreateResult.rejected(CreateRejection.NOT_LINKED);
}
return create(name, callbackUrl, nonce, claimSecret, requesterIp, pinnedTeamId);
}
private CreateResult create(
String name,
String callbackUrl,
String nonce,
String claimSecret,
String requesterIp,
Long pinnedTeamId) {
if (nonce == null || nonce.isBlank() || nonce.length() > MAX_NONCE_LENGTH) {
return CreateResult.rejected(CreateRejection.BAD_NONCE);
}
if (claimSecret == null || claimSecret.isBlank()) {
return CreateResult.rejected(CreateRejection.BAD_SECRET);
}
Optional<URI> parsed = validateCallback(callbackUrl);
if (parsed.isEmpty()) {
return CreateResult.rejected(CreateRejection.BAD_CALLBACK);
}
LocalDateTime now = LocalDateTime.now();
if (requesterIp != null
&& repo.countByRequesterIpAndCreatedAtAfter(requesterIp, now.minusHours(1))
>= MAX_REQUESTS_PER_IP) {
return CreateResult.rejected(CreateRejection.RATE_LIMITED);
}
URI uri = parsed.get();
ConnectRequest request = new ConnectRequest();
request.setRequestId(randomToken());
request.setName(trim(name, MAX_NAME_LENGTH));
request.setCallbackUrl(uri.toString());
request.setCallbackOrigin(originOf(uri));
request.setNonce(nonce);
request.setClaimSecretHash(sha256Hex(claimSecret));
request.setStatus(ConnectRequest.Status.PENDING);
request.setMode(
pinnedTeamId == null ? ConnectRequest.Mode.LINK : ConnectRequest.Mode.REAUTH);
request.setTeamId(pinnedTeamId);
request.setRequesterIp(requesterIp);
request.setExpiresAt(now.plusMinutes(LIFETIME_MINUTES));
repo.save(request);
// Never log the nonce or the claim secret; both are live. The request id is the safe
// handle for correlating a support request against this row.
log.info(
"Account-link connect: request {} created for origin {}",
request.getRequestId(),
request.getCallbackOrigin());
return CreateResult.ok(request.getRequestId(), LIFETIME_MINUTES * 60);
}
/** The approver's view of a handshake. */
@Transactional(readOnly = true)
public Optional<ConnectView> lookup(String requestId) {
return repo.findByRequestId(requestId)
.filter(r -> !r.isExpired(LocalDateTime.now()))
.map(
r ->
new ConnectView(
r.getRequestId(),
r.getName(),
r.getCallbackOrigin(),
!"https".equals(schemeOf(r.getCallbackOrigin())),
r.getMode(),
r.getStatus()));
}
/** Why an approval was refused, so the page can say something useful. */
public enum ApproveRejection {
/** Unknown, expired, or already settled. */
UNAVAILABLE,
/** The approver's team is not the team this server already belongs to. */
WRONG_TEAM
}
public record ApproveResult(ApprovalTarget target, ApproveRejection rejection) {
public boolean isRejected() {
return target == null;
}
}
/** Binds a pending handshake to the approver's team and returns where to send them next. */
@Transactional
public ApproveResult approve(String requestId, Long teamId, Long userId) {
Optional<ConnectRequest> found = repo.findByRequestIdForUpdate(requestId);
if (found.isEmpty()) {
return new ApproveResult(null, ApproveRejection.UNAVAILABLE);
}
ConnectRequest request = found.get();
LocalDateTime now = LocalDateTime.now();
if (request.isExpired(now) || request.getStatus() != ConnectRequest.Status.PENDING) {
return new ApproveResult(null, ApproveRejection.UNAVAILABLE);
}
Long pinned = request.getTeamId();
if (pinned != null && !pinned.equals(teamId)) {
log.warn(
"Account-link connect: request {} approved by team {} but is pinned to team {};"
+ " refusing",
requestId,
teamId,
pinned);
return new ApproveResult(null, ApproveRejection.WRONG_TEAM);
}
request.setStatus(ConnectRequest.Status.APPROVED);
request.setTeamId(teamId);
request.setApprovedByUserId(userId);
request.setApprovedAt(now);
repo.save(request);
log.info(
"Account-link connect: request {} approved for team {} ({})",
requestId,
teamId,
request.getMode());
return new ApproveResult(
new ApprovalTarget(request.getCallbackUrl(), request.getNonce()), null);
}
/** Declines a pending handshake. */
@Transactional
public boolean deny(String requestId) {
Optional<ConnectRequest> found = repo.findByRequestIdForUpdate(requestId);
if (found.isEmpty()) {
return false;
}
ConnectRequest request = found.get();
if (request.getStatus() != ConnectRequest.Status.PENDING) {
return false;
}
request.setStatus(ConnectRequest.Status.DENIED);
repo.save(request);
log.info("Account-link connect: request {} denied", requestId);
return true;
}
/** Collects the device credential for an approved handshake. */
@Transactional
public ClaimResult claim(String requestId, String claimSecret) {
if (requestId == null || claimSecret == null) {
return ClaimResult.of(ClaimOutcome.REJECTED);
}
Optional<ConnectRequest> found = repo.findByRequestIdForUpdate(requestId);
if (found.isEmpty()) {
return ClaimResult.of(ClaimOutcome.REJECTED);
}
ConnectRequest request = found.get();
if (!secretMatches(claimSecret, request.getClaimSecretHash())) {
// Same answer as an unknown id: a caller probing ids learns nothing from the
// difference.
log.warn("Account-link connect: claim for request {} had a bad secret", requestId);
return ClaimResult.of(ClaimOutcome.REJECTED);
}
if (request.isExpired(LocalDateTime.now())) {
return ClaimResult.of(ClaimOutcome.REJECTED);
}
return switch (request.getStatus()) {
case PENDING -> ClaimResult.of(ClaimOutcome.PENDING);
case APPROVED -> mint(request);
case DENIED, CONSUMED -> ClaimResult.of(ClaimOutcome.REJECTED);
};
}
/** Settles an approved handshake. */
private ClaimResult mint(ConnectRequest request) {
if (request.getMode() == ConnectRequest.Mode.REAUTH) {
request.setStatus(ConnectRequest.Status.CONSUMED);
request.setConsumedAt(LocalDateTime.now());
repo.save(request);
log.info(
"Account-link connect: request {} re-authenticated for team {}",
request.getRequestId(),
request.getTeamId());
return new ClaimResult(ClaimOutcome.CONFIRMED, null, null, request.getTeamId());
}
AccountLinkService.RegisteredInstance registered =
accountLinkService.register(
request.getTeamId(), request.getApprovedByUserId(), request.getName());
request.setStatus(ConnectRequest.Status.CONSUMED);
request.setConsumedAt(LocalDateTime.now());
repo.save(request);
log.info(
"Account-link connect: request {} claimed, instance {} bound to team {}",
request.getRequestId(),
registered.instanceId(),
request.getTeamId());
return new ClaimResult(
ClaimOutcome.GRANTED,
registered.deviceId(),
registered.deviceSecret(),
request.getTeamId());
}
/** Absolute http(s) URL, with a host, no credentials and no fragment of its own. */
static Optional<URI> validateCallback(String candidate) {
if (candidate == null || candidate.isBlank() || candidate.length() > MAX_CALLBACK_LENGTH) {
return Optional.empty();
}
URI uri;
try {
uri = new URI(candidate.strip());
} catch (URISyntaxException e) {
return Optional.empty();
}
if (!uri.isAbsolute() || uri.getScheme() == null) {
return Optional.empty();
}
String scheme = uri.getScheme().toLowerCase(Locale.ROOT);
if (!"http".equals(scheme) && !"https".equals(scheme)) {
return Optional.empty();
}
if (uri.getHost() == null || uri.getHost().isBlank()) {
return Optional.empty();
}
if (uri.getUserInfo() != null || uri.getFragment() != null) {
return Optional.empty();
}
return Optional.of(uri);
}
/** Scheme, host and port, with the default port omitted so origins compare cleanly. */
static String originOf(URI uri) {
String scheme = uri.getScheme().toLowerCase(Locale.ROOT);
return scheme + "://" + Origins.hostPort(scheme, uri.getHost(), uri.getPort());
}
private static String schemeOf(String origin) {
int sep = origin.indexOf("://");
return sep < 0 ? "" : origin.substring(0, sep);
}
private static String trim(String value, int max) {
if (value == null) {
return null;
}
String stripped = value.strip();
if (stripped.isEmpty()) {
return null;
}
return stripped.length() <= max ? stripped : stripped.substring(0, max);
}
private String randomToken() {
byte[] buf = new byte[REQUEST_ID_BYTES];
random.nextBytes(buf);
return Base64.getUrlEncoder().withoutPadding().encodeToString(buf);
}
/** Constant-time comparison so a claim cannot be brute-forced a byte at a time. */
private static boolean secretMatches(String candidate, String expectedHash) {
if (expectedHash == null) {
return false;
}
return MessageDigest.isEqual(
sha256Hex(candidate).getBytes(StandardCharsets.UTF_8),
expectedHash.getBytes(StandardCharsets.UTF_8));
}
private static String sha256Hex(String value) {
return AccountLinkService.sha256Hex(value);
}
}
@@ -19,7 +19,7 @@ import jakarta.servlet.http.HttpServletResponse;
import lombok.extern.slf4j.Slf4j;
/**
* Authenticates a linked self-hosted instance by its device credential (combined-billing "Mode A").
* Authenticates a linked self-hosted instance by its device credential (combined billing).
*
* <p>Reads {@code X-Device-Id} + {@code X-Device-Secret}, looks up the active {@link
* LinkedInstance}, and constant-time compares the SHA-256 of the presented secret against the
@@ -32,9 +32,9 @@ import stirling.software.saas.payg.policy.PricingPolicy;
import stirling.software.saas.payg.policy.PricingPolicyService;
/**
* Instance-facing surface (combined-billing "Mode A"), authenticated by the <b>device
* credential</b> — not a user JWT. Separate path prefix ({@code /api/v1/instance/**}) so the device
* credential is scoped here and nowhere else.
* Instance-facing surface (combined billing), authenticated by the <b>device credential</b> — not a
* user JWT. Separate path prefix ({@code /api/v1/instance/**}) so the device credential is scoped
* here and nowhere else.
*
* <p>{@code GET /whoami} is the MVP round-trip proof: a registered instance presenting a valid
* device credential gets back its resolved {@code instanceId} + {@code teamId}. {@code GET
@@ -0,0 +1,68 @@
package stirling.software.saas.accountlink;
import java.util.List;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.context.annotation.Profile;
import org.springframework.http.HttpStatus;
import org.springframework.security.core.Authentication;
import org.springframework.stereotype.Component;
import stirling.software.common.model.enumeration.TeamRole;
import stirling.software.proprietary.model.TeamMembership;
import stirling.software.proprietary.security.database.repository.UserRepository;
import stirling.software.proprietary.security.model.User;
import stirling.software.proprietary.security.repository.TeamMembershipRepository;
import stirling.software.saas.util.AuthenticationUtils;
/** Who is allowed to bind a self-hosted instance to a team. */
@Component
@Profile("saas")
@ConditionalOnProperty(name = "stirling.billing.account-link.enabled", havingValue = "true")
public class LeaderTeamResolver {
private final TeamMembershipRepository memberRepo;
private final UserRepository userRepository;
public LeaderTeamResolver(TeamMembershipRepository memberRepo, UserRepository userRepository) {
this.memberRepo = memberRepo;
this.userRepository = userRepository;
}
/**
* Resolved caller, or an {@code error} status to return ({@code teamId}/{@code userId} null).
*/
public record LeaderTeam(Long teamId, Long userId, HttpStatus error) {
public boolean isError() {
return error != null;
}
}
/** Caller must lead their team. */
public LeaderTeam resolve(Authentication auth) {
return resolve(auth, true);
}
/** Caller need only belong to a team. */
public LeaderTeam resolveMember(Authentication auth) {
return resolve(auth, false);
}
private LeaderTeam resolve(Authentication auth, boolean requireLeader) {
User user;
try {
user = AuthenticationUtils.getCurrentUser(auth, userRepository);
} catch (SecurityException e) {
return new LeaderTeam(null, null, HttpStatus.UNAUTHORIZED);
}
List<TeamMembership> rows = memberRepo.findPrimaryMembership(user.getId());
if (rows.isEmpty()) {
return new LeaderTeam(null, null, HttpStatus.FORBIDDEN);
}
TeamMembership membership = rows.getFirst();
if (requireLeader && membership.getRole() != TeamRole.LEADER) {
return new LeaderTeam(null, null, HttpStatus.FORBIDDEN);
}
return new LeaderTeam(membership.getTeam().getId(), user.getId(), null);
}
}
@@ -16,7 +16,7 @@ import lombok.NoArgsConstructor;
import lombok.Setter;
/**
* One self-hosted instance that has linked a SaaS account (combined-billing "Mode A", {@code
* One self-hosted instance that has linked a SaaS account (combined billing, {@code
* linked_instance}, V22).
*
* <p>Created by {@code POST /api/v1/account-link/register}, authenticated with the admin's
@@ -6,7 +6,7 @@ import org.springframework.security.authentication.AbstractAuthenticationToken;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
/**
* Authentication for a linked self-hosted instance (combined-billing "Mode A").
* Authentication for a linked self-hosted instance (combined billing).
*
* <p>Deliberately <em>not</em> a user: the principal is the instance ({@code instanceId}) bound to
* a {@code teamId}, with the single authority {@code ROLE_LINKED_INSTANCE}. It carries no {@code
@@ -0,0 +1,22 @@
package stirling.software.saas.accountlink;
/**
* Origin formatting shared by the connect handshake.
*
* <p>One place on purpose: the origin a request arrives on and the origin parsed out of a callback
* URL are compared with each other, so if either side stopped omitting the default port the
* comparison would start failing quietly.
*/
final class Origins {
private Origins() {}
/** {@code host} or {@code host:port}, dropping a port that is the scheme's default. */
static String hostPort(String scheme, String host, int port) {
boolean isDefault =
port <= 0
|| ("http".equals(scheme) && port == 80)
|| ("https".equals(scheme) && port == 443);
return isDefault ? host : host + ":" + port;
}
}
@@ -41,6 +41,7 @@ public final class SaasSchemaOwnership {
*/
public static final Set<String> MIGRATION_OWNED =
Set.of(
"account_link_connect_request",
"ai_create_sessions",
"audit_events",
"authorities",
@@ -78,6 +79,7 @@ public final class SaasSchemaOwnership {
*/
public static final Set<String> HIBERNATE_MANAGED =
Set.of(
"account_link_connect_state",
"account_link_device_credential",
"account_link_metered_signature",
"account_link_sync_state",
@@ -18,12 +18,12 @@ import stirling.software.saas.payg.model.ProcessType;
import stirling.software.saas.payg.repository.PaygInstanceUsageRepository;
/**
* Ingests a linked instance's daily usage sync (combined-billing "Mode A"). The instance reports a
* monotonic cumulative unit total per {@link BillingCategory}; we bill only the delta since the
* last sync via {@link JobChargeService#chargeStandalone} (reusing the in-cloud free-grant split,
* ledger DEBIT, Stripe meter and idempotency). Idempotent (a resend → delta 0 → no charge) and
* tamper-evident (a backwards total is refused; a monotonic {@code syncSeq} dedups replays). The
* cap is enforced at the instance gate, not here. Gated behind {@code account-link.enabled}.
* Ingests a linked instance's daily usage sync (combined billing). The instance reports a monotonic
* cumulative unit total per {@link BillingCategory}; we bill only the delta since the last sync via
* {@link JobChargeService#chargeStandalone} (reusing the in-cloud free-grant split, ledger DEBIT,
* Stripe meter and idempotency). Idempotent (a resend → delta 0 → no charge) and tamper-evident (a
* backwards total is refused; a monotonic {@code syncSeq} dedups replays). The cap is enforced at
* the instance gate, not here. Gated behind {@code account-link.enabled}.
*/
@Slf4j
@Service
@@ -19,9 +19,9 @@ import lombok.Setter;
/**
* Last-seen cumulative usage a linked self-hosted instance has reported for one {@code (team,
* billing period, category)} (combined-billing "Mode A"). The instance reports monotonic cumulative
* unit totals on its daily sync; SaaS bills {@code reportedCumulative - lastCumulativeUnits} via
* the standard charge path and advances this row. {@code lastSyncSeq} dedups replays.
* billing period, category)} (combined billing). The instance reports monotonic cumulative unit
* totals on its daily sync; SaaS bills {@code reportedCumulative - lastCumulativeUnits} via the
* standard charge path and advances this row. {@code lastSyncSeq} dedups replays.
*/
@Entity
@Table(
@@ -16,6 +16,7 @@ import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Profile;
import org.springframework.core.annotation.Order;
import org.springframework.core.env.Environment;
import org.springframework.http.HttpMethod;
import org.springframework.security.authentication.AbstractAuthenticationToken;
import org.springframework.security.config.Customizer;
@@ -71,6 +72,7 @@ public class SupabaseSecurityConfig {
private final SaasTeamService saasTeamService;
private final ApplicationProperties applicationProperties;
private final ApiKeyAuthenticationService apiKeyAuthenticationService;
private final Environment environment;
@Value("${app.supabase.issuer:}")
private String issuer;
@@ -105,6 +107,17 @@ public class SupabaseSecurityConfig {
.permitAll()
.requestMatchers("/actuator/health", "/api/v1/config/**")
.permitAll()
// Account-link connect handshake: an instance calls these
// before it holds any credential, so there is nothing to
// authenticate with yet. Neither grants anything on its
// own — /request records an intent a human must approve,
// and /claim requires a secret only the instance that
// created the request has ever held.
.requestMatchers(
HttpMethod.POST,
"/api/v1/account-link/connect/request",
"/api/v1/account-link/connect/claim")
.permitAll()
.requestMatchers(
req ->
RequestUriUtils.isStaticResource(
@@ -144,7 +157,7 @@ public class SupabaseSecurityConfig {
SupabaseSecurityConfig
::toAuthentication)));
// Device-credential auth for linked self-hosted instances (combined-billing Mode A).
// Device-credential auth for linked self-hosted instances (combined billing).
// The filter bean exists only when stirling.billing.account-link.enabled=true; when off it
// is absent here, so the instance surface cannot authenticate at all until release.
DeviceCredentialAuthenticationFilter deviceFilter =
@@ -268,6 +281,28 @@ public class SupabaseSecurityConfig {
}
}
/**
* Loopback on any port, as Spring origin patterns. Only added outside production; see {@link
* #corsConfigurationSource()}.
*/
private static final List<String> LOOPBACK_ANY_PORT =
List.of("http://localhost:[*]", "http://127.0.0.1:[*]");
/**
* Profiles that mean "a developer's machine or a preview environment", never the production
* deployment. Production runs the bare {@code saas} profile.
*/
private static final List<String> NON_PRODUCTION_PROFILES = List.of("dev", "staging", "local");
private boolean isNonProduction() {
for (String profile : environment.getActiveProfiles()) {
if (NON_PRODUCTION_PROFILES.contains(profile)) {
return true;
}
}
return false;
}
@Bean
CorsConfigurationSource corsConfigurationSource() {
CorsConfiguration cfg = new CorsConfiguration();
@@ -297,7 +332,23 @@ public class SupabaseSecurityConfig {
origins.add(desktopOrigin);
}
}
if (origins.stream().anyMatch(o -> o.contains("*"))) {
// Outside production, allow loopback on ANY port. Several dev servers run side by side
// (editor, saas web app, one per flavour under test) and their ports move, so pinning a
// list means every new local environment shows up as an opaque CORS failure. Unlike a
// wildcard subdomain, a wildcard port on loopback cannot be taken over: nothing but this
// machine can answer on it, so there is no lapsed-DNS or abandoned-vhost risk. Absent in
// production, where the profile check below is false.
if (!operatorOverride && isNonProduction()) {
origins.addAll(LOOPBACK_ANY_PORT);
log.info(
"Non-production profile active: allowing loopback CORS origins on any port {}",
LOOPBACK_ANY_PORT);
}
// Loopback port wildcards are exempt: the warning below is about hostname takeover, which
// does not apply to an origin only this machine can serve.
if (origins.stream()
.filter(o -> !LOOPBACK_ANY_PORT.contains(o))
.anyMatch(o -> o.contains("*"))) {
log.warn(
"CORS origins contain a wildcard paired with allowCredentials=true: {}."
+ " Wildcard subdomains can be taken over by an attacker (lapsed DNS,"
@@ -519,8 +519,8 @@ public class SaasTeamService {
* membership and its wallet) rather than deleting it, so a plain team is never orphaned. The
* only real hazard is a team the user is the <em>last</em> leader of that still carries live
* billing: an active paid/PAYG subscription, or a non-revoked linked self-hosted instance
* ("Mode A"). Those block the join until the plan is cancelled / leadership transferred /
* instances revoked. An unpaid, unlinked team (personal or shared) no longer blocks.
* (combined billing). Those block the join until the plan is cancelled / leadership transferred
* / instances revoked. An unpaid, unlinked team (personal or shared) no longer blocks.
*
* <p>The home team and the team being joined are excluded: neither is left by the join (home is
* parked, the joined team is kept), so their live billing cannot be stranded.
@@ -1,6 +1,7 @@
package stirling.software.saas.accountlink;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyNoInteractions;
import static org.mockito.Mockito.when;
@@ -23,8 +24,7 @@ import stirling.software.proprietary.model.TeamMembership;
import stirling.software.proprietary.security.database.repository.UserRepository;
import stirling.software.proprietary.security.model.User;
import stirling.software.proprietary.security.repository.TeamMembershipRepository;
import stirling.software.saas.accountlink.AccountLinkController.RegisterRequest;
import stirling.software.saas.accountlink.AccountLinkController.RegisterResponse;
import stirling.software.saas.accountlink.AccountLinkController.InstanceRow;
import stirling.software.saas.util.AuthenticationUtils;
/**
@@ -44,20 +44,27 @@ class AccountLinkControllerTest {
@BeforeEach
void setUp() {
controller = new AccountLinkController(service, memberRepo, userRepository);
// Real resolver over the mocked repositories: the leader ladder moved into
// LeaderTeamResolver, and these tests are still asserting that ladder's behaviour
// through the controller.
controller =
new AccountLinkController(
service, new LeaderTeamResolver(memberRepo, userRepository));
auth =
new AnonymousAuthenticationToken(
"k", "anonymousUser", List.of(new SimpleGrantedAuthority("ROLE_USER")));
}
// The leader ladder used to be asserted through POST /register, which has been removed along
// with the JWT relay. It is exercised through /instances instead: same resolver, same rungs.
@Test
void register_unauthenticated_returns401() {
void list_unauthenticated_returns401() {
try (var mocked = org.mockito.Mockito.mockStatic(AuthenticationUtils.class)) {
mocked.when(() -> AuthenticationUtils.getCurrentUser(auth, userRepository))
.thenThrow(new SecurityException("not authenticated"));
ResponseEntity<RegisterResponse> resp =
controller.register(new RegisterRequest("host"), auth);
ResponseEntity<List<InstanceRow>> resp = controller.list(auth);
assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED);
verifyNoInteractions(service);
@@ -65,14 +72,14 @@ class AccountLinkControllerTest {
}
@Test
void register_noMembership_returns403() {
void list_noMembership_returns403() {
User user = mockUser(42L);
try (var mocked = org.mockito.Mockito.mockStatic(AuthenticationUtils.class)) {
mocked.when(() -> AuthenticationUtils.getCurrentUser(auth, userRepository))
.thenReturn(user);
when(memberRepo.findPrimaryMembership(42L)).thenReturn(List.of());
ResponseEntity<RegisterResponse> resp = controller.register(null, auth);
ResponseEntity<List<InstanceRow>> resp = controller.list(auth);
assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.FORBIDDEN);
verifyNoInteractions(service);
@@ -80,7 +87,7 @@ class AccountLinkControllerTest {
}
@Test
void register_nonLeader_returns403() {
void list_nonLeader_returns403() {
User user = mockUser(42L);
TeamMembership member = membership(7L, TeamRole.MEMBER);
try (var mocked = org.mockito.Mockito.mockStatic(AuthenticationUtils.class)) {
@@ -88,7 +95,7 @@ class AccountLinkControllerTest {
.thenReturn(user);
when(memberRepo.findPrimaryMembership(42L)).thenReturn(List.of(member));
ResponseEntity<RegisterResponse> resp = controller.register(null, auth);
ResponseEntity<List<InstanceRow>> resp = controller.list(auth);
assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.FORBIDDEN);
verifyNoInteractions(service);
@@ -96,27 +103,20 @@ class AccountLinkControllerTest {
}
@Test
void register_leader_mintsCredentialForCallerTeam() {
void list_leader_readsOnlyTheCallersTeam() {
User user = mockUser(42L);
TeamMembership leader = membership(7L, TeamRole.LEADER);
when(service.register(7L, 42L, "host"))
.thenReturn(
new AccountLinkService.RegisteredInstance(99L, "dev-x", "sec-x", "host"));
when(service.list(7L)).thenReturn(List.of());
try (var mocked = org.mockito.Mockito.mockStatic(AuthenticationUtils.class)) {
mocked.when(() -> AuthenticationUtils.getCurrentUser(auth, userRepository))
.thenReturn(user);
when(memberRepo.findPrimaryMembership(42L)).thenReturn(List.of(leader));
ResponseEntity<RegisterResponse> resp =
controller.register(new RegisterRequest("host"), auth);
ResponseEntity<List<InstanceRow>> resp = controller.list(auth);
assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.CREATED);
RegisterResponse body = resp.getBody();
assertThat(body).isNotNull();
// Team comes from the caller's membership and is surfaced in the response.
assertThat(body.teamId()).isEqualTo(7L);
assertThat(body.instanceId()).isEqualTo(99L);
assertThat(body.deviceSecret()).isEqualTo("sec-x");
assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.OK);
// The team comes from the caller's membership, never from the request.
verify(service).list(7L);
}
}
@@ -0,0 +1,129 @@
package stirling.software.saas.accountlink;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.isNull;
import static org.mockito.Mockito.when;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.mockito.junit.jupiter.MockitoSettings;
import org.mockito.quality.Strictness;
import org.springframework.mock.web.MockHttpServletRequest;
import stirling.software.common.model.ApplicationProperties;
import stirling.software.saas.accountlink.ConnectController.CreateBody;
import stirling.software.saas.accountlink.ConnectController.CreateResponse;
/**
* The authorize URL the instance is told to send its admin to. Everything else on this controller
* delegates; this is the only decision it makes on its own.
*/
@ExtendWith(MockitoExtension.class)
@MockitoSettings(strictness = Strictness.LENIENT)
class ConnectControllerTest {
private static final CreateBody BODY =
new CreateBody("prod-1", "https://pdf.example.com/account-link/callback", "n", "s");
@Mock private ConnectRequestService service;
@Mock private LeaderTeamResolver leaderTeams;
@Mock private AccountLinkService accountLinkService;
private ApplicationProperties applicationProperties;
private ConnectController controller;
@BeforeEach
void setUp() {
applicationProperties = new ApplicationProperties();
controller =
new ConnectController(
service, leaderTeams, accountLinkService, applicationProperties);
when(service.create(anyString(), anyString(), anyString(), anyString(), any()))
.thenReturn(ConnectRequestService.CreateResult.ok("req-1", 1800));
}
private String authorizeUrl(MockHttpServletRequest request) {
Object body = controller.request(BODY, request).getBody();
assertThat(body).isInstanceOf(CreateResponse.class);
return ((CreateResponse) body).authorizeUrl();
}
private static MockHttpServletRequest request(String scheme, String host, int port) {
MockHttpServletRequest request = new MockHttpServletRequest();
request.setScheme(scheme);
request.setServerName(host);
request.setServerPort(port);
return request;
}
@Test
void prefersTheConfiguredFrontendUrl() {
applicationProperties.getSystem().setFrontendUrl("https://app.example.com/app/");
// Trailing slash trimmed, base path kept, and the API's own origin ignored.
assertThat(authorizeUrl(request("https", "api.example.com", 443)))
.isEqualTo("https://app.example.com/app/link?request=req-1");
}
@Test
void fallsBackToTheOriginTheApiWasReachedOn() {
assertThat(authorizeUrl(request("https", "api.example.com", 443)))
.isEqualTo("https://api.example.com/link?request=req-1");
}
@Test
void keepsANonDefaultPortAndTheContextPath() {
MockHttpServletRequest request = request("http", "localhost", 8081);
request.setContextPath("/stirling");
assertThat(authorizeUrl(request))
.isEqualTo("http://localhost:8081/stirling/link?request=req-1");
}
@Test
void honoursTheForwardedSchemeAndHost() {
MockHttpServletRequest request = request("http", "10.0.0.5", 8080);
request.addHeader("X-Forwarded-Proto", "https");
request.addHeader("X-Forwarded-Host", "api.example.com");
assertThat(authorizeUrl(request)).isEqualTo("https://api.example.com/link?request=req-1");
}
@Test
void takesOnlyTheFirstForwardedHop() {
MockHttpServletRequest request = request("http", "10.0.0.5", 8080);
request.addHeader("X-Forwarded-Proto", "https, http");
request.addHeader("X-Forwarded-Host", "api.example.com, evil.example.com");
assertThat(authorizeUrl(request)).isEqualTo("https://api.example.com/link?request=req-1");
}
@Test
void percentEncodesTheRequestId() {
when(service.create(anyString(), anyString(), anyString(), anyString(), any()))
.thenReturn(ConnectRequestService.CreateResult.ok("a b&c", 1800));
assertThat(authorizeUrl(request("https", "api.example.com", 443)))
.isEqualTo("https://api.example.com/link?request=a+b%26c");
}
@Test
void aBodylessRequestIsRejectedBeforeAnythingIsRecorded() {
assertThat(controller.request(null, request("https", "api.example.com", 443)).getBody())
.isEqualTo(java.util.Map.of("error", "BAD_REQUEST"));
}
@Test
void offeringNoCredentialTakesTheFirstLinkPath() {
authorizeUrl(request("https", "api.example.com", 443));
// createReauth is the credentialled path; a first link must not reach it.
org.mockito.Mockito.verify(service, org.mockito.Mockito.never())
.createReauth(anyString(), anyString(), anyString(), anyString(), any(), isNull());
}
}
@@ -0,0 +1,358 @@
package stirling.software.saas.accountlink;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyNoInteractions;
import static org.mockito.Mockito.when;
import java.time.LocalDateTime;
import java.util.Optional;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ValueSource;
import org.mockito.ArgumentCaptor;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.mockito.junit.jupiter.MockitoSettings;
import org.mockito.quality.Strictness;
import stirling.software.saas.accountlink.ConnectRequestService.ClaimOutcome;
import stirling.software.saas.accountlink.ConnectRequestService.CreateRejection;
/**
* Unit tests for the connect handshake's security properties, which are the reason this flow is
* safe rather than an open redirect: the callback is validated once and then read back from
* storage, the claim secret authenticates the collection, and one approval mints exactly one
* credential.
*/
@ExtendWith(MockitoExtension.class)
@MockitoSettings(strictness = Strictness.LENIENT)
class ConnectRequestServiceTest {
private static final String CALLBACK = "https://pdf.example.com/account-link/callback";
private static final String NONCE = "nonce-value";
private static final String CLAIM_SECRET = "claim-secret-value";
@Mock private ConnectRequestRepository repo;
@Mock private AccountLinkService accountLinkService;
private ConnectRequestService service;
@BeforeEach
void setUp() {
service = new ConnectRequestService(repo, accountLinkService);
}
@Test
void create_storesTheValidatedCallbackAndItsOrigin() {
ConnectRequestService.CreateResult result =
service.create("prod-1", CALLBACK, NONCE, CLAIM_SECRET, "10.0.0.1");
assertThat(result.isRejected()).isFalse();
assertThat(result.requestId()).isNotBlank();
ArgumentCaptor<ConnectRequest> saved = ArgumentCaptor.forClass(ConnectRequest.class);
verify(repo).save(saved.capture());
ConnectRequest row = saved.getValue();
assertThat(row.getCallbackUrl()).isEqualTo(CALLBACK);
assertThat(row.getCallbackOrigin()).isEqualTo("https://pdf.example.com");
assertThat(row.getStatus()).isEqualTo(ConnectRequest.Status.PENDING);
assertThat(row.getName()).isEqualTo("prod-1");
// The claim secret is only ever stored as a hash.
assertThat(row.getClaimSecretHash()).isNotEqualTo(CLAIM_SECRET).hasSize(64);
}
@Test
void create_keepsANonDefaultPortInTheOrigin() {
service.create(
null, "http://pdf.internal:8080/account-link/callback", NONCE, CLAIM_SECRET, null);
ArgumentCaptor<ConnectRequest> saved = ArgumentCaptor.forClass(ConnectRequest.class);
verify(repo).save(saved.capture());
assertThat(saved.getValue().getCallbackOrigin()).isEqualTo("http://pdf.internal:8080");
}
@ParameterizedTest
@ValueSource(
strings = {
"/account-link/callback", // not absolute
"ftp://pdf.example.com/cb", // wrong scheme
"javascript:alert(1)", // not a hierarchical http(s) URL
"https://user:pw@pdf.example.com/cb", // credentials in the URL
"https://pdf.example.com/cb#already", // would collide with our fragment
"https:///cb" // no host
})
void create_refusesCallbacksWeWouldNotWantToRedirectTo(String callback) {
ConnectRequestService.CreateResult result =
service.create(null, callback, NONCE, CLAIM_SECRET, null);
assertThat(result.rejection()).isEqualTo(CreateRejection.BAD_CALLBACK);
verify(repo, never()).save(any());
}
@Test
void create_refusesAMissingNonce() {
assertThat(service.create(null, CALLBACK, " ", CLAIM_SECRET, null).rejection())
.isEqualTo(CreateRejection.BAD_NONCE);
verify(repo, never()).save(any());
}
@Test
void create_namesTheSecretWhenTheSecretIsWhatIsMissing() {
assertThat(service.create(null, CALLBACK, NONCE, " ", null).rejection())
.isEqualTo(CreateRejection.BAD_SECRET);
verify(repo, never()).save(any());
}
@Test
void create_isCappedPerSourceAddress() {
when(repo.countByRequesterIpAndCreatedAtAfter(anyString(), any()))
.thenReturn((long) ConnectRequestService.MAX_REQUESTS_PER_IP);
ConnectRequestService.CreateResult result =
service.create(null, CALLBACK, NONCE, CLAIM_SECRET, "10.0.0.1");
assertThat(result.rejection()).isEqualTo(CreateRejection.RATE_LIMITED);
verify(repo, never()).save(any());
}
@Test
void lookup_flagsPlaintextTransportSoTheApproverCanSeeIt() {
ConnectRequest row = pending();
row.setCallbackOrigin("http://pdf.internal:8080");
when(repo.findByRequestId("req")).thenReturn(Optional.of(row));
assertThat(service.lookup("req")).get().extracting("insecureTransport").isEqualTo(true);
}
@Test
void lookup_hidesAnExpiredHandshake() {
ConnectRequest row = pending();
row.setExpiresAt(LocalDateTime.now().minusMinutes(1));
when(repo.findByRequestId("req")).thenReturn(Optional.of(row));
assertThat(service.lookup("req")).isEmpty();
}
@Test
void approve_bindsTheTeamAndReturnsTheStoredCallback() {
ConnectRequest row = pending();
when(repo.findByRequestIdForUpdate("req")).thenReturn(Optional.of(row));
ConnectRequestService.ApproveResult result = service.approve("req", 7L, 42L);
assertThat(result.isRejected()).isFalse();
// The destination comes from the row, never from the caller.
assertThat(result.target().callbackUrl()).isEqualTo(CALLBACK);
assertThat(result.target().nonce()).isEqualTo(NONCE);
assertThat(row.getStatus()).isEqualTo(ConnectRequest.Status.APPROVED);
assertThat(row.getTeamId()).isEqualTo(7L);
assertThat(row.getApprovedByUserId()).isEqualTo(42L);
// Approval on its own must not mint anything.
verifyNoInteractions(accountLinkService);
}
@Test
void approve_isSingleUse() {
ConnectRequest row = pending();
row.setStatus(ConnectRequest.Status.APPROVED);
when(repo.findByRequestIdForUpdate("req")).thenReturn(Optional.of(row));
assertThat(service.approve("req", 7L, 42L).isRejected()).isTrue();
}
@Test
void approve_refusesAnExpiredHandshake() {
ConnectRequest row = pending();
row.setExpiresAt(LocalDateTime.now().minusSeconds(1));
when(repo.findByRequestIdForUpdate("req")).thenReturn(Optional.of(row));
assertThat(service.approve("req", 7L, 42L).isRejected()).isTrue();
}
@Test
void createReauth_pinsTheTeamItWasToldByTheCredential() {
ConnectRequestService.CreateResult result =
service.createReauth(null, CALLBACK, NONCE, CLAIM_SECRET, null, 7L);
assertThat(result.isRejected()).isFalse();
ArgumentCaptor<ConnectRequest> saved = ArgumentCaptor.forClass(ConnectRequest.class);
verify(repo).save(saved.capture());
assertThat(saved.getValue().getMode()).isEqualTo(ConnectRequest.Mode.REAUTH);
assertThat(saved.getValue().getTeamId()).isEqualTo(7L);
}
@Test
void createReauth_withoutAnAuthenticatedInstanceIsRefused() {
// The controller passes null when the offered device credential did not authenticate.
assertThat(
service.createReauth(null, CALLBACK, NONCE, CLAIM_SECRET, null, null)
.rejection())
.isEqualTo(CreateRejection.NOT_LINKED);
verify(repo, never()).save(any());
}
@Test
void create_leavesTheTeamOpenForAFirstLink() {
service.create("n", CALLBACK, NONCE, CLAIM_SECRET, null);
ArgumentCaptor<ConnectRequest> saved = ArgumentCaptor.forClass(ConnectRequest.class);
verify(repo).save(saved.capture());
assertThat(saved.getValue().getMode()).isEqualTo(ConnectRequest.Mode.LINK);
// Approval is what decides the team on a first link.
assertThat(saved.getValue().getTeamId()).isNull();
}
@Test
void approve_refusesAnApproverFromADifferentTeam() {
ConnectRequest row = reauthPinnedTo(7L);
when(repo.findByRequestIdForUpdate("req")).thenReturn(Optional.of(row));
ConnectRequestService.ApproveResult result = service.approve("req", 99L, 42L);
// This is the "signed in to the wrong account" case, and it must not silently rebind.
assertThat(result.rejection()).isEqualTo(ConnectRequestService.ApproveRejection.WRONG_TEAM);
assertThat(row.getStatus()).isEqualTo(ConnectRequest.Status.PENDING);
assertThat(row.getTeamId()).isEqualTo(7L);
}
@Test
void approve_acceptsTheTeamTheServerAlreadyBelongsTo() {
ConnectRequest row = reauthPinnedTo(7L);
when(repo.findByRequestIdForUpdate("req")).thenReturn(Optional.of(row));
assertThat(service.approve("req", 7L, 42L).isRejected()).isFalse();
assertThat(row.getStatus()).isEqualTo(ConnectRequest.Status.APPROVED);
}
@Test
void claim_onAReauthConfirmsWithoutMintingASecondCredential() {
ConnectRequest row = reauthPinnedTo(7L);
row.setStatus(ConnectRequest.Status.APPROVED);
row.setApprovedByUserId(42L);
when(repo.findByRequestIdForUpdate("req")).thenReturn(Optional.of(row));
ConnectRequestService.ClaimResult result = service.claim("req", CLAIM_SECRET);
assertThat(result.outcome()).isEqualTo(ClaimOutcome.CONFIRMED);
assertThat(result.deviceId()).isNull();
assertThat(result.deviceSecret()).isNull();
assertThat(result.teamId()).isEqualTo(7L);
assertThat(row.getStatus()).isEqualTo(ConnectRequest.Status.CONSUMED);
// A second credential would orphan the one the instance already holds.
verifyNoInteractions(accountLinkService);
}
@Test
void claim_mintsOnceForAnApprovedHandshake() {
ConnectRequest row = approved();
when(repo.findByRequestIdForUpdate("req")).thenReturn(Optional.of(row));
when(accountLinkService.register(anyLong(), anyLong(), any()))
.thenReturn(
new AccountLinkService.RegisteredInstance(9L, "dev-id", "dev-secret", "n"));
ConnectRequestService.ClaimResult result = service.claim("req", CLAIM_SECRET);
assertThat(result.outcome()).isEqualTo(ClaimOutcome.GRANTED);
assertThat(result.deviceId()).isEqualTo("dev-id");
assertThat(result.deviceSecret()).isEqualTo("dev-secret");
assertThat(result.teamId()).isEqualTo(7L);
assertThat(row.getStatus()).isEqualTo(ConnectRequest.Status.CONSUMED);
verify(accountLinkService).register(7L, 42L, "n");
}
@Test
void claim_refusesASecondCollection() {
ConnectRequest row = approved();
row.setStatus(ConnectRequest.Status.CONSUMED);
when(repo.findByRequestIdForUpdate("req")).thenReturn(Optional.of(row));
assertThat(service.claim("req", CLAIM_SECRET).outcome()).isEqualTo(ClaimOutcome.REJECTED);
verifyNoInteractions(accountLinkService);
}
@Test
void claim_withTheWrongSecretMintsNothing() {
ConnectRequest row = approved();
when(repo.findByRequestIdForUpdate("req")).thenReturn(Optional.of(row));
assertThat(service.claim("req", "not-the-secret").outcome())
.isEqualTo(ClaimOutcome.REJECTED);
assertThat(row.getStatus()).isEqualTo(ConnectRequest.Status.APPROVED);
verifyNoInteractions(accountLinkService);
}
@Test
void claim_beforeApprovalTellsTheInstanceToWait() {
when(repo.findByRequestIdForUpdate("req")).thenReturn(Optional.of(pending()));
assertThat(service.claim("req", CLAIM_SECRET).outcome()).isEqualTo(ClaimOutcome.PENDING);
verifyNoInteractions(accountLinkService);
}
@Test
void claim_afterDenialIsTerminal() {
ConnectRequest row = pending();
row.setStatus(ConnectRequest.Status.DENIED);
when(repo.findByRequestIdForUpdate("req")).thenReturn(Optional.of(row));
assertThat(service.claim("req", CLAIM_SECRET).outcome()).isEqualTo(ClaimOutcome.REJECTED);
verifyNoInteractions(accountLinkService);
}
@Test
void claim_onAnExpiredHandshakeMintsNothing() {
ConnectRequest row = approved();
row.setExpiresAt(LocalDateTime.now().minusSeconds(1));
when(repo.findByRequestIdForUpdate("req")).thenReturn(Optional.of(row));
assertThat(service.claim("req", CLAIM_SECRET).outcome()).isEqualTo(ClaimOutcome.REJECTED);
verifyNoInteractions(accountLinkService);
}
@Test
void claim_forAnUnknownIdLooksTheSameAsABadSecret() {
when(repo.findByRequestIdForUpdate("nope")).thenReturn(Optional.empty());
assertThat(service.claim("nope", CLAIM_SECRET).outcome()).isEqualTo(ClaimOutcome.REJECTED);
}
// ---------------------------------------------------------------------------------------
private static ConnectRequest pending() {
ConnectRequest row = new ConnectRequest();
row.setRequestId("req");
row.setName("n");
row.setCallbackUrl(CALLBACK);
row.setCallbackOrigin("https://pdf.example.com");
row.setNonce(NONCE);
row.setClaimSecretHash(AccountLinkService.sha256Hex(CLAIM_SECRET));
row.setStatus(ConnectRequest.Status.PENDING);
row.setExpiresAt(LocalDateTime.now().plusMinutes(10));
return row;
}
/** A re-authentication whose team came from the instance's credential, not from a browser. */
private static ConnectRequest reauthPinnedTo(Long teamId) {
ConnectRequest row = pending();
row.setMode(ConnectRequest.Mode.REAUTH);
row.setTeamId(teamId);
return row;
}
private static ConnectRequest approved() {
ConnectRequest row = pending();
row.setStatus(ConnectRequest.Status.APPROVED);
row.setTeamId(7L);
row.setApprovedByUserId(42L);
row.setApprovedAt(LocalDateTime.now());
return row;
}
}
@@ -14,6 +14,8 @@ import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.core.env.Environment;
import org.springframework.mock.env.MockEnvironment;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.oauth2.jwt.Jwt;
import org.springframework.security.oauth2.jwt.JwtDecoder;
@@ -48,13 +50,19 @@ class SupabaseSecurityConfigMoreTest {
apiKeyAuthenticationService;
private SupabaseSecurityConfig config(ApplicationProperties props) {
return config(props, new MockEnvironment());
}
/** Loopback CORS origins are only added outside production, so the environment decides. */
private SupabaseSecurityConfig config(ApplicationProperties props, Environment environment) {
return new SupabaseSecurityConfig(
userService,
teamService,
supabaseUserService,
saasTeamService,
props,
apiKeyAuthenticationService);
apiKeyAuthenticationService,
environment);
}
@Nested
@@ -204,6 +212,50 @@ class SupabaseSecurityConfigMoreTest {
.hasSize(1);
}
@Test
@DisplayName("production does not allow loopback on arbitrary ports")
void productionHasNoLoopbackWildcard() {
CorsConfiguration cfg =
cors(config(new ApplicationProperties()).corsConfigurationSource());
assertThat(cfg.getAllowedOriginPatterns())
.doesNotContain("http://localhost:[*]", "http://127.0.0.1:[*]");
}
@Test
@DisplayName("non-production allows loopback on any port so dev servers can move")
void devAllowsAnyLoopbackPort() {
// Several dev servers run side by side and their ports change; pinning a list turns
// every new local environment into an opaque CORS failure.
MockEnvironment dev = new MockEnvironment();
dev.setActiveProfiles("saas", "dev");
CorsConfiguration cfg =
cors(config(new ApplicationProperties(), dev).corsConfigurationSource());
assertThat(cfg.getAllowedOriginPatterns())
.contains("http://localhost:[*]", "http://127.0.0.1:[*]")
// Still credentialed, which is the reason the pattern form matters.
.contains("https://stirling.com");
assertThat(cfg.getAllowCredentials()).isTrue();
}
@Test
@DisplayName("an operator origin list is respected verbatim even in dev")
void operatorOverrideSuppressesLoopbackWildcard() {
ApplicationProperties props = new ApplicationProperties();
props.getSystem().setCorsAllowedOrigins(List.of("https://custom.example.com"));
MockEnvironment dev = new MockEnvironment();
dev.setActiveProfiles("saas", "dev");
CorsConfiguration cfg = cors(config(props, dev).corsConfigurationSource());
// An operator who set the list meant it; we do not widen it behind their back.
assertThat(cfg.getAllowedOriginPatterns())
.contains("https://custom.example.com")
.doesNotContain("http://localhost:[*]");
}
@Test
@DisplayName("operator override replaces the default origin list")
void operatorOverrideUsed() {
+9 -3
View File
@@ -42,7 +42,7 @@ ext {
bucket4jVersion = "8.19.0"
archunitVersion = "1.4.2"
batikVersion = "1.19"
jpdfiumVersion = "1.0.4"
jpdfiumVersion = "1.1.3"
jwtVersion = "0.13.0"
awsSdkVersion = "2.51.3"
jschVersion = "2.28.6"
@@ -265,7 +265,6 @@ subprojects {
dependencies {
implementation 'org.springframework.boot:spring-boot-starter-actuator'
implementation 'io.github.pixee:java-security-toolkit:1.2.3'
//tmp for security bumps
implementation "ch.qos.logback:logback-core:$logback"
@@ -307,7 +306,7 @@ subprojects {
systemProperty 'apple.awt.UIElement', 'true'
testLogging {
events "started", "failed"
events "skipped", "failed"
showExceptions = true
showCauses = true
showStackTraces = true
@@ -543,6 +542,13 @@ subprojects {
}
}
// Lazy initialization defers bean creation until first use,
// reducing dev-mode RSS significantly (heap drops ~40-60%).
// Enable with: ./gradlew bootRun -PlazyInit=true
if (rootProject.findProperty('lazyInit') == 'true') {
runtimeArgs.add("-Dspring.main.lazy-initialization=true")
logger.lifecycle("Lazy initialization enabled (-PlazyInit=true)")
}
jvmArgs = runtimeArgs
}
}
@@ -3088,6 +3088,293 @@ summary_one = "Ran 1 tool"
summary_other = "Ran {{count}} tools"
unknownTool = "Unknown tool"
[classification.families]
correspondence = "Correspondence"
education = "Education"
engineering = "Engineering"
finance = "Financial"
forms = "Forms"
government = "Government"
health = "Medical"
hr = "HR"
legal = "Legal"
operations = "Operations"
projects = "Projects"
property = "Property"
reports = "Reports"
sales = "Marketing"
travel = "Travel"
[classification.labels]
academic-record = "Academic record"
action-plan = "Action plan"
addendum = "Addendum"
advertisement = "Advertisement"
affidavit = "Affidavit"
agenda = "Agenda"
amendment = "Amendment"
analytics-report = "Analytics report"
announcement = "Announcement"
annual-report = "Annual report"
api-documentation = "API documentation"
application-form = "Application form"
appraisal-report = "Appraisal report"
architecture-document = "Architecture document"
articles-of-incorporation = "Articles of incorporation"
assignment-brief = "Assignment brief"
audit-report = "Audit report"
balance-sheet = "Balance sheet"
bank-statement = "Bank statement"
benefits-summary = "Benefits summary"
bill-of-lading = "Bill of lading"
bill-of-materials = "Bill of materials"
blueprint = "Blueprint"
board-report = "Board report"
board-resolution = "Board resolution"
booking-confirmation = "Booking confirmation"
brochure = "Brochure"
budget = "Budget"
business-plan = "Business plan"
business-proposal = "Business proposal"
bylaws = "Bylaws"
campaign-brief = "Campaign brief"
case-study = "Case study"
cash-flow-statement = "Cash flow statement"
catalog = "Catalog"
cease-and-desist = "Cease and desist"
certificate = "Certificate"
certificate-of-completion = "Certificate of completion"
change-log = "Change log"
checklist = "Checklist"
claim-form = "Claim form"
closing-statement = "Closing statement"
complaint-letter = "Complaint letter"
compliance-document = "Compliance document"
confirmation-letter = "Confirmation letter"
consent-form = "Consent form"
contract = "Contract"
course-syllabus = "Course syllabus"
court-filing = "Court filing"
cover-letter = "Cover letter"
credit-note = "Credit note"
customs-declaration = "Customs declaration"
customs-form = "Customs form"
cv = "CV"
datasheet = "Datasheet"
debit-note = "Debit note"
deed = "Deed"
delivery-note = "Delivery note"
demand-letter = "Demand letter"
design-document = "Design document"
diploma = "Diploma"
discharge-summary = "Discharge summary"
dissertation = "Dissertation"
donation-receipt = "Donation receipt"
dunning-letter = "Dunning letter"
email-thread = "Email thread"
employee-handbook = "Employee handbook"
employment-contract = "Employment contract"
estimate = "Estimate"
event-agenda = "Event agenda"
event-program = "Event program"
eviction-notice = "Eviction notice"
exam-paper = "Exam paper"
expense-report = "Expense report"
expense-summary = "Expense summary"
explanation-of-benefits = "Explanation of benefits"
fact-sheet = "Fact sheet"
faq-document = "FAQ document"
feasibility-study = "Feasibility study"
feedback-form = "Feedback form"
financial-forecast = "Financial forecast"
financial-statement = "Financial statement"
floor-plan = "Floor plan"
flyer = "Flyer"
form = "Form"
franchise-agreement = "Franchise agreement"
freight-document = "Freight document"
gift-certificate = "Gift certificate"
glossary = "Glossary"
government-notice = "Government notice"
grade-report = "Grade report"
grant-agreement = "Grant agreement"
grant-application = "Grant application"
hoa-document = "HOA document"
home-inspection-report = "Home inspection report"
hr-memo = "HR memo"
hr-policy = "HR policy"
immigration-document = "Immigration document"
immunization-record = "Immunization record"
incident-report = "Incident report"
income-statement = "Income statement"
index = "Index"
inspection-report = "Inspection report"
insurance-certificate = "Insurance certificate"
insurance-claim = "Insurance claim"
insurance-policy = "Insurance policy"
intake-form = "Intake form"
inventory-list = "Inventory list"
investment-summary = "Investment summary"
invitation = "Invitation"
invoice = "Invoice"
itinerary = "Itinerary"
job-application = "Job application"
job-description = "Job description"
lab-report = "Lab report"
lease-agreement = "Lease agreement"
leave-request = "Leave request"
legal-brief = "Legal brief"
legal-filing = "Legal filing"
legal-notice = "Legal notice"
legal-opinion = "Legal opinion"
lesson-plan = "Lesson plan"
letter = "Letter"
letter-of-intent = "Letter of intent"
license = "License"
license-agreement = "License agreement"
loan-agreement = "Loan agreement"
loan-document = "Loan document"
maintenance-log = "Maintenance log"
manual = "Manual"
market-research = "Market research"
marketing-plan = "Marketing plan"
media-kit = "Media kit"
medical-invoice = "Medical invoice"
medical-report = "Medical report"
meeting-agenda = "Meeting agenda"
meeting-minutes = "Meeting minutes"
meeting-notes = "Meeting notes"
membership-document = "Membership document"
memo = "Memo"
memorandum-of-understanding = "Memorandum of understanding"
mortgage-document = "Mortgage document"
nda = "NDA"
newsletter = "Newsletter"
non-compete-agreement = "Non-compete agreement"
notice = "Notice"
offer-letter = "Offer letter"
onboarding-document = "Onboarding document"
order-confirmation = "Order confirmation"
order-form = "Order form"
organization-chart = "Organization chart"
packing-slip = "Packing slip"
partnership-agreement = "Partnership agreement"
patent = "Patent"
pathology-report = "Pathology report"
payment-reminder = "Payment reminder"
payroll-document = "Payroll document"
payslip = "Payslip"
performance-review = "Performance review"
permit = "Permit"
petition = "Petition"
pitch-deck = "Pitch deck"
power-of-attorney = "Power of attorney"
prescription = "Prescription"
presentation = "Presentation"
press-release = "Press release"
price-list = "Price list"
pricing-sheet = "Pricing sheet"
privacy-policy = "Privacy policy"
product-sheet = "Product sheet"
proforma-invoice = "Proforma invoice"
progress-report = "Progress report"
project-charter = "Project charter"
project-plan = "Project plan"
promotional-material = "Promotional material"
property-listing = "Property listing"
proposal = "Proposal"
public-notice = "Public notice"
purchase-agreement = "Purchase agreement"
purchase-order = "Purchase order"
quality-report = "Quality report"
quarterly-report = "Quarterly report"
questionnaire = "Questionnaire"
quick-start-guide = "Quick start guide"
quote = "Quote"
radiology-report = "Radiology report"
receipt = "Receipt"
recommendation-letter = "Recommendation letter"
reference-letter = "Reference letter"
referral-letter = "Referral letter"
registration-confirmation = "Registration confirmation"
registration-form = "Registration form"
regulatory-filing = "Regulatory filing"
release-notes = "Release notes"
remittance-advice = "Remittance advice"
rental-agreement = "Rental agreement"
report = "Report"
request-for-proposal = "Request for proposal"
request-for-quotation = "Request for quotation"
requirements-document = "Requirements document"
research-abstract = "Research abstract"
research-paper = "Research paper"
reservation = "Reservation"
resignation-letter = "Resignation letter"
resume = "Resume"
retrospective = "Retrospective"
return-authorization = "Return authorization"
risk-assessment = "Risk assessment"
roadmap = "Roadmap"
safety-data-sheet = "Safety data sheet"
safety-procedure = "Safety procedure"
sales-proposal = "Sales proposal"
sales-report = "Sales report"
schematic = "Schematic"
scope-of-work = "Scope of work"
service-agreement = "Service agreement"
service-report = "Service report"
settlement-agreement = "Settlement agreement"
shareholder-agreement = "Shareholder agreement"
shipping-confirmation = "Shipping confirmation"
specification = "Specification"
sponsorship-agreement = "Sponsorship agreement"
standard-operating-procedure = "Standard operating procedure"
statement-of-account = "Statement of account"
statement-of-work = "Statement of work"
status-report = "Status report"
stock-report = "Stock report"
study-guide = "Study guide"
subpoena = "Subpoena"
subscription-confirmation = "Subscription confirmation"
supply-order = "Supply order"
survey-form = "Survey form"
survey-results = "Survey results"
sustainability-report = "Sustainability report"
table-of-contents = "Table of contents"
tax-form = "Tax form"
tax-return = "Tax return"
tax-statement = "Tax statement"
technical-drawing = "Technical drawing"
technical-specification = "Technical specification"
tenancy-agreement = "Tenancy agreement"
tender-document = "Tender document"
termination-letter = "Termination letter"
terms-and-conditions = "Terms and conditions"
terms-of-service = "Terms of service"
test-plan = "Test plan"
test-report = "Test report"
thesis = "Thesis"
ticket = "Ticket"
timeline = "Timeline"
timesheet = "Timesheet"
title-document = "Title document"
training-material = "Training material"
transcript = "Transcript"
travel-itinerary = "Travel itinerary"
trust-document = "Trust document"
user-guide = "User guide"
utility-bill = "Utility bill"
vendor-agreement = "Vendor agreement"
visa-document = "Visa document"
waiver = "Waiver"
warehouse-receipt = "Warehouse receipt"
warranty-document = "Warranty document"
waybill = "Waybill"
white-paper = "White paper"
will = "Will"
work-instruction = "Work instruction"
work-order = "Work order"
[cloudBadge]
tooltip = "This operation will use your cloud credits"
@@ -3327,7 +3614,7 @@ enterEmailConfirm = "To confirm deletion, please type your email address ({{emai
guestDescription = "You are signed in as a guest. Consider upgrading your account above."
label = "Overview"
manageAccountPreferences = "Manage your account preferences"
signedInAs = "Signed in as"
signedInAs = "Account"
title = "Account Settings"
[config.account.profilePicture]
@@ -3438,6 +3725,39 @@ integration = "Integration Configuration"
security = "Security Configuration"
system = "System Configuration"
[connect]
loading = "Checking this request."
redirecting = "Returning you to your server."
[connect.confirm]
acknowledge = "I recognise this address and want to connect it to my team"
approve = "Connect server"
deny = "Decline"
lead = "A Stirling server is asking to connect to your team. Check the address below is yours before you approve."
originLabel = "Address"
signedInAs = "Signed in as"
switchAccount = "Use a different account"
title = "Connect this server?"
unknownAccount = "an unknown account"
[connect.confirm.insecure]
body = "This address does not use HTTPS, so your sign-in will be sent over an unencrypted connection. Only approve it on a network you trust."
label = "Not an encrypted address"
[connect.declined]
body = "Nothing was connected. You can close this page."
title = "Request declined"
[connect.error]
failed = "That did not go through. Only a team owner can connect a server."
[connect.meta]
title = "Connect a server"
[connect.notFound]
body = "This connection request is not valid. It may have expired, or already been used. Start another one from your server."
title = "Request not valid"
[convert]
autoRotate = "Auto Rotate"
autoRotateDescription = "Automatically rotate images to better fit the PDF page"
@@ -3908,7 +4228,6 @@ mobileShort = "Mobile"
mobileUpload = "Mobile Upload"
mobileUploadNotAvailable = "Mobile upload not enabled"
moreOptions = "More options"
myFiles = "My Files"
nextFile = "Next file"
noFiles = "No files available"
noFilesFound = "No files found matching your search"
@@ -4009,9 +4328,9 @@ duplicateFailed = "Could not duplicate file"
expand = "Expand sidebar"
googleDrive = "Google Drive"
googleDriveDisabled = "Google Drive is not configured"
leaveMyFiles = "Leave My Files"
leaveMyFiles = "Leave File library"
library = "PDF Library"
myFiles = "My Files"
myFiles = "File library"
noFiles = "No files yet"
openFileManager = "Browse all files & folders"
openFromComputer = "Open from computer"
@@ -4054,7 +4373,7 @@ addToWorkspaceCount = "Add {{count}} to workspace"
allFiles = "All files"
back = "Back"
backToFolder = "Back to {{folder}}"
backToMyFiles = "Back to My Files"
backToMyFiles = "Back to File library"
breadcrumbs = "Folder path"
bulkActions = "Actions"
cancel = "Cancel"
@@ -4106,7 +4425,6 @@ localFoldersUnavailable = "Folders are cloud-only - save a file to the cloud to
moveSkippedRemote_one = "{{count}} file couldn't be moved on the server (no permission or already deleted)."
moveSkippedRemote_other = "{{count}} files couldn't be moved on the server (no permission or already deleted)."
moveTo = "Move to…"
myFiles = "My Files"
newFolder = "New folder"
newFolderStorageDisabled = "Server folder storage isn't enabled. Ask your admin to turn it on."
newFolderTabUnavailable = "Switch to All or Cloud to create folders."
@@ -6580,6 +6898,34 @@ after = "to enable account linking against the hosted Stirling account. In dev y
before = "Set"
title = "SaaS login not configured"
[portal.accountLink.connect.callback]
continue = "Continue"
linkedNotSignedIn = "You are not signed in to Stirling in this browser, so usage and billing will ask you to sign in."
modalTitle = "Connecting this server"
retry = "Try again"
signedInAnyway = "You are signed in to Stirling, so billing and usage will load. Only the server link is incomplete."
working = "Finishing the connection."
[portal.accountLink.connect.callback.expired]
body = "Connection requests are short lived. Start another one."
title = "Request expired"
[portal.accountLink.connect.callback.linked]
body = "This server is connected to your Stirling account."
title = "Server connected"
[portal.accountLink.connect.callback.malformed]
body = "This page was opened without a valid connection response. Start the connection from settings."
title = "Could not read the response"
[portal.accountLink.connect.callback.rejected]
body = "This request was declined or has already been used. Start another one if that was not intended."
title = "Connection not completed"
[portal.accountLink.connect.callback.unfinished]
body = "Stirling did not confirm the connection. This is usually temporary."
title = "Not finished yet"
[portal.accountLink.gate]
action = "Link account"
description = "Link this org's Stirling account to use billable features."
@@ -6613,17 +6959,24 @@ minutesAgo_other = "{{count}}m ago"
never = "never"
[portal.accountLink.modal]
linkSubtitle = "Sign in to the account this server should bill against."
linkTitle = "Link your Stirling account"
reauthSubtitle = "Your session expired — sign back in to your Stirling account. Your instance stays linked."
cancel = "Cancel"
continueLink = "Continue to Stirling"
continueReauth = "Sign in again"
linkSubtitle = "Connect this server to the Stirling account it should bill against."
linkTitle = "Connect your Stirling account"
noAuthorizeUrl = "Stirling did not return somewhere to continue. Try again in a moment."
reauthSubtitle = "Your Stirling session expired. Sign in again to keep seeing usage and billing. This server stays connected either way."
reauthTitle = "Sign in again"
simulateSignIn = "Simulate sign-in (dev)"
startFailed = "Could not reach Stirling to start the connection. Check this server's outbound network access, then try again."
step1 = "We send you to stirling.com to sign in. Any sign-in method works there, including Google and single sign-on."
step2 = "You check this server's address and approve it. A team owner has to do this the first time."
step3 = "Stirling brings you straight back here and finishes up."
[portal.accountLink.modal.loginNotConfigured]
after = "to enable in-app linking against the hosted Stirling account."
after = "so this server can finish the connection when you come back."
and = "and"
before = "Set"
title = "SaaS login not configured"
title = "Stirling connection not configured"
[portal.accountLink.panel]
instancesSub = "Every self-hosted instance registered to this org. Revoke a credential to immediately cut off its unattended access."
@@ -8911,7 +9264,6 @@ appEditor = "Editor"
appProcessor = "Processor"
linkAccount = "Link Stirling account"
primaryNav = "Primary navigation"
switchApp = "Switch app"
[portal.shell.topbar]
closeNav = "Close navigation"
@@ -9390,6 +9742,16 @@ automate = "Automate"
config = "Config"
files = "Files"
[quickNav]
editor = "Editor"
home = "Stirling"
invite = "Invite"
landmark = "Quick navigation"
noProcessorAccess = "Ask an admin for processor access"
notifications = "Notifications"
processor = "Processor"
reader = "Reader"
[read]
tags = "view,open,display,read,viewer,PDF viewer,PDF reader,open PDF,view PDF,display PDF,preview,browse"
@@ -11890,6 +12252,10 @@ title = "Watermark Text"
image = "Image"
text = "Text"
[workbench.sessionRestore]
none = "Your previous files are no longer stored on this device."
partial = "Restored {{restored}} of {{total}} files. The rest are no longer stored on this device."
[workbenchBar]
activeFiles = "Active Files"
annotations = "Annotations"
@@ -360,11 +360,9 @@ const TeamSection: React.FC = () => {
verticalSpacing="sm"
withRowBorders
highlightOnHover
style={
{
"--table-border-color": "var(--mantine-color-gray-3)",
} as React.CSSProperties
}
style={{
"--table-border-color": "var(--mantine-color-gray-3)",
}}
>
<Table.Thead>
<Table.Tr
+16 -12
View File
@@ -1,6 +1,7 @@
import { Suspense, lazy } from "react";
import { Routes, Route } from "react-router-dom";
import { AppProviders } from "@app/components/AppProviders";
import { AppFrame } from "@app/components/layout/AppFrame";
import { AppLayout } from "@app/components/AppLayout";
import { LoadingFallback } from "@app/components/shared/LoadingFallback";
import { ThemeProvider } from "@app/components/shared/ThemeProvider";
@@ -53,18 +54,21 @@ export default function App() {
}
/>
{/* All other routes need AppProviders for backend integration */}
<Route
path="*"
element={
<AppProviders>
<AppLayout>
<HomePage />
<Onboarding />
</AppLayout>
</AppProviders>
}
/>
{/* The app, under a shared frame so the rail renders once outside it. */}
<Route element={<AppFrame />}>
{/* All other routes need AppProviders for backend integration */}
<Route
path="*"
element={
<AppProviders>
<AppLayout>
<HomePage />
<Onboarding />
</AppLayout>
</AppProviders>
}
/>
</Route>
</Routes>
</Suspense>
);
@@ -39,6 +39,7 @@ import { RedactionProvider } from "@app/contexts/RedactionContext";
import { FormFillProvider } from "@app/tools/formFill/FormFillContext";
import { FolderFileContextProvider } from "@app/contexts/FolderFileContext";
import { FolderProvider } from "@app/contexts/FolderContext";
import { WorkbenchSessionPersistence } from "@app/components/session/WorkbenchSessionPersistence";
// Component to initialize scarf tracking (must be inside AppConfigProvider)
function ScarfTrackingInitializer() {
@@ -163,6 +164,7 @@ export function AppProviders({
<TourOrchestrationProvider>
<AdminTourOrchestrationProvider>
<FolderFileContextProvider>
<WorkbenchSessionPersistence />
{children}
</FolderFileContextProvider>
</AdminTourOrchestrationProvider>
@@ -336,7 +336,7 @@ const FileEditor = ({
(fileId: FileId) => {
const index = stubsRef.current.findIndex((r) => r.id === fileId);
if (index !== -1) {
setActiveFileId(fileId as string);
setActiveFileId(fileId);
setActiveFileIndex(index);
navActions.setWorkbench("viewer");
}
@@ -410,10 +410,7 @@ const FileEditor = ({
onUnzipFile={handleUnzipFile}
toolMode={toolMode}
isSupported={isFileSupported(record.name)}
policies={
policyFileBadges.get(record.id as string) ??
EMPTY_POLICIES
}
policies={policyFileBadges.get(record.id) ?? EMPTY_POLICIES}
/>
);
})}
@@ -173,7 +173,7 @@ const FileSourceButtons: React.FC<FileSourceButtonsProps> = ({
mb="xs"
style={{ paddingLeft: "1rem" }}
>
{t("fileManager.myFiles", "My Files")}
{t("fileSidebar.myFiles", "File library")}
</Text>
{buttons}
</Stack>
@@ -140,7 +140,7 @@ export function FileDetailsPanel({
return null;
}
const single = files.length === 1 ? files[0]! : null;
const single = files.length === 1 ? files[0] : null;
const totalSize = files.reduce((sum, f) => sum + f.size, 0);
const ext = single ? (single.name.split(".").pop() ?? "").toUpperCase() : "";
// Files still needing a server upload; drives Save-to-server visibility.
@@ -422,7 +422,7 @@ function GridView(props: FileGridProps) {
parentPath={entry.parentPath}
isSelected={selectedFileIds.has(entry.file.id)}
isInWorkspace={
activeWorkspaceFileIds?.has(entry.file.id as string) ?? false
activeWorkspaceFileIds?.has(entry.file.id) ?? false
}
selectedFileIds={selectedFileIds}
multiSelectActive={selectedFileIds.size >= 2}
@@ -938,7 +938,7 @@ function FileCard({
shiftKey: false,
ctrlKey: true,
metaKey: true,
} as unknown as React.MouseEvent);
});
}}
onChange={() => {
/* handled by onClick */
@@ -982,7 +982,7 @@ function FileCard({
·
</span>
<span>{fileDate}</span>
<PolicyBadges fileId={file.id as string} />
<PolicyBadges fileId={file.id} />
</div>
</div>
<div className="files-page-card-actions">
@@ -1137,7 +1137,7 @@ function ListView(
parentPath={entry.parentPath}
isSelected={selectedFileIds.has(entry.file.id)}
isInWorkspace={
activeWorkspaceFileIds?.has(entry.file.id as string) ?? false
activeWorkspaceFileIds?.has(entry.file.id) ?? false
}
selectedFileIds={selectedFileIds}
multiSelectActive={selectedFileIds.size >= 2}
@@ -1424,7 +1424,7 @@ function FileRow({
shiftKey: false,
ctrlKey: true,
metaKey: true,
} as unknown as React.MouseEvent);
});
}}
onChange={() => {
/* handled by onClick */
@@ -1491,7 +1491,7 @@ function FileRow({
)}
</span>
<FileOriginBadge origin={getFileOrigin(file)} compact />
<PolicyBadges fileId={file.id as string} />
<PolicyBadges fileId={file.id} />
{isInWorkspace && (
<span className="files-page-row-open-pill">
<span className="files-page-card-open-dot" />
@@ -474,7 +474,7 @@ export default function FileManagerView() {
if (idx >= 0 && lastIdx >= 0) {
const [a, b] = idx < lastIdx ? [idx, lastIdx] : [lastIdx, idx];
for (let i = a; i <= b; i += 1) {
next.add(visibleFiles[i]!.id);
next.add(visibleFiles[i].id);
}
return next;
}
@@ -593,7 +593,7 @@ export default function FileManagerView() {
});
// Branch on requested stubs so already-active files still activate.
if (materialized.length === 1) {
setActiveFileId(materialized[0]!.id);
setActiveFileId(materialized[0].id);
navActions.setWorkbench("viewer");
} else if (materialized.length > 1) {
navActions.setWorkbench("fileEditor");
@@ -1172,7 +1172,7 @@ export default function FileManagerView() {
else if (e.key === "End") next = TAB_DEFS.length - 1;
else return;
e.preventDefault();
const target = TAB_DEFS[next]!;
const target = TAB_DEFS[next];
setCurrentTab(target.id);
focusTab(target.id);
}}
@@ -1602,7 +1602,7 @@ export default function FileManagerView() {
)
)
return;
setViewMode(v as (typeof FILES_PAGE_VIEW_MODES)[number]);
setViewMode(v);
}}
aria-label={t("filesPage.viewMode.label", "View mode")}
options={[
@@ -121,7 +121,7 @@ export function FolderTreePanel({ active }: FolderTreePanelProps) {
<div className="folder-tree-panel-inner">
<div className="folder-tree-panel-header">
<span className="folder-tree-panel-title">
{t("filesPage.myFiles", "My Files")}
{t("fileSidebar.myFiles", "File library")}
</span>
</div>
@@ -120,14 +120,14 @@ export function VersionTimeline({
};
const rows: Row[] = useMemo(() => {
if (!collapsible || showAllCollapsed) {
return ordered.map((v) => ({ kind: "version", version: v }) as Row);
return ordered.map<Row>((v) => ({ kind: "version", version: v }));
}
const head = ordered
.slice(0, 3)
.map((v) => ({ kind: "version", version: v }) as Row);
.map<Row>((v) => ({ kind: "version", version: v }));
const tail = ordered
.slice(-2)
.map((v) => ({ kind: "version", version: v }) as Row);
.map<Row>((v) => ({ kind: "version", version: v }));
const hidden = ordered.length - 5;
return [...head, { kind: "ellipsis", hidden }, ...tail];
}, [collapsible, showAllCollapsed, ordered]);
@@ -1,6 +1,6 @@
/**
* Stores the route the user came from when they open files into the
* workbench from My Files. Lets the workbench show a "Back to My Files"
* workbench from the file library. Lets the workbench show a "Back to File library"
* affordance and return to the exact folder they were browsing.
*
* Persisted in sessionStorage so a hard reload keeps the return path
@@ -27,7 +27,7 @@ function depthOf(
let cursor: FolderRecord | undefined = folder;
while (cursor && cursor.parentFolderId) {
depth += 1;
cursor = byId.get(cursor.parentFolderId as string);
cursor = byId.get(cursor.parentFolderId);
if (depth > 50) break;
}
return depth;
@@ -0,0 +1,18 @@
/* ========== APP FRAME ========== */
/* The rail's column, then whichever app is mounted, so a switch changes only the app. */
.app-frame {
display: flex;
height: 100vh;
height: 100dvh; /* track mobile browser chrome */
overflow: hidden;
background-color: var(--c-bg);
}
/* min-width: 0 so the app shrinks instead of forcing the frame past the window. */
.app-frame__content {
flex: 1;
min-width: 0;
height: 100%;
}
/* The rail hides itself below the mobile breakpoint - see QuickNavRailContainer.css. */
@@ -0,0 +1,22 @@
import { Suspense } from "react";
import { Outlet } from "react-router-dom";
import { LoadingFallback } from "@app/components/shared/LoadingFallback";
import { QuickNavHostProvider } from "@app/contexts/QuickNavHostContext";
import { QuickNavRailHost } from "@app/components/shared/quickNav/QuickNavRailHost";
import "@app/components/layout/AppFrame.css";
/** The rail renders once outside both apps; Suspense sits inside it, not above. */
export function AppFrame() {
return (
<QuickNavHostProvider>
<div className="app-frame">
<QuickNavRailHost />
<div className="app-frame__content">
<Suspense fallback={<LoadingFallback />}>
<Outlet />
</Suspense>
</div>
</div>
</QuickNavHostProvider>
);
}
@@ -0,0 +1,8 @@
import { Outlet } from "react-router-dom";
import { useSuppressQuickNavRail } from "@app/contexts/QuickNavHostContext";
/** Pages that aren't the app: inside the frame for its providers, but with no rail. */
export function NoAppChrome() {
useSuppressQuickNavRail();
return <Outlet />;
}
@@ -12,10 +12,8 @@
.workbenchBarReopenTab {
position: absolute;
top: 100%;
/* Right-align with the retract handle inside the bar: the bar's right
margin (--nav-gutter) + 1px border + 8px bar padding + the handle's own
6px inset. */
right: calc(var(--nav-gutter) + 15px);
/* Aligns with the retract handle: 8px bar padding plus its own 6px inset. */
right: 14px;
display: flex;
align-items: center;
justify-content: center;
@@ -1,4 +1,4 @@
import { useState, Suspense, lazy } from "react";
import { useState, useEffect, useRef, Suspense, lazy } from "react";
import { useTranslation } from "react-i18next";
import KeyboardArrowDownIcon from "@mui/icons-material/KeyboardArrowDown";
import { Box, Loader, Center, Stack, Text } from "@mantine/core";
@@ -15,6 +15,7 @@ import { VIEWER_SUPPORTED_EXTENSIONS } from "@app/utils/fileUtils";
import { useAppConfig } from "@app/contexts/AppConfigContext";
import { useSigningOverlay } from "@app/contexts/SigningOverlayContext";
import { useCookieConsent } from "@app/hooks/useCookieConsent";
import { useIsPhone } from "@app/hooks/useIsMobile";
import styles from "@app/components/layout/Workbench.module.css";
import WorkbenchBar from "@app/components/shared/WorkbenchBar";
@@ -58,10 +59,13 @@ export default function Workbench() {
setPageEditorFunctions,
setSidebarsVisible,
customWorkbenchViews,
readerMode,
} = useToolWorkflow();
const { handleToolSelect } = useToolWorkflow();
const { overlay: signingOverlay } = useSigningOverlay();
// Below this width the rail, and the bell it carries, is gone.
const isPhone = useIsPhone();
// Get navigation state - this is the source of truth
const { selectedTool: selectedToolId } = useNavigationState();
@@ -92,8 +96,20 @@ export default function Workbench() {
!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;
// Reading hides the bar; the rail's Reader entry is the way back.
const showWorkbenchBar =
topControlsAvailable && hasWorkbenchContent && !readerMode;
const showFloatingSearch =
topControlsAvailable && !hasWorkbenchContent && !readerMode;
// On the transition, so reading sets the toolbar's start state without locking it.
const prevReaderModeRef = useRef(readerMode);
useEffect(() => {
if (readerMode !== prevReaderModeRef.current) {
setViewerToolbarCollapsed(readerMode);
prevReaderModeRef.current = readerMode;
}
}, [readerMode]);
const handlePreviewClose = () => {
setPreviewFile(null);
@@ -126,7 +142,7 @@ export default function Workbench() {
}
}
// The "My Files" workbench is available regardless of whether files are
// The file-library workbench is available regardless of whether files are
// currently loaded into the workbench - it lives on top of the IDB store.
if (currentView === "myFiles") {
return <FileManagerView />;
@@ -249,10 +265,8 @@ export default function Workbench() {
data-tour="workbench"
style={{ backgroundColor: "var(--c-bg)", minWidth: 0 }}
>
{/* 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 && (
{/* Phone only: above that the rail carries the bell, and here no bar does. */}
{isPhone && !showWorkbenchBar && (
<div style={{ position: "absolute", top: 12, right: 12, zIndex: 20 }}>
<NotificationBell />
</div>
@@ -0,0 +1,16 @@
/* ========== WORKSPACE FRAME ========== */
/* Rail and sidebar side by side, full height. Shared by both apps. */
.workspace-frame {
display: flex;
height: 100%;
flex-shrink: 0;
background-color: var(--c-bg);
}
/* On mobile the sidebar is a fixed drawer, so the frame stops laying out. */
@media (max-width: 48rem) {
.workspace-frame {
display: block;
height: auto;
}
}
@@ -139,9 +139,9 @@ export const MobileDrawCanvas = forwardRef<
// where the per-frame synthetic event alone would drop curvature.
const events =
"getCoalescedEvents" in e.nativeEvent
? (e.nativeEvent as PointerEvent).getCoalescedEvents()
? e.nativeEvent.getCoalescedEvents()
: [e.nativeEvent as PointerEvent];
const rect = (e.currentTarget as HTMLCanvasElement).getBoundingClientRect();
const rect = e.currentTarget.getBoundingClientRect();
for (const ev of events) {
stroke.points.push({
x: ev.clientX - rect.left,
@@ -10,7 +10,7 @@
position: relative;
padding: var(--sp-2, 0.5rem);
border: none;
border-radius: var(--radius-md, 0.375rem);
border-radius: var(--radius-md);
background: transparent;
color: var(--c-text-muted);
cursor: pointer;
@@ -48,6 +48,12 @@
box-shadow: 0 10px 30px rgb(0 0 0 / 25%);
}
/* The rail's bell is at the foot of a full-height column, so its panel rises beside it. */
.notification-bell__panel--rail {
inset-inline-start: calc(var(--nav-rail-w) + var(--nav-gutter));
inset-block-end: var(--nav-gutter);
}
.notification-bell__heading {
margin: 0 0 var(--sp-2, 0.5rem);
font-size: 0.875rem;
@@ -1,27 +1,15 @@
import {
Fragment,
useEffect,
useId,
useLayoutEffect,
useRef,
useState,
} from "react";
import { useLayoutEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { BellIcon, Button } from "@app/ui";
import DividerWithText from "@app/components/shared/DividerWithText";
import { useNotifications } from "@app/hooks/useNotifications";
import { useNotificationActions } from "@app/components/notifications/notificationActions";
import { NotificationItem } from "@app/components/notifications/NotificationItem";
import { NotificationPanel } from "@app/components/notifications/NotificationPanel";
import { useNotificationsAvailable } from "@app/components/notifications/useNotificationsAvailable";
import "@app/components/notifications/NotificationBell.css";
/**
* Renders whatever the server sends without knowing which subsystem produced it or what its actions
* mean, so a new source or failure kind needs no change here. In core because both shells mount it.
*/
/** For the narrow layouts where the rail, which carries the bell, is off screen. */
export function NotificationBell() {
// A build with no notifications API gets no bell at all, rather than one that polls a
// nonexistent endpoint forever to show nothing.
// No API means no bell at all, rather than one polling an endpoint that isn't there.
const available = useNotificationsAvailable();
if (!available) return null;
return <MountedNotificationBell />;
@@ -29,14 +17,10 @@ export function NotificationBell() {
function MountedNotificationBell() {
const { t } = useTranslation();
const { notifications, unreadCount, documentStateFor, markAllSeen } =
useNotifications();
const { unreadCount } = useNotifications();
const registry = useNotificationActions();
const [open, setOpen] = useState(false);
const container = useRef<HTMLDivElement>(null);
const headingId = useId();
// Where the new ones stop, frozen when the panel opens (opening marks everything read).
const [firstSeenId, setFirstSeenId] = useState<string | null>(null);
// Viewport-fixed, because the workbench bar clips its own overflow.
const [anchor, setAnchor] = useState<{ top: number; right: number } | null>(
null,
@@ -61,54 +45,17 @@ function MountedNotificationBell() {
};
}, [open]);
// Opening marks them read, not closing: waiting would leave the badge lit while they read.
const toggle = () => {
setOpen((wasOpen) => {
if (!wasOpen) {
// Before marking, or there is nothing left to read.
setFirstSeenId(notifications[unreadCount]?.id ?? null);
markAllSeen();
}
return !wasOpen;
});
};
/**
* How many count as new. No boundary id means all of them were; one that has since left the list
* leaves nothing to divide on, so it reads as none rather than guessing at a row.
*/
const boundaryIndex = firstSeenId
? notifications.findIndex((notification) => notification.id === firstSeenId)
: notifications.length;
const dividedAt = Math.max(0, boundaryIndex);
useEffect(() => {
if (!open) return;
const closeOnOutside = (event: MouseEvent) => {
const target = event.target as HTMLElement;
if (!container.current?.contains(target)) setOpen(false);
};
const closeOnEscape = (event: KeyboardEvent) => {
if (event.key === "Escape") setOpen(false);
};
document.addEventListener("mousedown", closeOnOutside);
document.addEventListener("keydown", closeOnEscape);
return () => {
document.removeEventListener("mousedown", closeOnOutside);
document.removeEventListener("keydown", closeOnEscape);
};
}, [open]);
return (
<div className="notification-bell" ref={container}>
<Button
variant="quiet"
size="md"
shape="circle"
className="notification-bell__trigger"
// Read by the panel's outside-click handler.
data-notifications-trigger
aria-label={t("notifications.open", "Notifications")}
aria-expanded={open}
onClick={toggle}
onClick={() => setOpen((wasOpen) => !wasOpen)}
>
<BellIcon />
{unreadCount > 0 && (
@@ -119,53 +66,11 @@ function MountedNotificationBell() {
</Button>
{open && (
<div
className="notification-bell__panel"
role="dialog"
// Named by its own heading: a dialog with no accessible name is announced as just "dialog".
aria-labelledby={headingId}
<NotificationPanel
onClose={() => setOpen(false)}
registry={registry}
style={anchor ? { top: anchor.top, right: anchor.right } : undefined}
>
<h2 className="notification-bell__heading" id={headingId}>
{t("notifications.title", "Notifications")}
</h2>
{notifications.length === 0 ? (
<p className="notification-bell__empty">
{t("notifications.empty", "Nothing to report.")}
</p>
) : (
<ul className="notification-bell__list">
{notifications.map((notification, index) => (
<Fragment key={notification.id}>
{index === 0 && dividedAt > 0 && (
<li aria-hidden>
<DividerWithText
text={t("notifications.section.new", "New")}
/>
</li>
)}
{/* Only with something on both sides: a lone "Earlier" over everything says
nothing the empty badge has not. */}
{index === dividedAt && dividedAt > 0 && (
<li aria-hidden>
<DividerWithText
text={t("notifications.section.earlier", "Earlier")}
/>
</li>
)}
<NotificationItem
notification={notification}
unread={index < dividedAt}
documentState={documentStateFor(notification)}
registry={registry}
onDismissPanel={() => setOpen(false)}
/>
</Fragment>
))}
</ul>
)}
</div>
/>
)}
</div>
);
@@ -0,0 +1,135 @@
import { Fragment, useEffect, useId, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import DividerWithText from "@app/components/shared/DividerWithText";
import { useNotifications } from "@app/hooks/useNotifications";
import type { ClientActionRegistry } from "@app/components/notifications/notificationActions";
import { NotificationItem } from "@app/components/notifications/NotificationItem";
import "@app/components/notifications/NotificationBell.css";
/** Named so a trigger in another tree can point at it with aria-controls. */
export const NOTIFICATIONS_PANEL_ID = "quick-nav-notifications-panel";
export interface NotificationPanelProps {
onClose: () => void;
id?: string;
/** Passed in: its document handover has to run whether the panel is open or not. */
registry: ClientActionRegistry;
style?: React.CSSProperties;
className?: string;
}
/** Mounted only while open, since mounting is what marks everything read. */
export function NotificationPanel({
onClose,
registry,
id,
style,
className,
}: NotificationPanelProps) {
const { t } = useTranslation();
const { notifications, unreadCount, documentStateFor, markAllSeen } =
useNotifications();
const panel = useRef<HTMLDivElement>(null);
const headingId = useId();
// Frozen on open, since opening marks them all read.
const [firstSeenId, setFirstSeenId] = useState<string | null>(null);
// On mount, not on close: waiting leaves the badge lit while they read.
const marked = useRef(false);
useEffect(() => {
if (marked.current) return;
marked.current = true;
// Before marking, or there is nothing left to divide on.
setFirstSeenId(notifications[unreadCount]?.id ?? null);
markAllSeen();
}, [notifications, unreadCount, markAllSeen]);
// No boundary means all were new; one that has left the list means none.
const boundaryIndex = firstSeenId
? notifications.findIndex((notification) => notification.id === firstSeenId)
: notifications.length;
const dividedAt = Math.max(0, boundaryIndex);
// Focus goes back to the opener only if it is still inside the panel on close.
useEffect(() => {
const opener = document.activeElement as HTMLElement | null;
panel.current?.focus();
return () => {
if (panel.current?.contains(document.activeElement)) opener?.focus();
};
}, []);
useEffect(() => {
const closeOnOutside = (event: MouseEvent) => {
const target = event.target as HTMLElement;
if (panel.current?.contains(target)) return;
// A trigger closes this itself; counting it as outside would reopen it.
if (target.closest?.("[data-notifications-trigger]")) return;
onClose();
};
const closeOnEscape = (event: KeyboardEvent) => {
if (event.key === "Escape") onClose();
};
document.addEventListener("mousedown", closeOnOutside);
document.addEventListener("keydown", closeOnEscape);
return () => {
document.removeEventListener("mousedown", closeOnOutside);
document.removeEventListener("keydown", closeOnEscape);
};
}, [onClose]);
return (
<div
ref={panel}
className={
className
? `notification-bell__panel ${className}`
: "notification-bell__panel"
}
id={id}
role="dialog"
tabIndex={-1}
aria-labelledby={headingId}
style={style}
>
<h2 className="notification-bell__heading" id={headingId}>
{t("notifications.title", "Notifications")}
</h2>
{notifications.length === 0 ? (
<p className="notification-bell__empty">
{t("notifications.empty", "Nothing to report.")}
</p>
) : (
<ul className="notification-bell__list">
{notifications.map((notification, index) => (
<Fragment key={notification.id}>
{index === 0 && dividedAt > 0 && (
<li aria-hidden>
<DividerWithText
text={t("notifications.section.new", "New")}
/>
</li>
)}
{/* Only with unread rows above it. */}
{index === dividedAt && dividedAt > 0 && (
<li aria-hidden>
<DividerWithText
text={t("notifications.section.earlier", "Earlier")}
/>
</li>
)}
<NotificationItem
notification={notification}
unread={index < dividedAt}
documentState={documentStateFor(notification)}
registry={registry}
onDismissPanel={onClose}
/>
</Fragment>
))}
</ul>
)}
</div>
);
}
@@ -20,7 +20,6 @@ import {
import { useOnboardingDownload } from "@app/components/onboarding/useOnboardingDownload";
import {
SLIDE_DEFINITIONS,
type SlideId,
type ButtonAction,
} from "@app/components/onboarding/onboardingFlowConfig";
import ToolPanelModePrompt from "@app/components/tools/ToolPanelModePrompt";
@@ -322,7 +321,7 @@ export default function Onboarding() {
) {
return null;
}
return SLIDE_DEFINITIONS[currentStep.slideId as SlideId];
return SLIDE_DEFINITIONS[currentStep.slideId];
}, [currentStep]);
const currentSlideContent = useMemo(() => {
@@ -244,7 +244,7 @@ export class ReorderPagesCommand extends DOMCommand {
.map((pageNum) =>
currentDoc.pages.find((p) => p.pageNumber === pageNum),
)
.filter((page) => page !== undefined) as PDFPage[];
.filter((page) => page !== undefined);
const remainingPages = currentDoc.pages.filter(
(page) => !this.selectedPages!.includes(page.pageNumber),
@@ -0,0 +1,475 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { render, waitFor, act } from "@testing-library/react";
const mocks = vi.hoisted(() => ({
getLeafStirlingFileStubs: vi.fn(),
alert: vi.fn(),
setActiveFileId: vi.fn(),
restoreWorkbench: vi.fn(),
workbench: "viewer" as string,
authUser: null as { id: string } | null,
authLoading: false,
pathname: "/editor",
activeFileId: null as string | null,
}));
vi.mock("@app/services/fileStorage", () => ({
fileStorage: { getLeafStirlingFileStubs: mocks.getLeafStirlingFileStubs },
}));
vi.mock("@app/components/toast", () => ({ alert: mocks.alert }));
vi.mock("@app/contexts/NavigationContext", () => ({
useNavigationState: () => ({ workbench: mocks.workbench }),
useNavigationActions: () => ({
actions: { restoreWorkbench: mocks.restoreWorkbench },
}),
}));
vi.mock("react-router-dom", () => ({
useLocation: () => ({ pathname: mocks.pathname }),
}));
vi.mock("@app/auth/UseSession", () => ({
useAuth: () => ({ user: mocks.authUser, loading: mocks.authLoading }),
}));
vi.mock("@app/contexts/ViewerContext", () => ({
useViewer: () => ({
activeFileId: mocks.activeFileId,
setActiveFileId: mocks.setActiveFileId,
}),
}));
import { WorkbenchSessionPersistence } from "@app/components/session/WorkbenchSessionPersistence";
import { fingerprintOwner } from "@app/services/workbenchSession";
import {
FileStoreContext,
FileActionsContext,
} from "@app/contexts/file/contexts";
import type { StirlingFileStub } from "@app/types/fileContext";
const SESSION_KEY = "stirling.workbench.session";
function stub(
id: string,
originalFileId: string,
versionNumber = 1,
): StirlingFileStub {
return { id, originalFileId, versionNumber, name: `${id}.pdf` } as never;
}
// A minimal stand-in for the FileContext store: mutable state plus subscribers.
function makeStore(open: StirlingFileStub[] = [], selected: string[] = []) {
const listeners = new Set<() => void>();
const state = {
files: {
ids: open.map((s) => s.id),
byId: Object.fromEntries(open.map((s) => [s.id, s])),
},
ui: { selectedFileIds: selected },
};
return {
state,
getState: () => state as never,
subscribe: (listener: () => void) => {
listeners.add(listener);
return () => listeners.delete(listener);
},
// reopenView waits on this to know the restored bytes have landed.
selectors: {
getFiles: (ids: string[]) => ids.map((id) => ({ id })),
} as never,
notify: () => listeners.forEach((listener) => listener()),
};
}
const actions = {
addStirlingFileStubs: vi.fn().mockResolvedValue([]),
setSelectedFiles: vi.fn(),
};
function mount(store: ReturnType<typeof makeStore>) {
return render(
<FileStoreContext.Provider value={store as never}>
<FileActionsContext.Provider
value={{ actions, dispatch: vi.fn() } as never}
>
<WorkbenchSessionPersistence />
</FileActionsContext.Provider>
</FileStoreContext.Provider>,
);
}
beforeEach(() => {
// The shared setup stubs crypto.subtle.digest to one constant for every input, so every account
// would fingerprint alike - and ownership is exactly what these tests are about.
vi.spyOn(globalThis.crypto.subtle, "digest").mockImplementation(
async (_algorithm: AlgorithmIdentifier, data: BufferSource) => {
const bytes = ArrayBuffer.isView(data)
? new Uint8Array(data.buffer, data.byteOffset, data.byteLength)
: new Uint8Array(data);
let hash = 0x811c9dc5;
for (const byte of bytes) {
hash = Math.imul(hash ^ byte, 0x01000193) >>> 0;
}
const out = new Uint8Array(32);
for (let i = 0; i < out.length; i++) {
hash = Math.imul(hash ^ i, 0x01000193) >>> 0;
out[i] = hash & 0xff;
}
return out.buffer;
},
);
sessionStorage.clear();
vi.clearAllMocks();
actions.addStirlingFileStubs.mockResolvedValue([]);
mocks.getLeafStirlingFileStubs.mockResolvedValue([]);
mocks.workbench = "viewer";
mocks.authUser = null;
mocks.authLoading = false;
mocks.pathname = "/editor";
mocks.activeFileId = null;
});
afterEach(() => vi.useRealTimers());
describe("restore", () => {
it("refills an empty workbench with each file's current leaf, in saved order", async () => {
sessionStorage.setItem(
SESSION_KEY,
JSON.stringify({
v: 2,
fileIds: ["root-a", "root-b"],
selectedFileIds: ["root-b"],
}),
);
// root-a forked while the user was away: v3 must win over the stale v1 leaf.
mocks.getLeafStirlingFileStubs.mockResolvedValue([
stub("a-v1", "root-a", 1),
stub("a-v3", "root-a", 3),
stub("root-b", "root-b", 1),
]);
mount(makeStore());
await waitFor(() =>
expect(actions.addStirlingFileStubs).toHaveBeenCalled(),
);
const restored = actions.addStirlingFileStubs.mock.calls[0][0];
expect(restored.map((s: StirlingFileStub) => s.id)).toEqual([
"a-v3",
"root-b",
]);
expect(actions.setSelectedFiles).toHaveBeenCalledWith(["root-b"]);
expect(mocks.alert).not.toHaveBeenCalled();
});
it("does not touch a workbench that already holds files", async () => {
sessionStorage.setItem(
SESSION_KEY,
JSON.stringify({ v: 2, fileIds: ["root-a"], selectedFileIds: [] }),
);
mount(makeStore([stub("already-open", "already-open")]));
await act(async () => {});
expect(actions.addStirlingFileStubs).not.toHaveBeenCalled();
});
it("restores what still exists and says how much is gone", async () => {
sessionStorage.setItem(
SESSION_KEY,
JSON.stringify({
v: 2,
fileIds: ["root-a", "gone"],
selectedFileIds: [],
}),
);
mocks.getLeafStirlingFileStubs.mockResolvedValue([
stub("root-a", "root-a"),
]);
mount(makeStore());
await waitFor(() => expect(mocks.alert).toHaveBeenCalled());
expect(actions.addStirlingFileStubs.mock.calls[0][0]).toHaveLength(1);
expect(mocks.alert.mock.calls[0][0].alertType).toBe("warning");
});
it("does not say 'the rest' when nothing at all could be restored", async () => {
sessionStorage.setItem(
SESSION_KEY,
JSON.stringify({
v: 2,
fileIds: ["gone-1", "gone-2"],
selectedFileIds: [],
}),
);
mocks.getLeafStirlingFileStubs.mockResolvedValue([]);
mount(makeStore());
await waitFor(() => expect(mocks.alert).toHaveBeenCalled());
expect(mocks.alert.mock.calls[0][0].title).toBe(
"workbench.sessionRestore.none",
);
});
it("does nothing when no session was recorded", async () => {
mount(makeStore());
await act(async () => {});
expect(actions.addStirlingFileStubs).not.toHaveBeenCalled();
expect(mocks.getLeafStirlingFileStubs).not.toHaveBeenCalled();
});
it("reopens the document the user was viewing, at its current version", async () => {
sessionStorage.setItem(
SESSION_KEY,
JSON.stringify({
v: 2,
fileIds: ["root-a"],
selectedFileIds: ["root-a"],
workbench: "fileEditor",
activeFileId: "root-a",
}),
);
mocks.getLeafStirlingFileStubs.mockResolvedValue([
stub("a-v2", "root-a", 2),
]);
mount(makeStore());
await waitFor(() => expect(mocks.setActiveFileId).toHaveBeenCalled());
expect(mocks.setActiveFileId).toHaveBeenCalledWith("a-v2");
expect(mocks.restoreWorkbench).toHaveBeenCalledWith("fileEditor");
});
it("leaves a URL-owned view to the return path", async () => {
sessionStorage.setItem(
SESSION_KEY,
JSON.stringify({
v: 2,
fileIds: ["root-a"],
selectedFileIds: [],
workbench: "myFiles",
}),
);
mocks.getLeafStirlingFileStubs.mockResolvedValue([
stub("root-a", "root-a"),
]);
mount(makeStore());
await waitFor(() =>
expect(actions.addStirlingFileStubs).toHaveBeenCalled(),
);
expect(mocks.restoreWorkbench).not.toHaveBeenCalled();
});
});
describe("whose workbench it is", () => {
// Records hold a fingerprint of the owner, never the account id.
const record = async (userId: string | null) =>
sessionStorage.setItem(
SESSION_KEY,
JSON.stringify({
v: 2,
fileIds: ["root-a"],
selectedFileIds: [],
userId: userId == null ? null : await fingerprintOwner(userId),
}),
);
it("does not open one user's workbench for the next person in the tab", async () => {
await record("user-a");
mocks.authUser = { id: "user-b" };
mocks.getLeafStirlingFileStubs.mockResolvedValue([
stub("root-a", "root-a"),
]);
mount(makeStore());
await act(async () => {});
expect(actions.addStirlingFileStubs).not.toHaveBeenCalled();
// The record is theirs now - the previous person's files are gone from it, so they cannot
// resurface later in the session.
const taken = JSON.parse(sessionStorage.getItem(SESSION_KEY)!);
expect(taken.fileIds).toEqual([]);
expect(taken.userId).toBe(await fingerprintOwner("user-b"));
});
it("reopens it for the user who left it", async () => {
await record("user-a");
mocks.authUser = { id: "user-a" };
mocks.getLeafStirlingFileStubs.mockResolvedValue([
stub("root-a", "root-a"),
]);
mount(makeStore());
await waitFor(() =>
expect(actions.addStirlingFileStubs).toHaveBeenCalled(),
);
});
it("waits for the session before deciding", async () => {
await record("user-a");
mocks.authUser = null;
mocks.authLoading = true;
mocks.getLeafStirlingFileStubs.mockResolvedValue([
stub("root-a", "root-a"),
]);
mount(makeStore());
await act(async () => {});
// Neither restored nor discarded - who is signed in is not known yet.
expect(actions.addStirlingFileStubs).not.toHaveBeenCalled();
expect(sessionStorage.getItem(SESSION_KEY)).not.toBeNull();
});
});
describe("a lost session that comes back", () => {
const rerenderWith = (
view: ReturnType<typeof mount>,
store: ReturnType<typeof makeStore>,
) =>
view.rerender(
<FileStoreContext.Provider value={store as never}>
<FileActionsContext.Provider
value={{ actions, dispatch: vi.fn() } as never}
>
<WorkbenchSessionPersistence />
</FileActionsContext.Provider>
</FileStoreContext.Provider>,
);
it("survives a blip on the identity check", async () => {
// A failed /auth/me - flaky wifi, a backend redeploy, a refreshSession() that did not land -
// briefly reads as nobody signed in. It must not be mistaken for signing out.
sessionStorage.setItem(
SESSION_KEY,
JSON.stringify({
v: 2,
fileIds: ["root-a"],
selectedFileIds: [],
userId: await fingerprintOwner("user-a"),
}),
);
mocks.authUser = { id: "user-a" };
const store = makeStore([stub("f1", "f1")]);
const view = mount(store);
await act(async () => {});
mocks.authUser = null;
rerenderWith(view, store);
await act(async () => {});
expect(sessionStorage.getItem(SESSION_KEY)).not.toBeNull();
// ...and once the identity is back, the workbench is still being recorded.
mocks.authUser = { id: "user-a" };
rerenderWith(view, store);
// Let the fingerprint land: writes hold off while a known identity has none yet.
await act(async () => {});
store.state.files.ids = ["f2" as never];
store.state.files.byId = { f2: stub("f2", "root-b") } as never;
act(() => store.notify());
view.unmount();
expect(JSON.parse(sessionStorage.getItem(SESSION_KEY)!).fileIds).toEqual([
"root-b",
]);
});
});
describe("on the login screen", () => {
it("neither restores nor records - signing out must not rebuild the workbench there", async () => {
mocks.pathname = "/login";
sessionStorage.setItem(
SESSION_KEY,
JSON.stringify({ v: 2, fileIds: ["root-a"], selectedFileIds: [] }),
);
mocks.getLeafStirlingFileStubs.mockResolvedValue([
stub("root-a", "root-a"),
]);
const store = makeStore();
const { unmount } = mount(store);
await act(async () => {});
expect(actions.addStirlingFileStubs).not.toHaveBeenCalled();
// And the unmount flush must not write either.
store.state.files.ids = ["f1" as never];
store.state.files.byId = { f1: stub("f1", "f1") } as never;
act(() => store.notify());
unmount();
expect(JSON.parse(sessionStorage.getItem(SESSION_KEY)!).fileIds).toEqual([
"root-a",
]);
});
});
describe("writer", () => {
it("mirrors the open files and selection as original ids, debounced", async () => {
vi.useFakeTimers();
const store = makeStore();
mount(store);
store.state.files.ids = ["v2" as never];
store.state.files.byId = { v2: stub("v2", "root-a", 2) } as never;
store.state.ui.selectedFileIds = ["v2"];
act(() => store.notify());
await act(async () => {
await vi.advanceTimersByTimeAsync(300);
});
expect(JSON.parse(sessionStorage.getItem(SESSION_KEY)!)).toMatchObject({
fileIds: ["root-a"],
selectedFileIds: ["root-a"],
workbench: "viewer",
});
});
it("records the current view, so the return lands where the user left", async () => {
vi.useFakeTimers();
mocks.workbench = "fileEditor";
const store = makeStore([stub("f1", "f1")]);
mount(store);
act(() => store.notify());
await act(async () => {
await vi.advanceTimersByTimeAsync(300);
});
expect(JSON.parse(sessionStorage.getItem(SESSION_KEY)!).workbench).toBe(
"fileEditor",
);
});
it("writes nothing until the restore has settled", () => {
vi.useFakeTimers();
sessionStorage.setItem(
SESSION_KEY,
JSON.stringify({ v: 2, fileIds: ["root-a"], selectedFileIds: [] }),
);
// Restore is still awaiting storage, so this mount's empty state is not the truth.
mocks.getLeafStirlingFileStubs.mockReturnValue(new Promise(() => {}));
const store = makeStore();
const { unmount } = mount(store);
act(() => store.notify());
unmount();
expect(JSON.parse(sessionStorage.getItem(SESSION_KEY)!).fileIds).toEqual([
"root-a",
]);
});
it("flushes on unmount, so the state at the shell switch survives", () => {
vi.useFakeTimers();
const store = makeStore();
const { unmount } = mount(store);
store.state.files.ids = ["f1" as never];
store.state.files.byId = { f1: stub("f1", "f1") } as never;
act(() => store.notify());
unmount();
expect(JSON.parse(sessionStorage.getItem(SESSION_KEY)!).fileIds).toEqual([
"f1",
]);
});
});
@@ -0,0 +1,301 @@
// The editor/processor shell switch unmounts every editor provider, and a reload starts from nothing:
// this mirrors the workbench into sessionStorage and refills an empty one from that record on mount.
import { useCallback, useContext, useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import {
FileStoreContext,
type FileStateStore,
} from "@app/contexts/file/contexts";
import { useFileActions } from "@app/contexts/FileContext";
import {
useNavigationActions,
useNavigationState,
} from "@app/contexts/NavigationContext";
import { useViewer } from "@app/contexts/ViewerContext";
import { useAuth } from "@app/auth/UseSession";
import { useLocation } from "react-router-dom";
import { isAuthRoute } from "@app/constants/routes";
import { fileStorage } from "@app/services/fileStorage";
import { alert } from "@app/components/toast";
import { WORKBENCH_SESSION_RESTORE } from "@app/constants/featureFlags";
import {
beginRestoredView,
clearWorkbenchSession,
fingerprintOwner,
resumeWorkbenchSession,
endRestoredView,
isSeedableView,
originalIdOf,
readWorkbenchSession,
writeWorkbenchSession,
} from "@app/services/workbenchSession";
import type { WorkbenchType } from "@app/types/workbench";
import type { StirlingFileStub } from "@app/types/fileContext";
import type { FileId } from "@app/types/file";
const WRITE_DEBOUNCE_MS = 300;
// Current leaf per original id; a forked chain resolves to the highest version.
function leafByOriginalId(
leaves: StirlingFileStub[],
): Map<string, StirlingFileStub> {
const map = new Map<string, StirlingFileStub>();
for (const leaf of leaves) {
const key = originalIdOf(leaf);
const current = map.get(key);
if (!current || (leaf.versionNumber ?? 1) > (current.versionNumber ?? 1)) {
map.set(key, leaf);
}
}
return map;
}
/** How long to wait for the NEXT file to hydrate before giving up on holding the view. Restarted on
* each arrival, so a slow device with large documents keeps the view as long as it makes progress. */
const SETTLE_TIMEOUT_MS = 5000;
/** Released a beat late, so effects reacting to the same commit still see the restore in progress. */
const RELEASE_GRACE_MS = 250;
/**
* Reopen the recorded view, then hold the restore guard until the files have hydrated.
*
* The view is written ONCE. Re-asserting it after hydration would also overwrite a view the user
* picked in the meantime; holding the guard is what keeps HomePage's defaults off it instead.
*/
function reopenView(
store: FileStateStore,
reopen: (view: WorkbenchType) => void,
{
view,
fileCount,
token,
}: { view: WorkbenchType; fileCount: number; token: number },
): void {
reopen(view);
const loaded = () =>
store.selectors.getFiles(store.getState().files.ids).length;
const release = () =>
setTimeout(() => endRestoredView(token), RELEASE_GRACE_MS);
if (loaded() >= fileCount) {
release();
return;
}
let timer: ReturnType<typeof setTimeout>;
const stop = () => {
clearTimeout(timer);
unsubscribe();
release();
};
const waitForNext = () => {
clearTimeout(timer);
timer = setTimeout(stop, SETTLE_TIMEOUT_MS);
};
let seen = loaded();
const unsubscribe = store.subscribe(() => {
const now = loaded();
if (now >= fileCount) return stop();
// Progress, not completion: give the remaining files a fresh window.
if (now > seen) {
seen = now;
waitForNext();
}
});
waitForNext();
}
export function WorkbenchSessionPersistence() {
const store = useContext(FileStoreContext);
const { actions } = useFileActions();
const { workbench } = useNavigationState();
const { actions: navigationActions } = useNavigationActions();
const { activeFileId, setActiveFileId } = useViewer();
const { user, loading: authLoading } = useAuth();
// Login/signup mount the editor's providers too. Nothing there is the user's workbench, so this
// records nothing and restores nothing - otherwise signing out rebuilds it on the login screen.
const onAuthRoute = isAuthRoute(useLocation().pathname);
const userId = user?.id != null ? String(user.id) : null;
// Fingerprinted, never stored raw - see fingerprintOwner. Computed asynchronously, so writes
// hold off until it lands rather than stamping the record "nobody's" and then failing its own
// ownership check.
const [owner, setOwner] = useState<string | null>(null);
useEffect(() => {
if (userId == null) {
setOwner(null);
return;
}
let cancelled = false;
void fingerprintOwner(userId).then((fingerprint) => {
if (!cancelled) setOwner(fingerprint);
});
return () => {
cancelled = true;
};
}, [userId]);
const { t } = useTranslation();
// Captured before the writer below can overwrite it with the empty boot state.
const [saved] = useState(readWorkbenchSession);
const restoreStarted = useRef(false);
// Until the restore has run, this mount's empty state is not the truth to record.
const restoreSettled = useRef(false);
// Published so a build's restore setting is legible without reading the bundle.
useEffect(() => {
document.documentElement.dataset.workbenchRestore = String(
WORKBENCH_SESSION_RESTORE,
);
}, []);
const write = useCallback(() => {
if (!store || !restoreSettled.current) return;
// A known identity whose fingerprint has not landed yet: wait, do not stamp it as nobody's.
if (userId != null && owner == null) return;
const state = store.getState();
const toOriginal = (id: FileId): string | null => {
const stub = state.files.byId[id];
return stub ? originalIdOf(stub) : null;
};
const isPresent = (id: string | null): id is string => id !== null;
writeWorkbenchSession({
fileIds: state.files.ids.map(toOriginal).filter(isPresent),
selectedFileIds: state.ui.selectedFileIds
.map(toOriginal)
.filter(isPresent),
workbench,
userId: owner,
activeFileId: activeFileId
? (toOriginal(activeFileId as FileId) ?? undefined)
: undefined,
});
}, [store, workbench, activeFileId, userId, owner]);
// Read by the file subscription, which must not resubscribe on every view change.
const writeRef = useRef(write);
writeRef.current = write;
useEffect(() => {
if (!store || onAuthRoute) return;
// This mount is a new session: undo any suspension left by a sign-out in this page's lifetime.
resumeWorkbenchSession();
let timer: ReturnType<typeof setTimeout> | undefined;
const unsubscribe = store.subscribe(() => {
clearTimeout(timer);
timer = setTimeout(() => writeRef.current(), WRITE_DEBOUNCE_MS);
});
return () => {
clearTimeout(timer);
// Flush, so the state at the moment of the shell switch is what survives.
writeRef.current();
unsubscribe();
};
}, [store, onAuthRoute]);
// Changing view touches no file state, so the subscription above never sees it.
useEffect(() => write(), [write]);
useEffect(() => {
if (restoreStarted.current) return;
if (onAuthRoute) return;
// Who is signed in decides whether this record is theirs to reopen, so settle that first.
if (authLoading) return;
restoreStarted.current = true;
const nothingToDo =
!WORKBENCH_SESSION_RESTORE ||
!store ||
!saved ||
saved.fileIds.length === 0 ||
store.getState().files.ids.length > 0;
if (nothingToDo) {
restoreSettled.current = true;
return;
}
void (async () => {
// A tab can outlive a sign-out (the logout clears it, but a 401 bounce or an expiry does
// not), and the next person to sign in here must not open the last person's documents.
const currentOwner =
userId == null ? null : await fingerprintOwner(userId);
if ((saved.userId ?? null) !== currentOwner) {
clearWorkbenchSession();
restoreSettled.current = true;
return;
}
// Held while the files land: they are added one at a time, and each landing re-runs the
// default-view heuristic, which must not overwrite the recorded view mid-restore.
let held: number | null = null;
try {
// Resolve each id to its CURRENT leaf: a policy or another tab may have versioned it since.
const leaves = leafByOriginalId(
await fileStorage.getLeafStirlingFileStubs(),
);
const stubs = saved.fileIds
.map((id) => leaves.get(id))
.filter((stub): stub is StirlingFileStub => stub !== undefined);
if (stubs.length > 0) {
const view = isSeedableView(saved.workbench) ? saved.workbench : null;
if (view) held = beginRestoredView();
// The same entry point My Files uses, so a restored file is governed by the same rules as
// any other file entering the workbench - including whether a policy has already run on it.
await actions.addStirlingFileStubs(stubs);
const selected = saved.selectedFileIds
.map((id) => leaves.get(id)?.id)
.filter((id): id is FileId => id !== undefined);
if (selected.length > 0) actions.setSelectedFiles(selected);
// After the files land: the viewer drops an active id it cannot find.
const active = saved.activeFileId
? leaves.get(saved.activeFileId)?.id
: undefined;
if (active) setActiveFileId(active as string);
if (view && held !== null) {
reopenView(store, navigationActions.restoreWorkbench, {
view,
fileCount: stubs.length,
token: held,
});
held = null; // reopenView owns the release from here.
}
}
const missing = saved.fileIds.length - stubs.length;
if (missing > 0) {
alert({
alertType: "warning",
title:
stubs.length === 0
? t(
"workbench.sessionRestore.none",
"Your previous files are no longer stored on this device.",
)
: t(
"workbench.sessionRestore.partial",
"Restored {{restored}} of {{total}} files. The rest are no longer stored on this device.",
{ restored: stubs.length, total: saved.fileIds.length },
),
});
}
} finally {
if (held !== null) endRestoredView(held);
// Even a failed restore must release the writer, or the record freezes for the session.
restoreSettled.current = true;
}
})();
}, [
saved,
store,
actions,
navigationActions,
setActiveFileId,
t,
authLoading,
userId,
onAuthRoute,
]);
return null;
}
@@ -24,17 +24,10 @@
--app-banner-icon: var(--c-accent-fg, var(--c-primary));
}
/* The one bar meant to pop, so it takes the feature gradient rather than a tint.
Fixed hues by design — it doesn't follow the chosen accent. */
.app-banner--promo {
--app-banner-bg: linear-gradient(
135deg,
var(--c-hue-indigo) 0%,
var(--c-hue-purple) 100%
);
--app-banner-border: transparent;
--app-banner-icon: var(--color-text-on-accent);
color: var(--color-text-on-accent);
--app-banner-bg: var(--c-bg-raised);
--app-banner-border: var(--c-border-subtle);
--app-banner-icon: var(--c-text-muted);
}
.app-banner--warning {
@@ -86,16 +79,18 @@
font-size: 0.75rem;
}
/* On the gradient everything is white; muted grey would disappear. */
.app-banner--promo .app-banner__message,
.app-banner--promo .app-banner__actions .sui-btn--tertiary,
.app-banner--promo .app-banner__actions .sui-ai {
color: var(--color-text-on-accent);
.app-banner--promo .app-banner__icon {
width: 1.75rem;
height: 1.75rem;
justify-content: center;
border-radius: var(--radius-md);
background: var(--c-surface-sunken);
box-shadow: inset 0 0 0 1px var(--c-border-subtle);
}
/* Lifts the premium CTA off the gradient it sits on. */
.app-banner--promo .app-banner__actions .sui-btn--primary {
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.25);
.app-banner--promo.app-banner--compact .app-banner__icon {
width: 1.5rem;
height: 1.5rem;
}
.app-banner__actions {
@@ -11,7 +11,7 @@ export type AppBannerTone = "info" | "promo" | "warning" | "danger";
/** Tone decides the button too, so the CTA can't drift from the bar it sits on. */
const TONE_BUTTON = {
info: { variant: "secondary", accent: "default" },
promo: { variant: "primary", accent: "premium" },
promo: { variant: "primary", accent: "default" },
warning: { variant: "primary", accent: "warning" },
danger: { variant: "primary", accent: "danger" },
} as const;
@@ -11,12 +11,7 @@ interface AppSwitchMenuItemsProps {
onSwitch: (app: AppSwitchTarget) => void;
}
/**
* The editor / processor items for the app-switch menu. Rendered inside the
* BrandSwitcher's logo dropdown, which both apps use as their switcher. The
* mark is the shared <BrandMark>, which recolours itself from the theme
* tokens, so no colour-scheme prop needs threading down here.
*/
/** The editor / processor items for an app-switch menu. */
export function AppSwitchMenuItems({
current,
onSwitch,
@@ -1,22 +0,0 @@
import { Logo } from "@app/ui/Logo";
export interface AppSwitcherProps {
/** Icon-only brand mark for the collapsed rail. */
collapsed?: boolean;
}
/**
* Sidebar brand header. Core has no admin portal to switch to, so it just
* shows the Stirling logo. Builds that bundle the portal (proprietary/saas)
* shadow this with a version whose logo doubles as the editor⇄processor
* switcher.
*/
export function AppSwitcher({ collapsed }: AppSwitcherProps) {
return (
<Logo
variant={collapsed ? "iconOnly" : "iconAndText"}
iconHeight="1.6rem"
textHeight="1.3rem"
/>
);
}
@@ -1,15 +0,0 @@
/* Logo + app-switch dropdown, shared between the editor and the processor.
The logo itself is the trigger (its mark morphs into a chevron on hover). */
.sui-brand-switcher {
display: flex;
align-items: center;
flex: 1;
min-width: 0;
}
/* Tighten the ghost-button padding so the lockup sits flush like a plain logo,
and negative-margin it back so the hover surface still extends past the text. */
.sui-brand-switcher__trigger.sui-btn {
--button-padding-x: 0.375rem;
margin-inline: -0.375rem;
}
@@ -1,16 +0,0 @@
import type { Meta, StoryObj } from "@storybook/react";
import { BrandSwitcher } from "@app/components/shared/BrandSwitcher";
const meta: Meta<typeof BrandSwitcher> = {
title: "Brand/BrandSwitcher",
component: BrandSwitcher,
parameters: { layout: "centered" },
args: { current: "processor", onSwitch: () => {} },
argTypes: {
current: { control: "inline-radio", options: ["editor", "processor"] },
},
};
export default meta;
type Story = StoryObj<typeof BrandSwitcher>;
export const Playground: Story = {};
@@ -1,57 +0,0 @@
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { Button, Dropdown } from "@app/ui";
import { Logo } from "@app/ui/Logo";
import { BrandMark } from "@app/components/shared/BrandMark";
import {
AppSwitchMenuItems,
type AppSwitchTarget,
} from "@app/components/shared/AppSwitch";
import "@app/components/shared/BrandSwitcher.css";
interface BrandSwitcherProps {
/** The app this is rendered in (shown active in the menu). */
current: AppSwitchTarget;
/** Called with the selected app (only for the non-current one). */
onSwitch: (app: AppSwitchTarget) => void;
/** Icon-only: drop the wordmark, keep the morphing mark as the trigger. */
collapsed?: boolean;
className?: string;
}
/**
* Brand lockup that doubles as the editor⇄processor switcher. The whole logo
* is the dropdown trigger: on hover / focus / open the mark morphs into a
* downward chevron (see BrandMark), so no separate chevron button is needed.
* Shared so the editor and the processor present one identical header.
*/
export function BrandSwitcher({
current,
onSwitch,
collapsed = false,
className,
}: BrandSwitcherProps) {
const { t } = useTranslation();
const [open, setOpen] = useState(false);
return (
<div className={`sui-brand-switcher${className ? ` ${className}` : ""}`}>
<Dropdown.Root align="start" open={open} onOpenChange={setOpen}>
<Dropdown.Trigger>
<Button
variant="quiet"
data-brandmark-morph
className={`sui-brand-switcher__trigger${open ? " is-open" : ""}`}
aria-label={t("portal.shell.sidebar.switchApp", "Switch app")}
leftSection={<BrandMark height="1.6rem" />}
>
{!collapsed && <Logo variant="textOnly" textHeight="1.3rem" />}
</Button>
</Dropdown.Trigger>
<Dropdown.Menu width="11rem">
<AppSwitchMenuItems current={current} onSwitch={onSwitch} />
</Dropdown.Menu>
</Dropdown.Root>
</div>
);
}
@@ -0,0 +1,29 @@
interface BrandTileProps {
/** CSS length. Omit to let the caller's CSS size it. */
size?: string;
className?: string;
}
/** The mark in a rounded square. Decorative: call sites carry the accessible name. */
export function BrandTile({ size, className }: BrandTileProps) {
return (
<svg
className={className}
viewBox="0 0 256 256"
fill="none"
style={size ? { width: size, height: size } : undefined}
aria-hidden
>
<rect width="256" height="256" rx="58" fill="var(--c-brand-mark)" />
<path
d="M39.2638 127.834L155.374 32L155.375 121.499L39.2638 217.333L39.2638 127.834Z"
fill="white"
/>
<path
d="M159 124.5L159 88.5L216.728 38.4472L216.728 128.052L100.479 224L100.479 172L159 124.5Z"
fill="white"
fillOpacity="0.6"
/>
</svg>
);
}
@@ -1,7 +1,9 @@
/* ========== FILE SIDEBAR ========== */
.file-sidebar {
background-color: var(--c-bg);
/* One solid panel, with a rule only on the workbench side, so it frames the document. */
background-color: var(--c-surface);
border-inline-end: 1px solid var(--c-border-subtle);
display: flex;
flex-direction: column;
height: 100%;
@@ -37,12 +39,19 @@
gap: 0.5rem;
}
/* ---- Brand header (logo / editor⇄processor switcher) ---- */
.file-sidebar-brand {
/* Flattened here; two classes deep to beat .sui-nav-surface without relying on order. */
.file-sidebar .sui-nav-surface {
background: transparent;
border: 0;
border-radius: 0;
}
/* ---- Header row (wordmark + collapse toggle) ---- */
.file-sidebar-header {
display: flex;
align-items: center;
min-height: 40px;
padding: 0 0.375rem;
min-height: var(--nav-header-h);
padding: 0 var(--nav-gutter);
flex-shrink: 0;
}
@@ -51,9 +60,9 @@
flex-shrink: 0;
}
.file-sidebar[data-collapsed="true"] .file-sidebar-brand {
flex-direction: column;
gap: 0.25rem;
/* Collapsed the row holds only the toggle, so centre it. */
.file-sidebar[data-collapsed="true"] .file-sidebar-header {
justify-content: center;
padding: 0;
}
.file-sidebar[data-collapsed="true"] .file-sidebar-collapse-toggle {
@@ -24,7 +24,6 @@ import { useViewer } from "@app/contexts/ViewerContext";
import { useFileHandler } from "@app/hooks/useFileHandler";
import { useAccountIdentity } from "@app/hooks/useAccountIdentity";
import { useFreeCreditsSummary } from "@app/hooks/useFreeCreditsSummary";
import { useOtherAppSwitch } from "@app/hooks/useOtherAppSwitch";
import { useOpenPlan } from "@app/hooks/useOpenPlan";
import { NavFooter } from "@app/components/shared/navFooter/NavFooter";
import {
@@ -32,8 +31,7 @@ import {
useIndexedDBRevision,
} from "@app/contexts/IndexedDBContext";
import { GoogleDriveIcon } from "@app/components/shared/CloudStorageIcons";
import { AppSwitcher } from "@app/components/shared/AppSwitcher";
import { SidebarToggleIcon } from "@app/components/shared/SidebarToggleIcon";
import { SidebarHeader } from "@app/components/shared/SidebarHeader";
import type { StirlingFileStub } from "@app/types/fileContext";
import FolderOpenIcon from "@mui/icons-material/FolderOpen";
import FolderSpecialIcon from "@mui/icons-material/FolderSpecial";
@@ -78,8 +76,9 @@ import { WATCHED_FOLDERS_ENABLED } from "@app/constants/featureFlags";
import { useToolWorkflow } from "@app/contexts/ToolWorkflowContext";
import "@app/components/shared/FileSidebar.css";
const COLLAPSED_WIDTH = "3.5rem";
const EXPANDED_WIDTH = "16.25rem"; // ~260px
// Shared with the processor sidebar via tokens, so the two cannot drift.
const COLLAPSED_WIDTH = "var(--sidebar-collapsed-w)";
const EXPANDED_WIDTH = "var(--sidebar-w)";
// Inlined to avoid a circular import with WatchedFoldersRegistration.
const WATCHED_FOLDER_VIEW_ID = "watchedFolder";
@@ -98,9 +97,11 @@ export interface FileSidebarProps {
collapsed?: boolean;
onToggleCollapse?: () => void;
onOpenSettings?: () => void;
/** Accessible name override for the toggle button. */
/** The quick nav rail owns the account control, so the footer drops its own row. */
accountHoisted?: boolean;
/** Accessible name override for the collapse toggle. */
toggleAriaLabel?: string;
/** Icon override for the toggle button (e.g. back-arrow on /files). */
/** Icon override for the collapse toggle (e.g. back-arrow on /files). */
toggleIcon?: React.ReactNode;
/** Override the Open-from-computer handler (e.g. upload to /files folder). */
onUploadFiles?: (files: File[]) => void | Promise<void>;
@@ -155,11 +156,12 @@ const FileSidebar = forwardRef<HTMLDivElement, FileSidebarProps>(
collapsed = false,
onToggleCollapse,
onOpenSettings,
accountHoisted = false,
toggleAriaLabel,
toggleIcon,
onUploadFiles,
onPickGoogleDriveFiles,
extraAction,
toggleAriaLabel,
toggleIcon,
},
ref,
) {
@@ -249,7 +251,6 @@ const FileSidebar = forwardRef<HTMLDivElement, FileSidebarProps>(
const { displayName, profilePictureUrl, isAnonymous } =
useAccountIdentity();
const credits = useFreeCreditsSummary();
const otherApp = useOtherAppSwitch();
const openPlan = useOpenPlan();
// Leaf files = user-visible files (excludes intermediate tool outputs)
@@ -943,25 +944,12 @@ const FileSidebar = forwardRef<HTMLDivElement, FileSidebarProps>(
</div>
)}
<div className="file-sidebar-inner">
<div className="file-sidebar-brand">
<AppSwitcher collapsed={collapsed} />
{onToggleCollapse && (
<ActionIcon
variant="tertiary"
size="md"
className="file-sidebar-collapse-toggle"
onClick={() => onToggleCollapse()}
aria-label={
toggleAriaLabel ??
(collapsed
? t("fileSidebar.expand", "Expand sidebar")
: t("fileSidebar.collapse", "Collapse sidebar"))
}
>
{toggleIcon ?? <SidebarToggleIcon size={18} />}
</ActionIcon>
)}
</div>
<SidebarHeader
collapsed={collapsed}
onToggleCollapse={onToggleCollapse}
toggleAriaLabel={toggleAriaLabel}
toggleIcon={toggleIcon}
/>
{/* Box 1 — top controls (open / my files / cloud). No title. File
search lives in the global super search (top bar), not here. */}
@@ -984,7 +972,7 @@ const FileSidebar = forwardRef<HTMLDivElement, FileSidebarProps>(
{/* Tooltips only fire when collapsed - when expanded the visible
text label below already identifies each row, so a tooltip
would just flash a duplicate. Distinct icons (UploadFile for
"Open from computer" vs FolderOpen for "My Files") so the
"Open from computer" vs FolderOpen for "File library") so the
collapsed rail isn't two identical folder icons either. */}
<Tooltip
label={t("fileSidebar.openFromComputer", "Open from computer")}
@@ -1003,7 +991,7 @@ const FileSidebar = forwardRef<HTMLDivElement, FileSidebarProps>(
onClick={() => {
// "Open from computer" goes straight to the native OS file
// picker. The full file manager (recent + drives + folders)
// is reachable via "My Files" below.
// is reachable via "File library" below.
nativeFileInputRef.current?.click();
}}
role="button"
@@ -1080,7 +1068,7 @@ const FileSidebar = forwardRef<HTMLDivElement, FileSidebarProps>(
)}
<Tooltip
label={t("fileSidebar.myFiles", "My Files")}
label={t("fileSidebar.myFiles", "File library")}
position="right"
withinPortal
disabled={!collapsed}
@@ -1094,7 +1082,7 @@ const FileSidebar = forwardRef<HTMLDivElement, FileSidebarProps>(
}}
role="button"
tabIndex={0}
aria-label={t("fileSidebar.myFiles", "My Files")}
aria-label={t("fileSidebar.myFiles", "File library")}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
@@ -1105,7 +1093,7 @@ const FileSidebar = forwardRef<HTMLDivElement, FileSidebarProps>(
<FolderOpenIcon className="file-sidebar-action-icon" />
{!collapsed && (
<span className="file-sidebar-action-label sidebar-content-fade">
{t("fileSidebar.myFiles", "My Files")}
{t("fileSidebar.myFiles", "File library")}
</span>
)}
</div>
@@ -1370,15 +1358,15 @@ const FileSidebar = forwardRef<HTMLDivElement, FileSidebarProps>(
{/* Getting-started checklist, floating above the footer (SaaS only). */}
<SidebarChecklistSlot collapsed={collapsed} />
{/* Box 3 — the shared footer: credits, app switch, account row. */}
{/* Box 3 — the shared footer: credits, plan, and the account row unless hoisted. */}
<NavFooter
className="file-sidebar-footer-box"
displayName={displayName}
profilePictureUrl={profilePictureUrl}
onOpenSettings={onOpenSettings}
showAccount={!accountHoisted}
credits={credits}
onOpenPlan={openPlan ?? undefined}
otherApp={otherApp}
collapsed={collapsed}
/>
</div>
@@ -0,0 +1,33 @@
import { Logo } from "@app/ui/Logo";
import { SidebarToggleButton } from "@app/components/shared/SidebarToggleButton";
export interface SidebarHeaderProps {
collapsed?: boolean;
onToggleCollapse?: () => void;
toggleAriaLabel?: string;
toggleIcon?: React.ReactNode;
className?: string;
}
/** The wordmark and the collapse toggle; the brand mark sits in the rail beside it. */
export function SidebarHeader({
collapsed,
onToggleCollapse,
toggleAriaLabel,
toggleIcon,
className,
}: SidebarHeaderProps) {
return (
<div className={`file-sidebar-header${className ? ` ${className}` : ""}`}>
{!collapsed && <Logo variant="textOnly" textHeight="1.3rem" />}
{onToggleCollapse && (
<SidebarToggleButton
collapsed={collapsed}
onToggle={onToggleCollapse}
ariaLabel={toggleAriaLabel}
icon={toggleIcon}
/>
)}
</div>
);
}

Some files were not shown because too many files have changed in this diff Show More