Compare commits

...
Author SHA1 Message Date
Anthony Stirling 1143a8aba3 Gate AI config-push off in SaaS and cover it with tests 2026-07-15 18:45:52 +01:00
Anthony Stirling baf33b6844 Add AI settings UI and engine config-push bridge 2026-07-15 16:44:59 +01:00
James BruntonandGitHub 776749277c Redesign policies to use typed mappings properly (#7017)
# Description of Changes
The Policies page and all the frontend logic for running Policies is not
making use of the bidirectional type mappings that we now have to safely
convert from frontend to backend param models and vice versa. This
changes the way we track the types throughout so we use the mappings
properly.

Because of this, the Add Watermark settings in Policies now actually
pre-populate with the defaults instead of with nothing like they
previously did.

<img width="791" height="725" alt="image"
src="https://github.com/user-attachments/assets/cbdf4ae0-35af-4792-bf64-89216e48d304"
/>
2026-07-14 09:58:04 +00:00
James BruntonandGitHub 41b1b89fcb Fix Policies page showing the Editor as a source twice (#7022)
# Description of Changes
The Policies page currently hard-codes the Editor to be available as a
source, but we now also have a virtual Editor source on the backend,
which the Policies page also renders. This removes the now-unnecessary
hard-coded Editor source.

## Before

<img width="842" height="640" alt="image"
src="https://github.com/user-attachments/assets/d78b33a3-fed4-4bb0-a02f-489ca2ae0614"
/>

## After

<img width="785" height="586" alt="image"
src="https://github.com/user-attachments/assets/37aa664f-74f2-41c7-b89a-9b483bffc3a2"
/>
2026-07-14 09:43:07 +00:00
James BruntonandGitHub 4d4e994562 Fix crash in Processor when loading tool settings with tooltips (#7015)
# Description of Changes
Some of the tool settings make use of editor preferences indirectly, but
the Processor never gets that provider, so it crashes when trying to
load them.
2026-07-14 09:09:23 +00:00
2b118556f3 Merge hotfix/v2.14.2 into main (#7023)
Merges the `hotfix/v2.14.2` branch into `main`.
on the hotfix branch:

### What this actually changes on `main`
- **Version bump 2.14.1 → 2.14.2** `build.gradle`, `tauri.conf.json`,
both AUR `PKGBUILD`s, and the two `serverExperienceSimulations.ts`
test-config files.
- **Fix Postgres user settings for some users** removes `@Lob` from
`User.java that broke settings for some Postgres users.
- **Release workflow: stop msiexec hang in Windows signature verify**

---------

Co-authored-by: Ludy <Ludy87@users.noreply.github.com>
Co-authored-by: James Brunton <jbrunton96@gmail.com>
Co-authored-by: ConnorYoh <40631091+ConnorYoh@users.noreply.github.com>
Co-authored-by: LFdev <146497073+LFd3v@users.noreply.github.com>
Co-authored-by: EthanHealy01 <80844253+EthanHealy01@users.noreply.github.com>
2026-07-13 23:45:19 +01:00
Anthony Stirling fbaff56d1c Merge hotfix/v2.14.2 into main (v2.14.2 bump, Postgres user settings fix, msiexec release fix) 2026-07-13 20:23:14 +01:00
Anthony StirlingandGitHub a1b15e0570 Portal: dark disabled buttons and role column width (#7004)
# Description of Changes

Fixes disabled buttons rendering as plain grey in dark mode, and widens
the Users role column so "Organisation Owner" no longer clips.

Part of a portal (processor) UI-consistency pass, split into small
focused PRs.

## Before / after

<!-- paste before / after screenshots here -->

---

## Checklist

### General

- [x] I have read the [Contribution
Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md)
- [ ] I have read the [Stirling-PDF Developer
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md)
(if applicable)
- [ ] I have read the [How to add new languages to
Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md)
(if applicable)
- [x] I have performed a self-review of my own code
- [x] My changes generate no new warnings

### Documentation

- [ ] I have updated relevant docs on [Stirling-PDF's doc
repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/)
(if functionality has heavily changed)
- [ ] I have read the section [Add New Translation
Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags)
(for new translation tags only)

### Translations (if applicable)

- [ ] I ran
[`scripts/counter_translation.py`](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/docs/counter_translation.md)

### UI Changes (if applicable)

- [x] Screenshots or videos demonstrating the UI changes are attached
(e.g., as comments or direct attachments in the PR)

### Testing (if applicable)

- [ ] I have run `task check` to verify linters, typechecks, and tests
pass
- [x] I have tested my changes locally. Refer to the [Testing
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md#7-testing)
for more details.
2026-07-13 15:00:01 +00:00
James BruntonandGitHub 76549288a9 Redesign S3 connections to use connection resolver (#6965)
# Description of Changes
Redesign S3 connections based on feedback from #6948. Also redesigns the
UI for Sources to make them more like the Pipelines page which improves
UX quite a bit. There's still plenty more UI/UX work for Sources and S3
but moving in the right direction.
2026-07-13 14:44:41 +00:00
Anthony StirlingandGitHub b5d0c4a5ed Portal Home: SVG quick-action icons (#6998)
# Description of Changes

Replaces the ASCII quick-action glyphs on the Home hero with crisp
stroke SVG icons.

Part of a portal (processor) UI-consistency pass, split into small
focused PRs.

## Before / after
<img width="2880" height="2726" alt="after-home-dark"
src="https://github.com/user-attachments/assets/ab4c3611-25a6-4b2c-a993-99ce2f7b7558"
/>
<img width="2880" height="2726" alt="after-home-light"
src="https://github.com/user-attachments/assets/b0b342b9-4436-4791-b0a6-92837c3ec355"
/>
<img width="2880" height="2726" alt="before-home-dark"
src="https://github.com/user-attachments/assets/93d73393-5a81-4f34-a483-90671a6ae79e"
/>
<img width="2880" height="2726" alt="before-home-light"
src="https://github.com/user-attachments/assets/119389cc-78ef-4a43-9f9a-d551b03c9733"
/>


---

## Checklist

### General

- [x] I have read the [Contribution
Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md)
- [ ] I have read the [Stirling-PDF Developer
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md)
(if applicable)
- [ ] I have read the [How to add new languages to
Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md)
(if applicable)
- [x] I have performed a self-review of my own code
- [x] My changes generate no new warnings

### Documentation

- [ ] I have updated relevant docs on [Stirling-PDF's doc
repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/)
(if functionality has heavily changed)
- [ ] I have read the section [Add New Translation
Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags)
(for new translation tags only)

### Translations (if applicable)

- [ ] I ran
[`scripts/counter_translation.py`](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/docs/counter_translation.md)

### UI Changes (if applicable)

- [x] Screenshots or videos demonstrating the UI changes are attached
(e.g., as comments or direct attachments in the PR)

### Testing (if applicable)

- [ ] I have run `task check` to verify linters, typechecks, and tests
pass
- [x] I have tested my changes locally. Refer to the [Testing
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md#7-testing)
for more details.
2026-07-13 13:26:07 +00:00
Anthony StirlingandGitHub 8bfcf6eb7e Portal: theme-aware code blocks and hero-navy token (#7003)
# Description of Changes

Makes the code-snippet boxes theme-aware (a light palette in light mode)
and moves the hero navy into a design token without changing the colour
itself.

Part of a portal (processor) UI-consistency pass, split into small
focused PRs.

## Before / after

<img width="2136" height="272" alt="after-codeblock-light"
src="https://github.com/user-attachments/assets/ebdd4a7d-2d1a-429a-971b-0b04e93854fe"
/>
<img width="1800" height="740" alt="before-codeblock-light"
src="https://github.com/user-attachments/assets/6819231a-10d5-4730-9b7d-3c36dc1c8170"
/>

---

## Checklist

### General

- [x] I have read the [Contribution
Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md)
- [ ] I have read the [Stirling-PDF Developer
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md)
(if applicable)
- [ ] I have read the [How to add new languages to
Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md)
(if applicable)
- [x] I have performed a self-review of my own code
- [x] My changes generate no new warnings

### Documentation

- [ ] I have updated relevant docs on [Stirling-PDF's doc
repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/)
(if functionality has heavily changed)
- [ ] I have read the section [Add New Translation
Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags)
(for new translation tags only)

### Translations (if applicable)

- [ ] I ran
[`scripts/counter_translation.py`](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/docs/counter_translation.md)

### UI Changes (if applicable)

- [x] Screenshots or videos demonstrating the UI changes are attached
(e.g., as comments or direct attachments in the PR)

### Testing (if applicable)

- [ ] I have run `task check` to verify linters, typechecks, and tests
pass
- [x] I have tested my changes locally. Refer to the [Testing
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md#7-testing)
for more details.
2026-07-13 13:26:00 +00:00
James BruntonandGitHub b019f9b570 Fix missing and broken translations in Processor (#7016)
# Description of Changes
<img width="385" height="92" alt="image"
src="https://github.com/user-attachments/assets/7f4b1921-72e8-4c18-a4de-4b9736a5a2f1"
/>

Started from trying to fix this, but became a larger piece of work to
find missing/broken translations in the Processor and fix as many as I
could.
2026-07-13 12:55:36 +00:00
Anthony Stirling 38ccea074c Version bump 2026-07-08 10:50:37 +01:00
Anthony Stirling a7307ff393 Fix Postgres user settings for some users 2026-07-08 10:50:36 +01:00
Anthony Stirling 72729e99c1 fix(release): stop msiexec hang in Windows signature verify; don't force latest or regen release notes 2026-07-07 23:35:12 +01:00
Anthony Stirling 5fba2720f0 Fix cert sign not showing under certain instances (#6908) 2026-07-07 22:45:56 +01:00
01a1ef8c44 Fix missing app icon on Linux/Wayland (#6875)
Co-authored-by: EthanHealy01 <80844253+EthanHealy01@users.noreply.github.com>
Co-authored-by: Ludy <Ludy87@users.noreply.github.com>
2026-07-07 22:15:01 +01:00
LudyandAnthony Stirling 8535c7e9ac feat(ui): add dedicated third-party license sections to settings (#6820) 2026-07-07 22:15:01 +01:00
ConnorYohandAnthony Stirling cca3f42623 Set App version to v2.14.1 (#6891)
Upped version in build.gradle then ran build so version falls through
2026-07-07 12:15:01 +01:00
James BruntonandAnthony Stirling 1b7ffcdbac Fix tooltip positioning on Add Page Numbers (#6885)
# Description of Changes
## Before

<img width="483" height="227" alt="image"
src="https://github.com/user-attachments/assets/4bf86eec-a9cc-4f63-84f0-4eb2bd535bab"
/>

## After

<img width="732" height="235" alt="image"
src="https://github.com/user-attachments/assets/101d2ea4-36e8-4e8f-990a-d72b33fa0ac2"
/>
2026-07-07 12:09:51 +01:00
LudyandAnthony Stirling 67a0ca6110 fix(frontend): respect analytics config before initializing PostHog (#6812)
# Description of Changes

Please provide a summary of the changes, including:

- What was changed
- Moved PostHog startup out of `index.tsx` and into a config-aware
initializer inside `AppProviders`.
- Added a dedicated `usePosthogTracking` hook that only initializes
PostHog when `enableAnalytics` is explicitly `true` and `enablePosthog`
is not disabled.
- Kept cookie-consent handling in the same flow so consent is applied
only after PostHog is actually initialized.
- Removed the unconditional `PostHogProvider` and `posthog.init(...)`
bootstrap from the app entrypoint.
- Added targeted frontend tests covering analytics-disabled and
analytics-enabled startup behavior.

- Why the change was made
- The previous frontend bootstrap initialized PostHog before app config
was loaded, so disabling analytics in the UI or via environment settings
did not prevent PostHog network activity.
- This change makes analytics behavior follow the server-provided config
instead of always connecting on page load.

Closes #6358

---

## Checklist

### General

- [x] I have read the [Contribution
Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md)
- [x] I have read the [Stirling-PDF Developer
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md)
(if applicable)
- [ ] I have read the [How to add new languages to
Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md)
(if applicable)
- [x] I have performed a self-review of my own code
- [x] My changes generate no new warnings

### Documentation

- [ ] I have updated relevant docs on [Stirling-PDF's doc
repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/)
(if functionality has heavily changed)
- [ ] I have read the section [Add New Translation
Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags)
(for new translation tags only)

### Translations (if applicable)

- [ ] I ran
[`scripts/counter_translation.py`](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/docs/counter_translation.md)

### UI Changes (if applicable)

- [ ] Screenshots or videos demonstrating the UI changes are attached
(e.g., as comments or direct attachments in the PR)

### Testing (if applicable)

- [x] I have run `task check` to verify linters, typechecks, and tests
pass
- [x] I have tested my changes locally. Refer to the [Testing
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md#7-testing)
for more details.
2026-07-07 12:09:51 +01:00
Anthony Stirling 8e4b2e2fc6 Disable update check and notification in SaaS mode (#6863)
# Description of Changes

In SaaS mode the self-hosted "Update Available" notification could still
appear and the update-check code (external call to
`supabase.stirling.com/functions/v1/updates`) still ran, even though the
cloud owns app versioning. The web `UpdateStartupPopup` was already
SaaS-gated via a null override, but two other paths were not:

- **Desktop app in SaaS connection mode** - `useDesktopUpdatePopup()`
ran its startup check and rendered the `UpdateModal` regardless of
connection mode, so a self-hosted update popup appeared while connected
to SaaS.
- **Settings → General** - the core `GeneralSection` fired
`checkForUpdate()` on mount unconditionally, even when the update
section was hidden (as SaaS does), so the external call still ran.

**What changed**

- `useDesktopUpdatePopup.ts` - the startup timer now bails out
immediately when `connectionModeService.getCurrentMode() === "saas"`. No
mode lookup, no external fetch, no modal.
- `core/GeneralSection.tsx` - the mount `checkForUpdate()` now returns
early when `hideUpdateSection` is set, so hiding the section (web SaaS,
managed-disabled desktop) also stops the external call.
- `desktop/GeneralSection.tsx` - passes `hideUpdateSection` when
`useSaaSMode()` is true, which (via the above) suppresses the settings
check in desktop-SaaS too.

**Why** - in SaaS the update check should never be called and no update
notification should be shown; the cloud handles versioning.

---

## Checklist

### General

- [x] I have read the [Contribution
Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md)
- [ ] I have read the [Stirling-PDF Developer
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md)
(if applicable)
- [ ] I have read the [How to add new languages to
Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md)
(if applicable)
- [x] I have performed a self-review of my own code
- [x] My changes generate no new warnings

### Documentation

- [ ] I have updated relevant docs on [Stirling-PDF's doc
repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/)
(if functionality has heavily changed)
- [ ] I have read the section [Add New Translation
Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags)
(for new translation tags only)

### Translations (if applicable)

- [ ] I ran
[`scripts/counter_translation.py`](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/docs/counter_translation.md)

### UI Changes (if applicable)

- [ ] Screenshots or videos demonstrating the UI changes are attached
(e.g., as comments or direct attachments in the PR)

### Testing (if applicable)

- [x] I have run `task check` to verify linters, typechecks, and tests
pass
- [ ] I have tested my changes locally. Refer to the [Testing
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md#7-testing)
for more details.
2026-07-07 12:09:51 +01:00
Anthony Stirling 3c93457021 Fix rearrange-pages DUPLICATE producing shared page nodes (pypdf cyclic-references CI break) (#6851) 2026-07-07 12:09:51 +01:00
145 changed files with 7875 additions and 1952 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
# Maintainer: Stirling PDF Inc <contact@stirlingpdf.com>
pkgname=stirling-pdf-desktop
pkgver=2.14.1
pkgver=2.14.2
pkgrel=1
pkgdesc="Locally hosted, web-based PDF manipulation tool (Tauri desktop app, official Stirling PDF Inc build)"
arch=('x86_64')
+1 -1
View File
@@ -1,6 +1,6 @@
# Maintainer: Stirling PDF Inc <contact@stirlingpdf.com>
pkgname=stirling-pdf-server-bin
pkgver=2.14.1
pkgver=2.14.2
pkgrel=1
pkgdesc="Locally hosted, web-based PDF manipulation tool (server JAR, prebuilt)"
arch=('any')
+23 -6
View File
@@ -510,6 +510,7 @@ jobs:
# cargo output unsigned, so checking it produces false negatives.
- name: Verify Windows Code Signature
if: ${{ matrix.platform == 'windows-latest' && env.SM_API_KEY != '' && (github.event_name == 'release' || (github.event_name == 'workflow_dispatch' && github.event.inputs.sign != 'false') || github.ref == 'refs/heads/V2-master') }}
timeout-minutes: 15
shell: pwsh
run: |
$allSigned = $true
@@ -531,11 +532,26 @@ jobs:
# Extract MSI and verify the inner exe (the file that actually gets installed).
# This is the critical check - AV flags the installed exe at runtime.
# Use lessmsi, not `msiexec /a`: msiexec serializes on the global
# _MSIExecute mutex and hangs forever on hosted runners when another
# installer is busy. lessmsi reads MSI tables directly - no mutex, no service.
$msi = $msiFiles[0].FullName
$extractDir = Join-Path $env:RUNNER_TEMP "msi-verify"
if (Test-Path $extractDir) { Remove-Item $extractDir -Recurse -Force }
$proc = Start-Process msiexec.exe -ArgumentList '/a', $msi, '/qn', "TARGETDIR=$extractDir" -Wait -PassThru -NoNewWindow
if ($proc.ExitCode -eq 0) {
New-Item -ItemType Directory -Force -Path $extractDir | Out-Null
choco install lessmsi -y --no-progress --limit-output | Out-Null
# Bound the extraction and kill on hang (defence in depth over timeout-minutes).
$proc = Start-Process lessmsi -ArgumentList 'x', "`"$msi`"", "`"$extractDir\`"" -PassThru -NoNewWindow
if (-not $proc.WaitForExit(120000)) {
try { $proc.Kill() } catch {}
Write-Host "[ERROR] MSI extraction timed out after 120s"
$allSigned = $false
} elseif ($proc.ExitCode -ne 0) {
Write-Host "[ERROR] Failed to extract MSI for verification (exit code: $($proc.ExitCode))"
$allSigned = $false
} else {
$innerExe = Get-ChildItem -Path $extractDir -Filter "stirling-pdf.exe" -Recurse -File | Select-Object -First 1
if ($innerExe) {
$sig = Get-AuthenticodeSignature -FilePath $innerExe.FullName
@@ -548,9 +564,6 @@ jobs:
Write-Host "[ERROR] Could not find stirling-pdf.exe inside MSI"
$allSigned = $false
}
} else {
Write-Host "[ERROR] Failed to extract MSI for verification (exit code: $($proc.ExitCode))"
$allSigned = $false
}
if (-not $allSigned) {
@@ -800,7 +813,11 @@ jobs:
uses: softprops/action-gh-release@b4309332981a82ec1c5618f44dd2e27cc8bfbfda # v3.0.0
with:
tag_name: v${{ needs.determine-matrix.outputs.version }}
generate_release_notes: true
# Don't regenerate/append notes on re-runs, and don't force this into the
# "Latest" slot - leave the release body and latest marker as they are.
generate_release_notes: false
append_body: false
make_latest: false
fail_on_unmatched_files: true
# Installers + updater payloads + manifest. .sig contents are embedded
# in latest.json so the .sig files themselves are not uploaded.
@@ -308,6 +308,106 @@ public class ApplicationProperties {
* explicitly requests it via {@code AiEngineClient.postWithTimeout}.
*/
private int longRunningTimeoutSeconds = 600;
/** Timeout (seconds) for the SSE stream held open by long-running orchestrator runs. */
private int streamTimeoutSeconds = 1800;
/**
* Whether the processor pushes admin/settings-derived AI config (models, keys, RAG, limits)
* to the engine's {@code POST /api/v1/config} on startup and after a save. True lets a
* self-hosted admin drive the engine from the UI. Environment-driven deployments pin this
* false (SaaS does in application-saas.properties) so the engine stays entirely
* env-controlled and the processor never overrides its config.
*/
private boolean pushConfigToEngine = true;
/** Model + provider selection, forwarded to the engine per-request. */
private Models models = new Models();
/** Retrieval-augmented-generation (RAG) knobs, forwarded to the engine per-request. */
private Rag rag = new Rag();
/** Request size / cost guardrails. */
private Limits limits = new Limits();
/** Per-capability on/off switches so an admin can disable individual AI tools. */
private Features features = new Features();
@Data
public static class Models {
/** Provider driving the model strings: 'anthropic', 'openai', 'ollama', or 'custom'. */
private String provider = "anthropic";
/** High-quality tier model name (without provider prefix), e.g. 'claude-haiku-4-5'. */
private String smartModel = "claude-haiku-4-5";
/** Cheap/fast tier model name (without provider prefix). */
private String fastModel = "claude-haiku-4-5";
private int smartMaxTokens = 8192;
private int fastMaxTokens = 2048;
/**
* API key for the selected provider. Secret - masked in the admin API and overridable
* by the engine's own provider env var (e.g. ANTHROPIC_API_KEY) in environment-driven
* deployments. Empty means the engine falls back to its native env credentials.
*/
private String apiKey = "";
/**
* OpenAI-compatible base URL for 'ollama' / 'custom' providers (e.g.
* http://ollama:11434/v1). Ignored for anthropic/openai. SSRF-sensitive - admin only.
*/
private String baseUrl = "";
}
@Data
public static class Rag {
/**
* Embedding provider: 'voyageai', 'openai', 'ollama', or 'custom' (OpenAI-compatible).
*/
private String embeddingProvider = "voyageai";
/** Embedding model name (without provider prefix), e.g. 'voyage-4'. */
private String embeddingModel = "voyage-4";
/**
* Secret API key for the embedding provider; masked + env-overridable like
* models.apiKey.
*/
private String embeddingApiKey = "";
/**
* OpenAI-compatible base URL for 'ollama' / 'custom' embedding providers (e.g.
* http://ollama:11434/v1). Ignored for voyageai/openai. SSRF-sensitive - admin only.
*/
private String embeddingBaseUrl = "";
/** How many chunks retrieval returns per search. */
private int topK = 20;
/** Per-run cap on knowledge-search tool calls before the agent must answer. */
private int maxSearches = 5;
}
@Data
public static class Limits {
private int maxPages = 200;
private int maxCharacters = 200000;
/** Process-wide cap on concurrent model API calls (engine restart to apply). */
private int modelMaxConcurrency = 32;
}
@Data
public static class Features {
private boolean chat = true;
private boolean documentQuestions = true;
private boolean createPdf = true;
private boolean mathAuditor = true;
private boolean pdfComment = true;
private boolean classify = true;
}
}
/**
@@ -336,7 +336,19 @@ public class ConfigController {
configData.put("premiumEnabled", applicationProperties.getPremium().isEnabled());
// AI Engine settings
configData.put("aiEngineEnabled", applicationProperties.getAiEngine().isEnabled());
ApplicationProperties.AiEngine aiEngineConfig = applicationProperties.getAiEngine();
configData.put("aiEngineEnabled", aiEngineConfig.isEnabled());
// Per-capability flags let the UI hide individual AI tools an admin has turned off.
ApplicationProperties.AiEngine.Features aiFeatures = aiEngineConfig.getFeatures();
configData.put(
"aiFeatures",
Map.ofEntries(
Map.entry("chat", aiFeatures.isChat()),
Map.entry("documentQuestions", aiFeatures.isDocumentQuestions()),
Map.entry("createPdf", aiFeatures.isCreatePdf()),
Map.entry("mathAuditor", aiFeatures.isMathAuditor()),
Map.entry("pdfComment", aiFeatures.isPdfComment()),
Map.entry("classify", aiFeatures.isClassify())));
// Timestamp TSA settings — single source of truth for presets + admin URLs
ApplicationProperties.Security.Timestamp tsConfig =
@@ -366,6 +366,35 @@ aiEngine:
enabled: false # Set to 'true' to enable the AI engine integration
url: http://localhost:5001 # URL of the Python AI engine
timeoutSeconds: 120 # Timeout in seconds for AI engine requests
longRunningTimeoutSeconds: 600 # Timeout (seconds) for heavy operations like RAG ingestion of large documents
streamTimeoutSeconds: 1800 # SSE stream timeout (seconds) for long-running orchestrator runs
pushConfigToEngine: true # Push admin/settings AI config (models, keys, RAG, limits) to the engine on startup + save. Set false to leave the engine fully env-controlled
models:
provider: anthropic # Model provider: 'anthropic', 'openai', 'ollama', or 'custom' (OpenAI-compatible)
smartModel: claude-haiku-4-5 # High-quality tier model name (no provider prefix)
fastModel: claude-haiku-4-5 # Cheap/fast tier model name (no provider prefix)
smartMaxTokens: 8192 # Max output tokens for the smart tier
fastMaxTokens: 2048 # Max output tokens for the fast tier
apiKey: "" # API key for the selected provider (secret). Empty = engine uses its native env credentials (e.g. ANTHROPIC_API_KEY)
baseUrl: "" # OpenAI-compatible base URL for 'ollama'/'custom' providers (e.g. http://ollama:11434/v1). Ignored for anthropic/openai
rag:
embeddingProvider: voyageai # Embedding provider: 'voyageai', 'openai', 'ollama', or 'custom' (OpenAI-compatible)
embeddingModel: voyage-4 # Embedding model name (no provider prefix)
embeddingApiKey: "" # Secret API key for the embedding provider. Empty = engine uses its native env credentials (e.g. VOYAGE_API_KEY)
embeddingBaseUrl: "" # OpenAI-compatible base URL for 'ollama'/'custom' embedding providers (e.g. http://ollama:11434/v1). Ignored for voyageai/openai
topK: 20 # Number of chunks retrieval returns per search
maxSearches: 5 # Per-run cap on knowledge-search tool calls before the agent must answer
limits:
maxPages: 200 # Upper bound on PDF pages the engine will process per request
maxCharacters: 200000 # Upper bound on characters of extracted text per request
modelMaxConcurrency: 32 # Process-wide cap on concurrent model API calls (engine restart to apply)
features: # Per-capability switches; turn an individual AI tool off without disabling the whole engine
chat: true # Assistant chat
documentQuestions: true # Ask-questions-about-a-PDF
createPdf: true # Generate a PDF from a natural-language spec
mathAuditor: true # Numerical/formula contradiction auditing
pdfComment: true # AI-authored PDF comments/annotations
classify: true # Automatic document classification/labelling
policies:
# Folder automations can read from and write to the directories you allow here, so treat this as a
@@ -385,6 +414,8 @@ policies:
mcp:
enabled: false # Master switch. 'false' (default) means no /mcp endpoint, no metadata, no beans wired.
scopesEnabled: true # Enforce mcp.tools.read / mcp.tools.write scopes derived from operation category
maxRequestBytes: 10485760 # Max size (bytes) of an incoming MCP tool request payload (default 10 MB)
maxInlineResponseBytes: 10485760 # Max size (bytes) of an MCP tool response returned inline before it is rejected (default 10 MB)
allowedOperations: [] # Tool allow-list (operation ids, e.g. ['compress-pdf']). Empty = all. When set, ONLY these are exposed over MCP.
blockedOperations: [] # Tool deny-list (operation ids). Always removed from MCP even if otherwise allowed.
auth:
@@ -7,7 +7,6 @@ import java.util.concurrent.Executor;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
@@ -28,6 +27,7 @@ import jakarta.validation.Valid;
import lombok.extern.slf4j.Slf4j;
import stirling.software.common.model.ApplicationProperties;
import stirling.software.common.model.job.ResultFile;
import stirling.software.common.service.JobOwnershipService;
import stirling.software.common.service.TaskManager;
@@ -63,12 +63,11 @@ public class AiEngineController {
private final UserServiceInterface userService;
/**
* SSE emitter timeout. Long enough to accommodate multi-gigabyte PDF workflows (OCR on a
* SSE emitter timeout in ms. Long enough to accommodate multi-gigabyte PDF workflows (OCR on a
* 1000-page scan, splitting a huge PDF, etc.) without the emitter completing out from under the
* executor. Configurable via {@code stirling.ai.streamTimeoutMs}.
* executor. Derived from {@code aiEngine.streamTimeoutSeconds}.
*/
@Value("${stirling.ai.streamTimeoutMs:1800000}")
private long streamTimeoutMs;
private final long streamTimeoutMs;
public AiEngineController(
AiEngineClient aiEngineClient,
@@ -78,6 +77,7 @@ public class AiEngineController {
TaskManager taskManager,
JobOwnershipService jobOwnershipService,
AiEngineEndpointResolver endpointResolver,
ApplicationProperties applicationProperties,
@Autowired(required = false) UserServiceInterface userService) {
this.aiEngineClient = aiEngineClient;
this.aiWorkflowService = aiWorkflowService;
@@ -87,6 +87,8 @@ public class AiEngineController {
this.jobOwnershipService = jobOwnershipService;
this.endpointResolver = endpointResolver;
this.userService = userService;
this.streamTimeoutMs =
applicationProperties.getAiEngine().getStreamTimeoutSeconds() * 1000L;
}
private String currentUserId() {
@@ -36,6 +36,7 @@ import stirling.software.proprietary.classification.store.ClassificationLabelSto
import stirling.software.proprietary.model.api.ai.AiPageText;
import stirling.software.proprietary.policy.config.PolicyManagementAuthority;
import stirling.software.proprietary.service.AiEngineClient;
import stirling.software.proprietary.service.AiFeatureGate;
import stirling.software.proprietary.service.PdfContentExtractor;
import tools.jackson.databind.JsonNode;
@@ -68,6 +69,7 @@ public class ClassifyLabelController {
private final PdfContentExtractor pdfContentExtractor;
private final PdfMetadataService pdfMetadataService;
private final AiEngineClient aiEngineClient;
private final AiFeatureGate aiFeatureGate;
private final ObjectMapper objectMapper;
private final UserServiceInterface userService;
@@ -86,6 +88,7 @@ public class ClassifyLabelController {
PdfContentExtractor pdfContentExtractor,
PdfMetadataService pdfMetadataService,
AiEngineClient aiEngineClient,
AiFeatureGate aiFeatureGate,
ObjectMapper objectMapper,
@Autowired(required = false) UserServiceInterface userService,
@Autowired(required = false) ClassificationLabelStore labelStore,
@@ -95,6 +98,7 @@ public class ClassifyLabelController {
this.pdfContentExtractor = pdfContentExtractor;
this.pdfMetadataService = pdfMetadataService;
this.aiEngineClient = aiEngineClient;
this.aiFeatureGate = aiFeatureGate;
this.objectMapper = objectMapper;
this.userService = userService;
this.labelStore = labelStore;
@@ -111,6 +115,7 @@ public class ClassifyLabelController {
+ " intended for direct client use.")
public ResponseEntity<Resource> classifyAndLabel(
@RequestParam("fileInput") MultipartFile fileInput) throws IOException {
aiFeatureGate.requireClassify();
try (PDDocument document = pdfDocumentFactory.load(fileInput, true)) {
String fileName = safeFileName(fileInput.getOriginalFilename());
@@ -43,6 +43,10 @@ public class IntegrationConfigService {
private final OwnershipService ownership;
private final SecretMasker secretMasker;
private final ResourceGrantRepository grantRepository;
// Bean-discovered extension points: features that understand a type contribute its config
// schema and report what still references a config, without this module depending on them.
private final List<IntegrationConfigValidator> validators;
private final List<IntegrationConfigUsageCheck> usageChecks;
// ---- commands ----
@@ -66,13 +70,21 @@ public class IntegrationConfigService {
? DefaultAccessPolicy.EXPLICIT_ONLY
: request.defaultAccess());
// TEAM scope may omit the team id: default to the caller's own team so clients (the
// portal) need not know it. assignOwnership still enforces admin-or-leader of that team.
Long ownerTeamId = request.ownerTeamId();
if (ownerTeamId == null && scope == OwnerScope.TEAM && currentUser.getTeam() != null) {
ownerTeamId = currentUser.getTeam().getId();
}
ownership.assignOwnership(
cfg,
scope,
request.ownerTeamId(),
ownerTeamId,
currentUser,
() -> lockedServerExists(cfg.getIntegrationType()));
cfg.setConfig(writeJson(secretMasker.sanitize(request.config())));
Map<String, Object> config = secretMasker.sanitize(request.config());
validateConfig(cfg.getIntegrationType(), config);
cfg.setConfig(writeJson(config));
return repository.save(cfg);
}
@@ -101,8 +113,10 @@ public class IntegrationConfigService {
cfg.setDefaultAccess(request.defaultAccess());
}
if (request.config() != null) {
cfg.setConfig(
writeJson(secretMasker.merge(readJson(cfg.getConfig()), request.config())));
Map<String, Object> merged =
secretMasker.merge(readJson(cfg.getConfig()), request.config());
validateConfig(cfg.getIntegrationType(), merged);
cfg.setConfig(writeJson(merged));
}
return repository.save(cfg);
}
@@ -113,6 +127,15 @@ public class IntegrationConfigService {
if (!ownership.canManage(TYPE, cfg, currentUser)) {
throw forbidden("You cannot manage this integration");
}
// Refuse to pull a connection out from under whatever still references it.
List<String> usages =
usageChecks.stream()
.flatMap(check -> check.usagesOf(cfg.getId()).stream())
.toList();
if (!usages.isEmpty()) {
throw new ResponseStatusException(
HttpStatus.CONFLICT, "Integration is in use by: " + String.join(", ", usages));
}
// Drop grants sharing this config so they do not dangle as dead rows.
grantRepository.deleteByResourceTypeAndResourceId(TYPE, String.valueOf(cfg.getId()));
repository.delete(cfg);
@@ -188,6 +211,19 @@ public class IntegrationConfigService {
// ---- integration-specific glue ----
/** Runs every registered validator for the type; unknown types save free-form. */
private void validateConfig(IntegrationType type, Map<String, Object> config) {
for (IntegrationConfigValidator validator : validators) {
if (validator.type() == type) {
try {
validator.validate(config == null ? Map.of() : config);
} catch (IllegalArgumentException e) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, e.getMessage());
}
}
}
}
/** A non-admin can't create a personal config of a type an admin has locked at server scope. */
private boolean lockedServerExists(IntegrationType type) {
return repository.findByScope(OwnerScope.SERVER).stream()
@@ -0,0 +1,15 @@
package stirling.software.proprietary.integration.service;
import java.util.List;
/**
* Reports what still references an integration config, so deletion can be refused instead of
* pulling a connection out from under a live consumer. Implementations are beans discovered by
* {@link IntegrationConfigService} (e.g. the policy subsystem reporting sources and pipelines that
* reference a connection).
*/
public interface IntegrationConfigUsageCheck {
/** Human-readable labels of everything still using the config; empty when unreferenced. */
List<String> usagesOf(long configId);
}
@@ -0,0 +1,23 @@
package stirling.software.proprietary.integration.service;
import java.util.Map;
import stirling.software.proprietary.integration.model.IntegrationType;
/**
* Validates one integration type's config map at save time. Implementations are beans discovered by
* {@link IntegrationConfigService}, so the feature that understands a type (e.g. the policy S3
* backend) owns its schema without the integration module depending on it. Types with no registered
* validator save free-form.
*/
public interface IntegrationConfigValidator {
/** The type this validator understands. */
IntegrationType type();
/**
* Validates the config as it will be stored (secrets already sanitized/merged, so values are
* real, never the redaction mask). Throws {@link IllegalArgumentException} on bad config.
*/
void validate(Map<String, Object> config);
}
@@ -120,6 +120,7 @@ public class PolicyController {
throws IOException {
stampPolicyAudit(definition);
requireRunnable(definition);
validateAdHocOutput(definition);
PolicyInputs inputs = toInputs(files);
PolicyRunHandle handle =
policyRunner.runAdHoc(definition, inputs, PolicyProgressListener.NOOP);
@@ -140,6 +141,7 @@ public class PolicyController {
throws IOException {
stampPolicyAudit(definition);
requireRunnable(definition);
validateAdHocOutput(definition);
PolicyInputs inputs = toInputs(files);
SseEmitter emitter =
@@ -530,6 +532,24 @@ public class PolicyController {
}
}
/**
* Authorization-check an ad-hoc run's output while the caller's principal is present (this
* request thread). The worker thread that later delivers carries no security context, so an S3
* output's connection-access check would be skipped there; without this gate a caller could
* reference another tenant's connection by id and write to it (confused deputy). Stored
* policies are covered by save-time {@link PolicyValidator#validate} instead.
*/
private void validateAdHocOutput(PipelineDefinition definition) {
if (definition.output() == null) {
return;
}
try {
policyValidator.validateOutput(definition.output());
} catch (IllegalArgumentException e) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, e.getMessage());
}
}
/**
* Ad-hoc runs (AI / one-off pipelines) are still editor activity, so their supplied documents
* feed the same virtual editor source as stored editor policies, counted against the caller's
@@ -52,7 +52,19 @@ public class PolicyValidator {
InputSpec spec = source.toInputSpec();
inputSourceFor(spec).validate(spec);
}
outputSinkFor(policy.output()).validate(policy.output());
validateOutput(policy.output());
}
/**
* Validate an output spec against its sink. Must be called on a request thread (caller's
* principal present) so an S3 output's connection is authorization-checked against the caller -
* ad-hoc runs are never persisted and so never hit {@link #validate(Policy)}, and the worker
* thread that later delivers has no principal, so this is their only access gate.
*
* @throws IllegalArgumentException if the type is unknown or the config is invalid/inaccessible
*/
public void validateOutput(OutputSpec output) {
outputSinkFor(output).validate(output);
}
private PolicyTrigger triggerFor(TriggerConfig config) {
@@ -18,6 +18,7 @@ import stirling.software.proprietary.policy.model.InputSpec;
import stirling.software.proprietary.policy.model.PolicyInputs;
import stirling.software.proprietary.policy.s3.S3Config;
import stirling.software.proprietary.policy.s3.S3ConnectionPool;
import stirling.software.proprietary.policy.s3.S3ConnectionResolver;
import stirling.software.proprietary.policy.s3.S3Identities;
import software.amazon.awssdk.core.exception.SdkException;
@@ -36,15 +37,14 @@ import software.amazon.awssdk.services.s3.model.S3Object;
* Reads input files from an Amazon S3 (or S3-compatible) bucket; each listed object is its own unit
* of work, claimed through the {@link ResolveContext} ledger and tracked in place. Identity and
* version gate come from {@link S3Identities}, so the steady-state sweep never downloads content.
* Options (see {@link S3Config}): "bucket" (required), "region" (default us-east-1), "prefix" (only
* keys starting with it are read), "endpoint" (S3-compatible stores such as MinIO; path-style
* addressing is used automatically), "accessKeyId" and "secretAccessKey" (required; requests are
* never signed with the server's own AWS identity), and "mode" which is "consume" (default: a
* processed object is deleted once every policy that claimed it has settled successfully and it is
* still the version that ran; failures stay in place and are not retried until they change) or
* "snapshot" (stateless, every run sees the full set). Keys ending in "/" (folder placeholders) and
* keys with a dot-prefixed path segment are never picked up, mirroring the folder source's
* hidden-file rule.
* Options: "connectionId" references the stored S3 connection (an {@code IntegrationConfig} owning
* bucket, region, endpoint, and credentials - resolved by {@link S3ConnectionResolver}); "prefix"
* (only keys starting with it are read) and "mode" are per-source, where mode is "consume"
* (default: a processed object is deleted once every policy that claimed it has settled
* successfully and it is still the version that ran; failures stay in place and are not retried
* until they change) or "snapshot" (stateless, every run sees the full set). Keys ending in "/"
* (folder placeholders) and keys with a dot-prefixed path segment are never picked up, mirroring
* the folder source's hidden-file rule.
*/
@Slf4j
@Service
@@ -55,6 +55,7 @@ public class S3InputSource implements InputSource {
private static final String TYPE = "s3";
private final S3ConnectionPool connectionPool;
private final S3ConnectionResolver connectionResolver;
@Override
public String type() {
@@ -67,12 +68,12 @@ public class S3InputSource implements InputSource {
}
/**
* Fails fast at save time: bad config shape, a private endpoint without the operator opt-in, or
* a bucket the supplied credentials cannot list.
* Fails fast at save time: an unknown/disabled/unusable connection, bad config shape, a private
* endpoint without the operator opt-in, or a bucket the connection cannot list.
*/
@Override
public void validate(InputSpec spec) {
S3Config config = S3Config.from(spec.options());
S3Config config = connectionResolver.resolve(spec.options());
try {
connectionPool.clientFor(config).listObjectsV2(listRequest(config).maxKeys(1).build());
} catch (SdkException e) {
@@ -89,7 +90,7 @@ public class S3InputSource implements InputSource {
@Override
public List<ResolvedInput> resolve(InputSpec spec, ResolveContext ctx) throws IOException {
S3Config config = S3Config.from(spec.options());
S3Config config = connectionResolver.resolve(spec.options());
S3Client client = connectionPool.clientFor(config);
// A listing failure propagates so the sweep reads it as "could not list" (which vetoes
// presence cleanup), never as "verifiably no objects".
@@ -27,6 +27,7 @@ import stirling.software.proprietary.policy.ledger.ProcessedLedger;
import stirling.software.proprietary.policy.model.OutputSpec;
import stirling.software.proprietary.policy.s3.S3Config;
import stirling.software.proprietary.policy.s3.S3ConnectionPool;
import stirling.software.proprietary.policy.s3.S3ConnectionResolver;
import stirling.software.proprietary.policy.s3.S3Identities;
import software.amazon.awssdk.core.exception.SdkException;
@@ -59,6 +60,7 @@ public class S3OutputSink implements PolicyOutputSink {
private static final String TYPE = "s3";
private final S3ConnectionPool connectionPool;
private final S3ConnectionResolver connectionResolver;
private final ProcessedLedger processedLedger;
@Override
@@ -72,19 +74,19 @@ public class S3OutputSink implements PolicyOutputSink {
}
/**
* Config shape and endpoint guard only - no network probe, since write-only credentials
* (s3:PutObject without s3:ListBucket) are a legitimate setup for an output bucket and a
* listing probe would wrongly reject them.
* Connection resolution (including the saving user's right to use it) and endpoint guard only -
* no network probe, since write-only credentials (s3:PutObject without s3:ListBucket) are a
* legitimate setup for an output bucket and a listing probe would wrongly reject them.
*/
@Override
public void validate(OutputSpec spec) {
connectionPool.clientFor(S3Config.from(spec.options()));
connectionPool.clientFor(connectionResolver.resolve(spec.options()));
}
@Override
public List<ResultFile> deliver(
OutputDelivery delivery, List<Resource> outputs, OutputSpec spec) throws IOException {
S3Config config = S3Config.from(spec.options());
S3Config config = connectionResolver.resolve(spec.options());
S3Client client = connectionPool.clientFor(config);
List<ResultFile> results = new ArrayList<>();
@@ -0,0 +1,212 @@
package stirling.software.proprietary.policy.s3;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import org.springframework.boot.autoconfigure.condition.ConditionalOnBooleanProperty;
import org.springframework.boot.context.event.ApplicationReadyEvent;
import org.springframework.context.event.EventListener;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import stirling.software.proprietary.access.model.DefaultAccessPolicy;
import stirling.software.proprietary.access.model.OwnerScope;
import stirling.software.proprietary.integration.model.IntegrationConfig;
import stirling.software.proprietary.integration.model.IntegrationType;
import stirling.software.proprietary.integration.repository.IntegrationConfigRepository;
import stirling.software.proprietary.model.Team;
import stirling.software.proprietary.policy.model.OutputSpec;
import stirling.software.proprietary.policy.model.Policy;
import stirling.software.proprietary.policy.source.Source;
import stirling.software.proprietary.policy.source.SourceStore;
import stirling.software.proprietary.policy.store.PolicyStore;
import stirling.software.proprietary.security.repository.TeamRepository;
import tools.jackson.databind.ObjectMapper;
/**
* One-time, idempotent extraction of legacy embedded S3 credentials into stored connections:
* sources and policy outputs written before connections shipped carry bucket/credentials in their
* own options; this rewrites each to reference a (deduplicated) S3 {@link IntegrationConfig} and
* keeps only per-use options (prefix, mode). MUST be programmatic - the option JSON is encrypted at
* the application layer, so no SQL migration can read it.
*
* <p>Idempotent by construction: rewritten rows no longer embed credentials, so re-runs find
* nothing to do. Connections are deduplicated against both this run's extractions and existing S3
* connections; a concurrent multi-node boot can at worst create a redundant connection row, never
* corrupt a source. Ownership follows the owning row: team-scoped when the source/policy has a
* team, server-scoped otherwise (single-operator self-hosted).
*/
@Slf4j
@Component
@RequiredArgsConstructor
@ConditionalOnBooleanProperty(name = "policies.enabled")
public class EmbeddedS3CredentialMigration {
private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();
private static final List<String> CONNECTION_OPTIONS =
List.of("bucket", "region", "endpoint", "accessKeyId", "secretAccessKey");
// Field separator for the dedup key: a unit-separator control char that cannot appear in a
// bucket/region/endpoint/credential, so distinct field sets can never collide.
private static final char DELIMITER = '\u001f';
private final SourceStore sourceStore;
private final PolicyStore policyStore;
private final IntegrationConfigRepository connections;
private final TeamRepository teamRepository;
@EventListener(ApplicationReadyEvent.class)
@Transactional
public void migrate() {
Map<String, IntegrationConfig> byCredentialKey = indexExistingConnections();
int migrated = 0;
for (Source source : sourceStore.all()) {
if (!"s3".equals(source.type()) || !embedsCredentials(source.options())) {
continue;
}
IntegrationConfig connection =
connectionFor(source.options(), source.teamId(), byCredentialKey);
sourceStore.save(withOptions(source, referencing(connection, source.options(), true)));
migrated++;
}
for (Policy policy : policyStore.all()) {
OutputSpec output = policy.output();
if (!"s3".equals(output.type()) || !embedsCredentials(output.options())) {
continue;
}
IntegrationConfig connection =
connectionFor(output.options(), policy.teamId(), byCredentialKey);
policyStore.save(
withOutput(
policy,
new OutputSpec(
output.type(),
referencing(connection, output.options(), false))));
migrated++;
}
if (migrated > 0) {
log.info("Extracted embedded S3 credentials from {} row(s) into connections", migrated);
}
}
private static boolean embedsCredentials(Map<String, Object> options) {
return options.get("accessKeyId") != null;
}
/** Reuses an existing connection with identical coordinates+credentials, else creates one. */
private IntegrationConfig connectionFor(
Map<String, Object> options, Long teamId, Map<String, IntegrationConfig> byKey) {
String key = credentialKey(options);
IntegrationConfig existing = byKey.get(key);
if (existing != null) {
return existing;
}
IntegrationConfig connection = new IntegrationConfig();
connection.setIntegrationType(IntegrationType.S3);
connection.setName(connectionName(options, byKey));
connection.setEnabled(true);
connection.setLocked(false);
connection.setDefaultAccess(DefaultAccessPolicy.EXPLICIT_ONLY);
Team team = teamId == null ? null : teamRepository.findById(teamId).orElse(null);
if (team != null) {
connection.setScope(OwnerScope.TEAM);
connection.setOwnerTeam(team);
} else {
// No team (teamless self-hosted, or a source whose team was since deleted): server
// scope, i.e. admin-owned. An orphaned-team source's non-admin editor would then need
// an admin to re-share the connection - acceptable for the narrow orphaned case.
connection.setScope(OwnerScope.SERVER);
}
Map<String, Object> config = new LinkedHashMap<>();
for (String option : CONNECTION_OPTIONS) {
Object value = options.get(option);
if (value != null && !value.toString().isBlank()) {
config.put(option, value);
}
}
connection.setConfig(OBJECT_MAPPER.writeValueAsString(config));
IntegrationConfig saved = connections.save(connection);
byKey.put(key, saved);
return saved;
}
/** The rewritten options: the connection reference plus per-use settings only. */
private static Map<String, Object> referencing(
IntegrationConfig connection, Map<String, Object> legacy, boolean keepMode) {
Map<String, Object> options = new LinkedHashMap<>();
options.put(S3ConnectionResolver.CONNECTION_ID_OPTION, connection.getId());
Object prefix = legacy.get("prefix");
if (prefix != null && !prefix.toString().isBlank()) {
options.put("prefix", prefix);
}
Object mode = legacy.get("mode");
if (keepMode && mode != null && !mode.toString().isBlank()) {
options.put("mode", mode);
}
return options;
}
private Map<String, IntegrationConfig> indexExistingConnections() {
Map<String, IntegrationConfig> byKey = new LinkedHashMap<>();
for (IntegrationConfig connection : connections.findAll()) {
if (connection.getIntegrationType() != IntegrationType.S3) {
continue;
}
try {
Map<String, Object> config =
OBJECT_MAPPER.readValue(connection.getConfig(), Map.class);
byKey.putIfAbsent(credentialKey(config), connection);
} catch (Exception e) {
log.debug(
"Skipping unreadable S3 connection {} while indexing: {}",
connection.getId(),
e.getMessage());
}
}
return byKey;
}
private static String credentialKey(Map<String, Object> options) {
StringBuilder key = new StringBuilder();
for (String option : CONNECTION_OPTIONS) {
Object value = options.get(option);
key.append(value == null ? "" : value.toString().trim()).append(DELIMITER);
}
return key.toString();
}
private static String connectionName(
Map<String, Object> options, Map<String, IntegrationConfig> byKey) {
String base = "S3: " + options.getOrDefault("bucket", "bucket");
long sameName = byKey.values().stream().filter(c -> c.getName().startsWith(base)).count();
return sameName == 0 ? base : base + " (" + (sameName + 1) + ")";
}
private static Source withOptions(Source source, Map<String, Object> options) {
return new Source(
source.id(),
source.name(),
source.type(),
options,
source.enabled(),
source.owner(),
source.teamId());
}
private static Policy withOutput(Policy policy, OutputSpec output) {
return new Policy(
policy.id(),
policy.name(),
policy.owner(),
policy.enabled(),
policy.trigger(),
policy.sourceIds(),
policy.steps(),
output,
policy.teamId());
}
}
@@ -0,0 +1,56 @@
package stirling.software.proprietary.policy.s3;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import org.springframework.boot.autoconfigure.condition.ConditionalOnBooleanProperty;
import org.springframework.stereotype.Component;
import lombok.RequiredArgsConstructor;
import stirling.software.proprietary.integration.service.IntegrationConfigUsageCheck;
import stirling.software.proprietary.policy.model.Policy;
import stirling.software.proprietary.policy.source.Source;
import stirling.software.proprietary.policy.source.SourceStore;
import stirling.software.proprietary.policy.store.PolicyStore;
/**
* Reports the policy sources and pipeline outputs referencing an S3 connection, so the connection
* cannot be deleted out from under them (mirrors {@code SourceController}'s referenced-source
* delete guard). Scans in memory - fine at admin-dashboard scale, always consistent with the live
* stores.
*/
@Component
@RequiredArgsConstructor
@ConditionalOnBooleanProperty(name = "policies.enabled")
public class PolicyS3ConnectionUsageCheck implements IntegrationConfigUsageCheck {
private final SourceStore sourceStore;
private final PolicyStore policyStore;
@Override
public List<String> usagesOf(long configId) {
List<String> usages = new ArrayList<>();
for (Source source : sourceStore.all()) {
if (references(source.options(), configId)) {
usages.add("source '" + source.name() + "'");
}
}
for (Policy policy : policyStore.all()) {
if (references(policy.output().options(), configId)) {
usages.add("pipeline '" + policy.name() + "'");
}
}
return usages;
}
private static boolean references(Map<String, Object> options, long configId) {
try {
Long reference = S3ConnectionResolver.connectionId(options);
return reference != null && reference == configId;
} catch (IllegalArgumentException unparseable) {
return false;
}
}
}
@@ -5,10 +5,12 @@ import java.net.URISyntaxException;
import java.util.Map;
/**
* Connection settings shared by the S3 input source and output sink, parsed from a spec's options
* map. Credentials are required: there is deliberately no fallback to the server's own AWS
* credential chain, so user-supplied config can never borrow the host's identity. {@code snapshot}
* is input-only and ignored by the sink.
* The fully resolved connection settings the S3 input source and output sink run with - normally
* produced by {@link S3ConnectionResolver} merging a stored connection (bucket, region, endpoint,
* credentials) with per-use options (prefix, mode), or parsed directly from legacy options that
* still embed credentials. Credentials are required: there is deliberately no fallback to the
* server's own AWS credential chain, so user-supplied config can never borrow the host's identity.
* {@code snapshot} is input-only and ignored by the sink.
*/
public record S3Config(
String bucket,
@@ -0,0 +1,151 @@
package stirling.software.proprietary.policy.s3;
import java.util.LinkedHashMap;
import java.util.Map;
import org.springframework.boot.autoconfigure.condition.ConditionalOnBooleanProperty;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import stirling.software.proprietary.access.model.ResourceType;
import stirling.software.proprietary.access.service.OwnershipService;
import stirling.software.proprietary.integration.model.IntegrationConfig;
import stirling.software.proprietary.integration.model.IntegrationType;
import stirling.software.proprietary.integration.repository.IntegrationConfigRepository;
import stirling.software.proprietary.security.model.User;
import stirling.software.proprietary.security.service.UserService;
import tools.jackson.core.type.TypeReference;
import tools.jackson.databind.ObjectMapper;
/**
* Turns a source's or output's options into a full {@link S3Config} by dereferencing its {@code
* connectionId} to a stored S3 {@link IntegrationConfig} (the connection owns bucket, region,
* endpoint, and credentials; the options own per-use settings such as prefix and mode). Options
* with no {@code connectionId} fall back to legacy embedded credentials, so rows written before
* connections shipped keep working until {@link EmbeddedS3CredentialMigration} rewrites them.
*
* <p>When an authenticated caller is present (save-time validation), they must be allowed to use
* the connection. Background sweeps and deliveries run with no caller and skip that check: the
* referencing source or policy was access-checked when it was saved.
*/
@Slf4j
@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
@ConditionalOnBooleanProperty(name = "policies.enabled")
public class S3ConnectionResolver {
static final String CONNECTION_ID_OPTION = "connectionId";
private static final String PREFIX_OPTION = "prefix";
private static final String MODE_OPTION = "mode";
private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();
private final IntegrationConfigRepository connections;
private final OwnershipService ownership;
private final UserService userService;
public S3Config resolve(Map<String, Object> options) {
Long connectionId = connectionId(options);
if (connectionId == null) {
// Legacy embedded credentials, pending migration.
return S3Config.from(options);
}
IntegrationConfig connection =
connections
.findById(connectionId)
.filter(cfg -> cfg.getIntegrationType() == IntegrationType.S3)
.filter(this::usableByCurrentUser)
// Existence and access collapse into one error: a caller must not be able
// to tell "no such connection" from "someone else's connection" and
// enumerate ids. The id/name are never echoed.
.orElseThrow(
() ->
new IllegalArgumentException(
"unknown or inaccessible s3 connection"));
if (!connection.isEnabled()) {
throw new IllegalArgumentException("s3 connection is disabled");
}
Map<String, Object> merged = new LinkedHashMap<>(connectionConfig(connection));
copyPerUseOption(options, merged, PREFIX_OPTION);
copyPerUseOption(options, merged, MODE_OPTION);
return S3Config.from(merged);
}
/** The {@code connectionId} option as a long, or null when the options are legacy-embedded. */
static Long connectionId(Map<String, Object> options) {
Object reference = options.get(CONNECTION_ID_OPTION);
if (reference == null || (reference instanceof String s && s.isBlank())) {
return null;
}
if (reference instanceof Number number) {
return number.longValue();
}
try {
return Long.valueOf(reference.toString().trim());
} catch (NumberFormatException e) {
throw new IllegalArgumentException(
"s3 'connectionId' is not a valid connection reference: " + reference);
}
}
/**
* Whether the current caller may use this connection. With no principal - a background sweep or
* delivery on a worker thread that carries no {@code SecurityContext} - access is treated as
* already established: stored policies are validated with the caller present at save time, and
* ad-hoc runs are validated on the request thread before dispatch (see {@code
* PolicyValidator#validateOutput}). A missing principal must therefore never be the ONLY thing
* standing between a caller and a connection, or the check becomes a confused deputy.
*/
private boolean usableByCurrentUser(IntegrationConfig connection) {
User user = currentUser();
return user == null || ownership.canUse(ResourceType.INTEGRATION_CONFIG, connection, user);
}
// Mirrors ResourceAccessSecurity's principal resolution; null when unauthenticated.
private User currentUser() {
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
if (auth == null || !auth.isAuthenticated()) {
return null;
}
Object principal = auth.getPrincipal();
if (principal instanceof User user) {
return user;
}
if (principal instanceof UserDetails userDetails) {
return userService.findByUsername(userDetails.getUsername()).orElse(null);
}
if (principal instanceof String username && !"anonymousUser".equals(username)) {
return userService.findByUsername(username).orElse(null);
}
return null;
}
private static Map<String, Object> connectionConfig(IntegrationConfig connection) {
String json = connection.getConfig();
if (json == null || json.isBlank()) {
return Map.of();
}
try {
return OBJECT_MAPPER.readValue(
json, new TypeReference<LinkedHashMap<String, Object>>() {});
} catch (Exception e) {
throw new IllegalArgumentException(
"s3 connection '" + connection.getName() + "' has unreadable config", e);
}
}
private static void copyPerUseOption(
Map<String, Object> options, Map<String, Object> merged, String key) {
Object value = options.get(key);
if (value != null && !value.toString().isBlank()) {
merged.put(key, value);
}
}
}
@@ -0,0 +1,51 @@
package stirling.software.proprietary.policy.s3;
import java.net.URI;
import java.util.Map;
import org.springframework.boot.autoconfigure.condition.ConditionalOnBooleanProperty;
import org.springframework.stereotype.Component;
import lombok.RequiredArgsConstructor;
import stirling.software.common.model.ApplicationProperties;
import stirling.software.proprietary.cluster.s3.S3Clients;
import stirling.software.proprietary.integration.model.IntegrationType;
import stirling.software.proprietary.integration.service.IntegrationConfigValidator;
/**
* The S3 connection schema, enforced when an S3 {@link IntegrationType} config is saved: bucket and
* credentials required, endpoint an http(s) URL that must not reach private addresses without the
* operator opt-in - the same rules {@link S3ConnectionPool} enforces before signing, moved to save
* time so a bad connection fails in the form rather than in a sweep.
*/
@Component
@RequiredArgsConstructor
@ConditionalOnBooleanProperty(name = "policies.enabled")
public class S3IntegrationValidator implements IntegrationConfigValidator {
private final ApplicationProperties applicationProperties;
@Override
public IntegrationType type() {
return IntegrationType.S3;
}
@Override
public void validate(Map<String, Object> config) {
S3Config parsed = S3Config.from(config);
if (parsed.endpoint() == null) {
return;
}
try {
S3Clients.validateEndpointHost(
URI.create(parsed.endpoint()),
applicationProperties.getPolicies().isAllowPrivateS3Endpoints(),
"S3 connection endpoint",
"set policies.allowPrivateS3Endpoints=true to opt in (e.g. for a local"
+ " MinIO).");
} catch (IllegalStateException e) {
throw new IllegalArgumentException(e.getMessage(), e);
}
}
}
@@ -8,6 +8,7 @@ import java.util.ArrayList;
import java.util.Arrays;
import java.util.HashMap;
import java.util.HashSet;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
@@ -45,6 +46,7 @@ import stirling.software.common.util.RegexPatternUtils;
import stirling.software.proprietary.security.model.api.admin.SettingValueResponse;
import stirling.software.proprietary.security.model.api.admin.UpdateSettingValueRequest;
import stirling.software.proprietary.security.model.api.admin.UpdateSettingsRequest;
import stirling.software.proprietary.service.AiEngineConfigSync;
import tools.jackson.core.type.TypeReference;
import tools.jackson.databind.ObjectMapper;
@@ -58,6 +60,7 @@ public class AdminSettingsController {
private final ApplicationProperties applicationProperties;
private final ObjectMapper objectMapper;
private final ApplicationContext applicationContext;
private final AiEngineConfigSync aiEngineConfigSync;
// Track settings that have been modified but not yet applied (require restart)
private static final ConcurrentHashMap<String, Object> pendingChanges =
@@ -172,6 +175,28 @@ public class AdminSettingsController {
.body(Map.of("error", "No settings provided to update"));
}
// Work on a mutable copy so the removal below works even if the caller passed an
// immutable map, then drop masked sensitive values so a UI round-trip never overwrites
// a real secret (e.g. an API key) with the "********" placeholder from the GET
// response.
settings = new LinkedHashMap<>(settings);
settings.entrySet()
.removeIf(
e -> {
if (!"********".equals(e.getValue())) {
return false;
}
String key = e.getKey();
String leaf =
key.contains(".")
? key.substring(key.lastIndexOf('.') + 1)
: key;
return isSensitiveFieldWithPath(leaf, key);
});
if (settings.isEmpty()) {
return ResponseEntity.ok(Map.of("message", "No changed settings to update."));
}
// Validate all settings first before applying any changes
for (Map.Entry<String, Object> entry : settings.entrySet()) {
String key = entry.getKey();
@@ -206,6 +231,10 @@ public class AdminSettingsController {
pendingChanges.put(key, value != null ? value : "");
}
// If AI settings changed, push them to the engine live so model/RAG/limit changes
// apply without waiting for a processor restart.
maybePushAiEngineLive();
return ResponseEntity.ok(
Map.of(
"message",
@@ -600,6 +629,23 @@ public class AdminSettingsController {
}
}
/**
* Forward any pending {@code aiEngine.*} changes to the AI engine immediately. The running bean
* overlaid with these pending values is the current settings.yml state; {@link
* AiEngineConfigSync} no-ops unless AI is enabled and an engine-relevant key changed.
*/
private void maybePushAiEngineLive() {
Map<String, Object> aiEnginePending = new HashMap<>();
for (Map.Entry<String, Object> entry : pendingChanges.entrySet()) {
if (entry.getKey().startsWith("aiEngine.")) {
aiEnginePending.put(entry.getKey(), entry.getValue());
}
}
if (!aiEnginePending.isEmpty()) {
aiEngineConfigSync.pushLiveAfterSave(aiEnginePending);
}
}
private Object getSectionData(String sectionName) {
if (sectionName == null || sectionName.trim().isEmpty()) {
return null;
@@ -843,8 +889,13 @@ public class AdminSettingsController {
return true;
}
// Check for fields containing 'password' or 'secret'
return lowerField.contains("password") || lowerField.contains("secret");
// Substring match for secret-bearing field names. Covers provider credentials such as
// aiEngine.models.apiKey and aiEngine.rag.embeddingApiKey (which the exact-name set would
// miss) so keys are never returned to the client in cleartext.
return lowerField.contains("password")
|| lowerField.contains("secret")
|| lowerField.contains("apikey")
|| lowerField.contains("token");
}
/** Create a masked representation for sensitive fields */
@@ -103,7 +103,6 @@ public class User implements UserDetails, Serializable {
@ElementCollection
@MapKeyColumn(name = "setting_key")
@Lob
@Column(name = "setting_value", columnDefinition = "text")
@CollectionTable(name = "user_settings", joinColumns = @JoinColumn(name = "user_id"))
@JsonIgnore
@@ -0,0 +1,181 @@
package stirling.software.proprietary.service;
import java.util.Map;
import org.springframework.boot.context.event.ApplicationReadyEvent;
import org.springframework.context.event.EventListener;
import org.springframework.stereotype.Service;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import stirling.software.common.model.ApplicationProperties;
import stirling.software.common.model.ApplicationProperties.AiEngine;
import tools.jackson.databind.JsonNode;
import tools.jackson.databind.ObjectMapper;
import tools.jackson.databind.node.ObjectNode;
/**
* Pushes the admin-configured AI settings (model provider/name, per-provider API key, RAG choices,
* limits) to the Python engine so a self-hosted deployment can drive the engine's model and API key
* from the Stirling settings UI. Pushed on processor startup and again live whenever AI settings
* are saved (so model/RAG/limit changes apply without a restart). The engine applies it live
* (rebuilds its models) and caches it, so it self-restores on its own reboot. Empty
* key/baseUrl/model fields mean "keep the engine's own environment credential".
*
* <p>Gated by {@code aiEngine.pushConfigToEngine} (default true). Environment-driven deployments
* pin it false (SaaS does so in application-saas.properties) so the engine stays entirely
* env-controlled and the processor never pushes settings-derived config to it. Best-effort and
* non-blocking: a slow or unreachable engine never delays or fails Stirling startup.
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class AiEngineConfigSync {
private static final int MAX_ATTEMPTS = 5;
private static final long RETRY_DELAY_MS = 3000L;
private final ApplicationProperties applicationProperties;
private final AiEngineClient aiEngineClient;
private final ObjectMapper objectMapper;
@EventListener(ApplicationReadyEvent.class)
public void pushConfigOnStartup() {
AiEngine cfg = applicationProperties.getAiEngine();
if (!cfg.isEnabled()) {
return;
}
if (!cfg.isPushConfigToEngine()) {
log.debug(
"Skipping AI engine config push: aiEngine.pushConfigToEngine is disabled"
+ " (the engine is configured from its own environment)");
return;
}
// Engine may still be booting; push on a virtual thread with a few retries so we never
// block or crash Stirling startup when the engine is slow or briefly unreachable.
Thread.ofVirtual().name("ai-engine-config-sync").start(() -> pushWithRetries(cfg));
}
/**
* Push AI settings to the engine immediately after an admin save so model/RAG/limit changes
* reach the engine without waiting for a processor restart. {@code pendingAiEngine} are the
* pending {@code aiEngine.*} dot-notation changes; the running bean overlaid with these is the
* current settings.yml state. No-op unless AI is enabled and an engine-relevant key changed.
*/
public void pushLiveAfterSave(Map<String, Object> pendingAiEngine) {
// Gate on the RUNNING bean: AiEngineClient refuses calls while the bean is disabled, so
// pushing on a pending-but-not-restarted enable would always fail. The post-restart
// startup push covers first-time enablement.
AiEngine cfg = applicationProperties.getAiEngine();
if (pendingAiEngine == null
|| pendingAiEngine.isEmpty()
|| !cfg.isPushConfigToEngine()
|| !cfg.isEnabled()) {
return;
}
boolean engineRelevant =
pendingAiEngine.keySet().stream().anyMatch(AiEngineConfigSync::isEngineRelevantKey);
if (!engineRelevant) {
return;
}
ObjectNode node = buildConfigNode(cfg);
pendingAiEngine.forEach((k, v) -> overlayIfEngineRelevant(node, k, v));
String body = node.toString();
Thread.ofVirtual().name("ai-engine-config-live-push").start(() -> pushOnce(body));
}
private void pushOnce(String body) {
try {
aiEngineClient.post("/api/v1/config", body, null);
log.info("Pushed AI engine configuration after settings change");
} catch (Exception e) {
log.warn(
"Live AI engine config push failed: {} (will re-sync on next restart)",
e.getMessage());
}
}
// Only models/rag/limits are forwarded to the engine; enabled/url/timeouts/features are
// processor-side and don't need a live engine push.
private static boolean isEngineRelevantKey(String key) {
return key.startsWith("aiEngine.models.")
|| key.startsWith("aiEngine.rag.")
|| key.startsWith("aiEngine.limits.");
}
private void overlayIfEngineRelevant(ObjectNode node, String key, Object value) {
if (!isEngineRelevantKey(key)) {
return;
}
String[] parts = key.substring("aiEngine.".length()).split("\\.");
ObjectNode parent = node;
for (int i = 0; i < parts.length - 1; i++) {
JsonNode child = parent.get(parts[i]);
parent = (child instanceof ObjectNode on) ? on : parent.putObject(parts[i]);
}
parent.set(parts[parts.length - 1], objectMapper.valueToTree(value));
}
private void pushWithRetries(AiEngine cfg) {
String body = buildConfigNode(cfg).toString();
for (int attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
try {
aiEngineClient.post("/api/v1/config", body, null);
log.info("Pushed AI engine configuration on startup (attempt {})", attempt);
return;
} catch (Exception e) {
log.warn(
"AI engine config push failed (attempt {}/{}): {}",
attempt,
MAX_ATTEMPTS,
e.getMessage());
if (attempt < MAX_ATTEMPTS) {
try {
Thread.sleep(RETRY_DELAY_MS);
} catch (InterruptedException ie) {
Thread.currentThread().interrupt();
return;
}
}
}
}
log.warn(
"Giving up pushing AI engine configuration after {} attempts; the engine will use"
+ " its own environment configuration until the next restart.",
MAX_ATTEMPTS);
}
private ObjectNode buildConfigNode(AiEngine cfg) {
AiEngine.Models m = cfg.getModels();
AiEngine.Rag r = cfg.getRag();
AiEngine.Limits l = cfg.getLimits();
ObjectNode root = objectMapper.createObjectNode();
ObjectNode models = root.putObject("models");
models.put("provider", m.getProvider());
models.put("smartModel", m.getSmartModel());
models.put("fastModel", m.getFastModel());
models.put("smartMaxTokens", m.getSmartMaxTokens());
models.put("fastMaxTokens", m.getFastMaxTokens());
models.put("apiKey", m.getApiKey());
models.put("baseUrl", m.getBaseUrl());
ObjectNode rag = root.putObject("rag");
rag.put("embeddingProvider", r.getEmbeddingProvider());
rag.put("embeddingModel", r.getEmbeddingModel());
rag.put("embeddingApiKey", r.getEmbeddingApiKey());
rag.put("embeddingBaseUrl", r.getEmbeddingBaseUrl());
rag.put("topK", r.getTopK());
rag.put("maxSearches", r.getMaxSearches());
ObjectNode limits = root.putObject("limits");
limits.put("maxPages", l.getMaxPages());
limits.put("maxCharacters", l.getMaxCharacters());
limits.put("modelMaxConcurrency", l.getModelMaxConcurrency());
return root;
}
}
@@ -0,0 +1,58 @@
package stirling.software.proprietary.service;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Component;
import org.springframework.web.server.ResponseStatusException;
import lombok.RequiredArgsConstructor;
import stirling.software.common.model.ApplicationProperties;
import stirling.software.common.model.ApplicationProperties.AiEngine.Features;
/**
* Central gate for the per-capability AI feature switches ({@code aiEngine.features.*}). Each AI
* capability entry point calls the matching {@code require*} method so an admin who turns a feature
* off in the settings UI gets a clean 503 instead of the request silently reaching the engine. All
* checks also fail closed when the AI engine itself is disabled.
*/
@Component
@RequiredArgsConstructor
public class AiFeatureGate {
private final ApplicationProperties applicationProperties;
private Features features() {
return applicationProperties.getAiEngine().getFeatures();
}
private void require(boolean featureEnabled, String feature) {
if (!applicationProperties.getAiEngine().isEnabled() || !featureEnabled) {
throw new ResponseStatusException(
HttpStatus.SERVICE_UNAVAILABLE, "AI feature '" + feature + "' is disabled");
}
}
public void requireChat() {
require(features().isChat(), "chat");
}
public void requireDocumentQuestions() {
require(features().isDocumentQuestions(), "documentQuestions");
}
public void requireCreatePdf() {
require(features().isCreatePdf(), "createPdf");
}
public void requireMathAuditor() {
require(features().isMathAuditor(), "mathAuditor");
}
public void requirePdfComment() {
require(features().isPdfComment(), "pdfComment");
}
public void requireClassify() {
require(features().isClassify(), "classify");
}
}
@@ -32,6 +32,7 @@ import stirling.software.proprietary.classification.model.ClassificationLabels;
import stirling.software.proprietary.classification.store.InProcessClassificationLabelStore;
import stirling.software.proprietary.policy.config.PolicyManagementAuthority;
import stirling.software.proprietary.service.AiEngineClient;
import stirling.software.proprietary.service.AiFeatureGate;
import stirling.software.proprietary.service.PdfContentExtractor;
import tools.jackson.databind.JsonNode;
@@ -49,6 +50,7 @@ class ClassifyLabelControllerTest {
@Mock private PdfContentExtractor pdfContentExtractor;
@Mock private PdfMetadataService pdfMetadataService;
@Mock private AiEngineClient aiEngineClient;
@Mock private AiFeatureGate aiFeatureGate;
@Mock private PolicyManagementAuthority policyManagementAuthority;
private final ObjectMapper objectMapper = JsonMapper.builder().build();
@@ -65,6 +67,7 @@ class ClassifyLabelControllerTest {
pdfContentExtractor,
pdfMetadataService,
aiEngineClient,
aiFeatureGate,
objectMapper,
null,
labelStore,
@@ -13,9 +13,9 @@ import java.util.Map;
import java.util.Optional;
import java.util.Set;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.http.HttpStatus;
@@ -52,7 +52,58 @@ class IntegrationConfigServiceTest {
@Mock
private stirling.software.proprietary.access.repository.ResourceGrantRepository grantRepository;
@InjectMocks private IntegrationConfigService service;
@Mock private IntegrationConfigValidator validator;
@Mock private IntegrationConfigUsageCheck usageCheck;
private IntegrationConfigService service;
@BeforeEach
void setUp() {
service =
new IntegrationConfigService(
repository,
ownership,
secretMasker,
grantRepository,
List.of(validator),
List.of(usageCheck));
}
@Test
void createRejectsAConfigItsTypeValidatorRefuses() {
when(secretMasker.sanitize(any())).thenReturn(Map.of());
when(validator.type()).thenReturn(IntegrationType.API);
org.mockito.Mockito.doThrow(new IllegalArgumentException("api config needs a 'url'"))
.when(validator)
.validate(any());
assertThatThrownBy(
() ->
service.create(
request(IntegrationType.API, OwnerScope.USER, null),
user(7)))
.isInstanceOf(ResponseStatusException.class)
.satisfies(
e ->
assertThat(((ResponseStatusException) e).getStatusCode())
.isEqualTo(HttpStatus.BAD_REQUEST));
}
@Test
void deleteRefusedWhileAnythingStillReferencesTheConfig() {
IntegrationConfig cfg = config(9L);
when(repository.findById(9L)).thenReturn(Optional.of(cfg));
when(ownership.canManage(any(), eq(cfg), any())).thenReturn(true);
when(usageCheck.usagesOf(9L)).thenReturn(List.of("source 'Claims intake'"));
assertThatThrownBy(() -> service.delete(9L, user(7)))
.isInstanceOf(ResponseStatusException.class)
.satisfies(
e ->
assertThat(((ResponseStatusException) e).getStatusCode())
.isEqualTo(HttpStatus.CONFLICT));
verify(repository, org.mockito.Mockito.never()).delete(any(IntegrationConfig.class));
}
@Test
void createDelegatesOwnershipAndSanitizesConfig() {
@@ -4,6 +4,7 @@ import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.doThrow;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@@ -194,6 +195,29 @@ class PolicyControllerTest {
assertThat(((ResponseStatusException) e).getStatusCode())
.isEqualTo(HttpStatus.BAD_REQUEST));
}
@Test
@DisplayName("rejects an ad-hoc output the caller cannot use, on the request thread")
void rejectsUnauthorizedAdHocOutput() {
// The confused-deputy guard: an S3 output referencing a connection the caller may not
// use is validated here (principal present) and refused before any worker dispatch.
PipelineDefinition definition =
new PipelineDefinition(
"pipe",
List.of(new PipelineStep("/api/v1/misc/compress-pdf", null)),
new OutputSpec("s3", Map.of("connectionId", 999)));
doThrow(new IllegalArgumentException("unknown or inaccessible s3 connection"))
.when(policyValidator)
.validateOutput(any());
assertThatThrownBy(() -> controller.run(definition, new PolicyRunFiles()))
.isInstanceOf(ResponseStatusException.class)
.satisfies(
e ->
assertThat(((ResponseStatusException) e).getStatusCode())
.isEqualTo(HttpStatus.BAD_REQUEST));
verify(policyRunner, never()).runAdHoc(any(), any(), any());
}
}
@Nested
@@ -82,6 +82,28 @@ class PolicyValidatorTest {
assertTrue(ex.getMessage().contains("schedule"));
}
@Test
void validateOutputDelegatesToTheSink() {
when(outputSink.supports(any())).thenReturn(true);
OutputSpec output = new OutputSpec("s3", Map.of("connectionId", 1));
validator.validateOutput(output);
verify(outputSink).validate(output);
}
@Test
void validateOutputSurfacesAnInaccessibleConnection() {
when(outputSink.supports(any())).thenReturn(true);
doThrow(new IllegalArgumentException("unknown or inaccessible s3 connection"))
.when(outputSink)
.validate(any());
assertThrows(
IllegalArgumentException.class,
() -> validator.validateOutput(new OutputSpec("s3", Map.of("connectionId", 1))));
}
@Test
void rejectsAnUnknownTriggerType() {
when(trigger.type()).thenReturn("schedule");
@@ -23,6 +23,7 @@ import stirling.software.common.model.ApplicationProperties;
import stirling.software.proprietary.policy.ledger.InProcessProcessedLedger;
import stirling.software.proprietary.policy.model.InputSpec;
import stirling.software.proprietary.policy.s3.S3ConnectionPool;
import stirling.software.proprietary.policy.s3.S3TestConnections;
import software.amazon.awssdk.auth.credentials.AwsBasicCredentials;
import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider;
@@ -82,7 +83,9 @@ class S3InputSourceMinioTest {
// The MinIO endpoint resolves to loopback, so the operator opt-in must be on.
ApplicationProperties properties = new ApplicationProperties();
properties.getPolicies().setAllowPrivateS3Endpoints(true);
source = new S3InputSource(new S3ConnectionPool(properties));
source =
new S3InputSource(
new S3ConnectionPool(properties), S3TestConnections.legacyResolver());
ledger = new InProcessProcessedLedger();
ctx = new RecordingContext();
}
@@ -161,7 +164,9 @@ class S3InputSourceMinioTest {
@Test
void aPrivateEndpointIsRejectedWithoutTheOperatorOptIn() {
S3InputSource guarded =
new S3InputSource(new S3ConnectionPool(new ApplicationProperties()));
new S3InputSource(
new S3ConnectionPool(new ApplicationProperties()),
S3TestConnections.legacyResolver());
assertThatThrownBy(() -> guarded.validate(spec(Map.of())))
.isInstanceOf(IllegalArgumentException.class)
@@ -29,6 +29,7 @@ import stirling.software.common.model.ApplicationProperties;
import stirling.software.proprietary.policy.ledger.InProcessProcessedLedger;
import stirling.software.proprietary.policy.model.InputSpec;
import stirling.software.proprietary.policy.s3.S3ConnectionPool;
import stirling.software.proprietary.policy.s3.S3TestConnections;
import software.amazon.awssdk.core.ResponseInputStream;
import software.amazon.awssdk.core.exception.SdkClientException;
@@ -64,7 +65,8 @@ class S3InputSourceTest {
void setUp() {
source =
new S3InputSource(
new S3ConnectionPool(new ApplicationProperties(), config -> s3Client));
new S3ConnectionPool(new ApplicationProperties(), config -> s3Client),
S3TestConnections.legacyResolver());
ledger = new InProcessProcessedLedger();
ctx = new RecordingContext();
}
@@ -28,6 +28,7 @@ import stirling.software.proprietary.policy.ledger.InProcessProcessedLedger;
import stirling.software.proprietary.policy.model.InputSpec;
import stirling.software.proprietary.policy.model.OutputSpec;
import stirling.software.proprietary.policy.s3.S3ConnectionPool;
import stirling.software.proprietary.policy.s3.S3TestConnections;
import software.amazon.awssdk.auth.credentials.AwsBasicCredentials;
import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider;
@@ -90,8 +91,8 @@ class S3OutputSinkMinioTest {
properties.getPolicies().setAllowPrivateS3Endpoints(true);
S3ConnectionPool pool = new S3ConnectionPool(properties);
ledger = new InProcessProcessedLedger();
sink = new S3OutputSink(pool, ledger);
source = new S3InputSource(pool);
sink = new S3OutputSink(pool, S3TestConnections.legacyResolver(), ledger);
source = new S3InputSource(pool, S3TestConnections.legacyResolver());
}
@Test
@@ -32,6 +32,7 @@ import stirling.software.proprietary.policy.ledger.InProcessProcessedLedger;
import stirling.software.proprietary.policy.ledger.ProcessedFileStatus;
import stirling.software.proprietary.policy.model.OutputSpec;
import stirling.software.proprietary.policy.s3.S3ConnectionPool;
import stirling.software.proprietary.policy.s3.S3TestConnections;
import software.amazon.awssdk.awscore.exception.AwsServiceException;
import software.amazon.awssdk.core.exception.SdkClientException;
@@ -66,6 +67,7 @@ class S3OutputSinkTest {
sink =
new S3OutputSink(
new S3ConnectionPool(new ApplicationProperties(), config -> s3Client),
S3TestConnections.legacyResolver(),
ledger);
}
@@ -0,0 +1,212 @@
package stirling.software.proprietary.policy.s3;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.lenient;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.concurrent.atomic.AtomicLong;
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 stirling.software.proprietary.access.model.OwnerScope;
import stirling.software.proprietary.integration.model.IntegrationConfig;
import stirling.software.proprietary.integration.repository.IntegrationConfigRepository;
import stirling.software.proprietary.model.Team;
import stirling.software.proprietary.policy.model.OutputSpec;
import stirling.software.proprietary.policy.model.PipelineStep;
import stirling.software.proprietary.policy.model.Policy;
import stirling.software.proprietary.policy.source.InProcessSourceStore;
import stirling.software.proprietary.policy.source.Source;
import stirling.software.proprietary.policy.store.InProcessPolicyStore;
import stirling.software.proprietary.security.repository.TeamRepository;
/**
* Tests for {@link EmbeddedS3CredentialMigration}: legacy embedded credentials become deduplicated
* team-scoped connections, rewritten rows keep only per-use options, and re-runs are no-ops.
*/
@ExtendWith(MockitoExtension.class)
class EmbeddedS3CredentialMigrationTest {
@Mock private IntegrationConfigRepository connections;
@Mock private TeamRepository teamRepository;
private final InProcessSourceStore sourceStore = new InProcessSourceStore();
private final InProcessPolicyStore policyStore = new InProcessPolicyStore();
private EmbeddedS3CredentialMigration migration;
@BeforeEach
void setUp() {
migration =
new EmbeddedS3CredentialMigration(
sourceStore, policyStore, connections, teamRepository);
AtomicLong ids = new AtomicLong(100);
// Lenient: the nothing-to-migrate cases never create a connection.
lenient().when(connections.findAll()).thenReturn(List.of());
lenient()
.when(connections.save(any()))
.thenAnswer(
invocation -> {
IntegrationConfig saved = invocation.getArgument(0);
if (saved.getId() == null) {
saved.setId(ids.incrementAndGet());
}
return saved;
});
}
@Test
void extractsSharedCredentialsIntoOneTeamScopedConnection() {
Team team = new Team();
team.setId(7L);
when(teamRepository.findById(7L)).thenReturn(Optional.of(team));
Source source =
sourceStore.save(
new Source(
null,
"Claims intake",
"s3",
Map.of(
"bucket", "inbox",
"prefix", "incoming/",
"mode", "snapshot",
"accessKeyId", "AKIAEXAMPLE",
"secretAccessKey", "shh"),
true,
"alice",
7L));
Policy policy =
policyStore.save(
new Policy(
null,
"Rotate",
"alice",
true,
null,
List.of(source.id()),
List.of(new PipelineStep("/api/v1/misc/compress-pdf", Map.of())),
new OutputSpec(
"s3",
Map.of(
"bucket", "inbox",
"prefix", "processed/",
"accessKeyId", "AKIAEXAMPLE",
"secretAccessKey", "shh")),
7L));
migration.migrate();
// Same bucket + credentials on both rows: exactly one connection extracted.
verify(connections, times(1)).save(any());
Map<String, Object> sourceOptions = sourceStore.get(source.id()).orElseThrow().options();
assertEquals(101L, sourceOptions.get("connectionId"));
assertEquals("incoming/", sourceOptions.get("prefix"));
assertEquals("snapshot", sourceOptions.get("mode"));
assertNull(sourceOptions.get("accessKeyId"));
assertNull(sourceOptions.get("secretAccessKey"));
assertNull(sourceOptions.get("bucket"));
Map<String, Object> outputOptions =
policyStore.get(policy.id()).orElseThrow().output().options();
assertEquals(101L, outputOptions.get("connectionId"));
assertEquals("processed/", outputOptions.get("prefix"));
assertNull(outputOptions.get("secretAccessKey"));
}
@Test
void connectionOwnershipFollowsTheSourceTeam() {
Team team = new Team();
team.setId(7L);
when(teamRepository.findById(7L)).thenReturn(Optional.of(team));
sourceStore.save(s3Source("teamed", 7L));
migration.migrate();
verify(connections)
.save(
org.mockito.ArgumentMatchers.argThat(
connection ->
connection.getScope() == OwnerScope.TEAM
&& connection.getOwnerTeam() == team));
}
@Test
void teamlessRowsBecomeServerScopedConnections() {
sourceStore.save(s3Source("solo", null));
migration.migrate();
verify(connections)
.save(
org.mockito.ArgumentMatchers.argThat(
connection -> connection.getScope() == OwnerScope.SERVER));
}
@Test
void aSecondRunFindsNothingToDo() {
sourceStore.save(s3Source("once", null));
migration.migrate();
migration.migrate();
// One connection from the first run; the rewritten source no longer embeds credentials.
verify(connections, times(1)).save(any());
}
@Test
void nonS3AndAlreadyMigratedRowsAreUntouched() {
Source folder =
sourceStore.save(
new Source(
null,
"Folder",
"folder",
Map.of("directory", "/in"),
true,
"alice",
null));
Source migrated =
sourceStore.save(
new Source(
null,
"Done already",
"s3",
Map.of("connectionId", 55L, "prefix", "in/"),
true,
"alice",
null));
migration.migrate();
verify(connections, times(0)).save(any());
assertEquals(
Map.of("directory", "/in"), sourceStore.get(folder.id()).orElseThrow().options());
assertEquals(
Map.of("connectionId", 55L, "prefix", "in/"),
sourceStore.get(migrated.id()).orElseThrow().options());
}
private static Source s3Source(String name, Long teamId) {
return new Source(
null,
name,
"s3",
Map.of(
"bucket", "inbox",
"accessKeyId", "AKIAEXAMPLE",
"secretAccessKey", "shh"),
true,
"alice",
teamId);
}
}
@@ -0,0 +1,52 @@
package stirling.software.proprietary.policy.s3;
import static org.assertj.core.api.Assertions.assertThat;
import java.util.List;
import java.util.Map;
import org.junit.jupiter.api.Test;
import stirling.software.proprietary.policy.model.OutputSpec;
import stirling.software.proprietary.policy.model.PipelineStep;
import stirling.software.proprietary.policy.model.Policy;
import stirling.software.proprietary.policy.source.InProcessSourceStore;
import stirling.software.proprietary.policy.source.Source;
import stirling.software.proprietary.policy.store.InProcessPolicyStore;
/** Tests for {@link PolicyS3ConnectionUsageCheck}'s reference scan across sources and outputs. */
class PolicyS3ConnectionUsageCheckTest {
private final InProcessSourceStore sourceStore = new InProcessSourceStore();
private final InProcessPolicyStore policyStore = new InProcessPolicyStore();
private final PolicyS3ConnectionUsageCheck check =
new PolicyS3ConnectionUsageCheck(sourceStore, policyStore);
@Test
void reportsSourcesAndOutputsReferencingTheConnection() {
sourceStore.save(
new Source(
null,
"Claims intake",
"s3",
Map.of("connectionId", 5L, "prefix", "in/"),
true,
"alice",
null));
policyStore.save(
new Policy(
null,
"Rotate",
"alice",
true,
null,
List.of(),
List.of(new PipelineStep("/api/v1/misc/compress-pdf", Map.of())),
new OutputSpec("s3", Map.of("connectionId", "5")),
null));
assertThat(check.usagesOf(5))
.containsExactlyInAnyOrder("source 'Claims intake'", "pipeline 'Rotate'");
assertThat(check.usagesOf(6)).isEmpty();
}
}
@@ -0,0 +1,149 @@
package stirling.software.proprietary.policy.s3;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.when;
import java.util.Map;
import java.util.Optional;
import org.junit.jupiter.api.AfterEach;
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.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.context.SecurityContextHolder;
import stirling.software.proprietary.access.service.OwnershipService;
import stirling.software.proprietary.integration.model.IntegrationConfig;
import stirling.software.proprietary.integration.model.IntegrationType;
import stirling.software.proprietary.integration.repository.IntegrationConfigRepository;
import stirling.software.proprietary.security.model.User;
import stirling.software.proprietary.security.service.UserService;
/**
* Tests for {@link S3ConnectionResolver}: connection dereferencing with per-use overrides, the
* legacy embedded fallback, and the save-time access check that background sweeps skip.
*/
@ExtendWith(MockitoExtension.class)
class S3ConnectionResolverTest {
@Mock private IntegrationConfigRepository connections;
@Mock private OwnershipService ownership;
@Mock private UserService userService;
@AfterEach
void clearSecurityContext() {
SecurityContextHolder.clearContext();
}
@Test
void resolvesAConnectionAndMergesPerUseOptions() {
when(connections.findById(9L)).thenReturn(Optional.of(s3Connection(9L, true)));
S3Config config =
resolver()
.resolve(
Map.of(
"connectionId", 9L,
"prefix", "incoming/",
"mode", "snapshot"));
assertEquals("inbox", config.bucket());
assertEquals("AKIAEXAMPLE", config.accessKeyId());
assertEquals("incoming/", config.prefix());
assertTrue(config.snapshot());
}
@Test
void acceptsAStringConnectionReference() {
when(connections.findById(9L)).thenReturn(Optional.of(s3Connection(9L, true)));
assertEquals("inbox", resolver().resolve(Map.of("connectionId", "9")).bucket());
}
@Test
void fallsBackToLegacyEmbeddedCredentials() {
S3Config config =
resolver()
.resolve(
Map.of(
"bucket", "legacy",
"accessKeyId", "AKIAEXAMPLE",
"secretAccessKey", "shh"));
assertEquals("legacy", config.bucket());
}
@Test
void rejectsUnknownDisabledOrWrongTypeConnections() {
when(connections.findById(1L)).thenReturn(Optional.empty());
assertThrows(
IllegalArgumentException.class,
() -> resolver().resolve(Map.of("connectionId", 1L)));
when(connections.findById(2L)).thenReturn(Optional.of(s3Connection(2L, false)));
assertThrows(
IllegalArgumentException.class,
() -> resolver().resolve(Map.of("connectionId", 2L)));
IntegrationConfig mcp = s3Connection(3L, true);
mcp.setIntegrationType(IntegrationType.MCP);
when(connections.findById(3L)).thenReturn(Optional.of(mcp));
assertThrows(
IllegalArgumentException.class,
() -> resolver().resolve(Map.of("connectionId", 3L)));
}
@Test
void anAuthenticatedSaverMustBeAllowedToUseTheConnection() {
when(connections.findById(9L)).thenReturn(Optional.of(s3Connection(9L, true)));
User saver = new User();
saver.setUsername("alice");
SecurityContextHolder.getContext()
.setAuthentication(
new UsernamePasswordAuthenticationToken(saver, null, java.util.List.of()));
when(ownership.canUse(any(), any(IntegrationConfig.class), eq(saver))).thenReturn(false);
// Denied reads the same as unknown and never echoes the connection name, so ids can't be
// enumerated by probing.
assertThrows(
IllegalArgumentException.class,
() -> resolver().resolve(Map.of("connectionId", 9L)));
try {
resolver().resolve(Map.of("connectionId", 9L));
} catch (IllegalArgumentException e) {
org.junit.jupiter.api.Assertions.assertFalse(
e.getMessage().contains("Claims bucket"),
"access-denied error must not leak the connection name");
}
}
@Test
void backgroundSweepsWithNoUserSkipTheAccessCheck() {
when(connections.findById(9L)).thenReturn(Optional.of(s3Connection(9L, true)));
// No authentication in the context: resolution succeeds without consulting ownership.
assertEquals("inbox", resolver().resolve(Map.of("connectionId", 9L)).bucket());
}
private S3ConnectionResolver resolver() {
return new S3ConnectionResolver(connections, ownership, userService);
}
private static IntegrationConfig s3Connection(long id, boolean enabled) {
IntegrationConfig connection = new IntegrationConfig();
connection.setId(id);
connection.setIntegrationType(IntegrationType.S3);
connection.setName("Claims bucket");
connection.setEnabled(enabled);
connection.setConfig(
"{\"bucket\":\"inbox\",\"accessKeyId\":\"AKIAEXAMPLE\","
+ "\"secretAccessKey\":\"shh\"}");
return connection;
}
}
@@ -0,0 +1,71 @@
package stirling.software.proprietary.policy.s3;
import static org.assertj.core.api.Assertions.assertThatCode;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import java.util.Map;
import org.junit.jupiter.api.Test;
import stirling.software.common.model.ApplicationProperties;
import stirling.software.proprietary.integration.model.IntegrationType;
/**
* Tests for {@link S3IntegrationValidator}: the S3 connection schema fails at save time - missing
* credentials, bad endpoints, and private endpoints without the operator opt-in.
*/
class S3IntegrationValidatorTest {
@Test
void acceptsACompleteConnection() {
assertThatCode(
() ->
validator(false)
.validate(
Map.of(
"bucket", "inbox",
"accessKeyId", "AKIAEXAMPLE",
"secretAccessKey", "shh")))
.doesNotThrowAnyException();
}
@Test
void rejectsMissingCredentialsOrBucket() {
assertThatThrownBy(() -> validator(false).validate(Map.of("bucket", "inbox")))
.isInstanceOf(IllegalArgumentException.class);
assertThatThrownBy(
() ->
validator(false)
.validate(
Map.of(
"accessKeyId", "AKIAEXAMPLE",
"secretAccessKey", "shh")))
.isInstanceOf(IllegalArgumentException.class);
}
@Test
void rejectsAPrivateEndpointWithoutTheOperatorOptIn() {
Map<String, Object> config =
Map.of(
"bucket", "inbox",
"accessKeyId", "AKIAEXAMPLE",
"secretAccessKey", "shh",
"endpoint", "http://localhost:9000");
assertThatThrownBy(() -> validator(false).validate(config))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("allowPrivateS3Endpoints");
assertThatCode(() -> validator(true).validate(config)).doesNotThrowAnyException();
}
@Test
void itOnlyClaimsTheS3Type() {
org.junit.jupiter.api.Assertions.assertEquals(IntegrationType.S3, validator(false).type());
}
private static S3IntegrationValidator validator(boolean allowPrivateEndpoints) {
ApplicationProperties properties = new ApplicationProperties();
properties.getPolicies().setAllowPrivateS3Endpoints(allowPrivateEndpoints);
return new S3IntegrationValidator(properties);
}
}
@@ -0,0 +1,24 @@
package stirling.software.proprietary.policy.s3;
import static org.mockito.Mockito.mock;
import stirling.software.proprietary.access.service.OwnershipService;
import stirling.software.proprietary.integration.repository.IntegrationConfigRepository;
import stirling.software.proprietary.security.service.UserService;
/** Test fixtures for S3 connection plumbing shared across the policy S3 tests. */
public final class S3TestConnections {
private S3TestConnections() {}
/**
* A resolver for tests whose options embed credentials directly (the legacy pass-through path),
* so its collaborators are never touched.
*/
public static S3ConnectionResolver legacyResolver() {
return new S3ConnectionResolver(
mock(IntegrationConfigRepository.class),
mock(OwnershipService.class),
mock(UserService.class));
}
}
@@ -1,10 +1,13 @@
package stirling.software.proprietary.security.controller.api;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.argThat;
import static org.mockito.Mockito.mockStatic;
import static org.mockito.Mockito.verify;
import java.io.IOException;
import java.lang.reflect.Field;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
@@ -26,6 +29,7 @@ import stirling.software.common.util.GeneralUtils;
import stirling.software.proprietary.security.model.api.admin.SettingValueResponse;
import stirling.software.proprietary.security.model.api.admin.UpdateSettingValueRequest;
import stirling.software.proprietary.security.model.api.admin.UpdateSettingsRequest;
import stirling.software.proprietary.service.AiEngineConfigSync;
import tools.jackson.databind.ObjectMapper;
import tools.jackson.databind.json.JsonMapper;
@@ -37,6 +41,7 @@ class AdminSettingsControllerTest {
private ApplicationProperties applicationProperties;
private ObjectMapper objectMapper;
private ApplicationContext applicationContext;
private AiEngineConfigSync aiEngineConfigSync;
private AdminSettingsController controller;
@@ -45,9 +50,13 @@ class AdminSettingsControllerTest {
applicationProperties = new ApplicationProperties();
objectMapper = JsonMapper.builder().build();
applicationContext = org.mockito.Mockito.mock(ApplicationContext.class);
aiEngineConfigSync = org.mockito.Mockito.mock(AiEngineConfigSync.class);
controller =
new AdminSettingsController(
applicationProperties, objectMapper, applicationContext);
applicationProperties,
objectMapper,
applicationContext,
aiEngineConfigSync);
clearPendingChanges();
}
@@ -277,6 +286,51 @@ class AdminSettingsControllerTest {
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST);
}
}
@Test
@DisplayName("drops a masked ******** secret so a UI round-trip can't overwrite a real key")
void dropsMaskedSecretValue() {
UpdateSettingsRequest request = new UpdateSettingsRequest();
Map<String, Object> settings = new HashMap<>();
settings.put("aiEngine.models.apiKey", "********");
settings.put("ui.appName", "My App");
request.setSettings(settings);
try (MockedStatic<GeneralUtils> mocked = mockStatic(GeneralUtils.class)) {
ResponseEntity<Map<String, Object>> response = controller.updateSettings(request);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
// The masked secret is stripped; only the real change is persisted.
mocked.verify(
() ->
GeneralUtils.updateSettingsTransactional(
argThat(
(Map<String, Object> m) ->
!m.containsKey("aiEngine.models.apiKey")
&& m.containsKey("ui.appName"))));
}
}
@Test
@DisplayName("forwards only aiEngine.* pending keys to the engine live-push")
void forwardsOnlyAiEngineKeysToLivePush() {
UpdateSettingsRequest request = new UpdateSettingsRequest();
Map<String, Object> settings = new HashMap<>();
settings.put("aiEngine.models.provider", "ollama");
settings.put("ui.appName", "My App");
request.setSettings(settings);
try (MockedStatic<GeneralUtils> mocked = mockStatic(GeneralUtils.class)) {
controller.updateSettings(request);
verify(aiEngineConfigSync)
.pushLiveAfterSave(
argThat(
(Map<String, Object> m) ->
m.containsKey("aiEngine.models.provider")
&& !m.containsKey("ui.appName")));
}
}
}
@Nested
@@ -0,0 +1,150 @@
package stirling.software.proprietary.service;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.ArgumentMatchers.isNull;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.timeout;
import static org.mockito.Mockito.verify;
import java.util.Map;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import stirling.software.common.model.ApplicationProperties;
import stirling.software.common.model.ApplicationProperties.AiEngine;
import tools.jackson.databind.JsonNode;
import tools.jackson.databind.ObjectMapper;
import tools.jackson.databind.json.JsonMapper;
/**
* The config-push bridge is a self-hosted-only feature: it lets the admin UI drive the engine's
* model/key/RAG/limit config. Environment-driven deployments pin {@code
* aiEngine.pushConfigToEngine} false (SaaS does so in application-saas.properties) so the engine
* stays entirely env-controlled - these tests lock in that the processor stays silent then while
* still pushing when it is enabled.
*/
class AiEngineConfigSyncTest {
private ApplicationProperties applicationProperties;
private AiEngineClient aiEngineClient;
private AiEngineConfigSync sync;
@BeforeEach
void setUp() {
applicationProperties = new ApplicationProperties();
applicationProperties.getAiEngine().setEnabled(true);
applicationProperties.getAiEngine().setPushConfigToEngine(true);
aiEngineClient = mock(AiEngineClient.class);
ObjectMapper objectMapper = JsonMapper.builder().build();
sync = new AiEngineConfigSync(applicationProperties, aiEngineClient, objectMapper);
}
@Test
void startupPushSkippedWhenPushDisabled() throws Exception {
applicationProperties.getAiEngine().setPushConfigToEngine(false);
sync.pushConfigOnStartup();
// Returns synchronously before spawning the push thread, so no interaction ever happens.
verify(aiEngineClient, never()).post(anyString(), anyString(), isNull());
}
@Test
void startupPushSkippedWhenDisabled() throws Exception {
applicationProperties.getAiEngine().setEnabled(false);
sync.pushConfigOnStartup();
verify(aiEngineClient, never()).post(anyString(), anyString(), isNull());
}
@Test
void startupPushSentWhenEnabledAndPushOn() throws Exception {
sync.pushConfigOnStartup();
// Push runs on a virtual thread; wait for the single POST to /api/v1/config.
verify(aiEngineClient, timeout(3000)).post(eq("/api/v1/config"), anyString(), isNull());
}
@Test
void livePushSkippedWhenPushDisabled() throws Exception {
applicationProperties.getAiEngine().setPushConfigToEngine(false);
sync.pushLiveAfterSave(Map.of("aiEngine.models.provider", "ollama"));
verify(aiEngineClient, never()).post(anyString(), anyString(), isNull());
}
@Test
void livePushSentForEngineRelevantChangeWhenPushOn() throws Exception {
sync.pushLiveAfterSave(Map.of("aiEngine.models.provider", "ollama"));
verify(aiEngineClient, timeout(3000)).post(eq("/api/v1/config"), anyString(), isNull());
}
@Test
void startupPushSerialisesTheEngineWireContract() throws Exception {
// Distinct values so a dropped/renamed field is detectable. Mirrors
// engine/tests/fixtures/processor_config_push.json - keep the two in sync; the engine's
// test_config_contract.py validates that same shape on the receiving side.
AiEngine ai = applicationProperties.getAiEngine();
ai.getModels().setProvider("ollama");
ai.getModels().setSmartModel("smart-model-x");
ai.getModels().setFastModel("fast-model-x");
ai.getModels().setSmartMaxTokens(1111);
ai.getModels().setFastMaxTokens(2222);
ai.getModels().setApiKey("provider-key-abc");
ai.getModels().setBaseUrl("http://engine.example/v1");
ai.getRag().setEmbeddingProvider("custom");
ai.getRag().setEmbeddingModel("embed-model-x");
ai.getRag().setEmbeddingApiKey("embed-key-abc");
ai.getRag().setEmbeddingBaseUrl("http://embed.example/v1");
ai.getRag().setTopK(33);
ai.getRag().setMaxSearches(7);
ai.getLimits().setMaxPages(111);
ai.getLimits().setMaxCharacters(222222);
ai.getLimits().setModelMaxConcurrency(9);
ArgumentCaptor<String> body = ArgumentCaptor.forClass(String.class);
sync.pushConfigOnStartup();
verify(aiEngineClient, timeout(3000)).post(eq("/api/v1/config"), body.capture(), isNull());
JsonNode root = JsonMapper.builder().build().readTree(body.getValue());
JsonNode models = root.get("models");
assertEquals("ollama", models.get("provider").asText());
assertEquals("smart-model-x", models.get("smartModel").asText());
assertEquals("fast-model-x", models.get("fastModel").asText());
assertEquals(1111, models.get("smartMaxTokens").asInt());
assertEquals(2222, models.get("fastMaxTokens").asInt());
assertEquals("provider-key-abc", models.get("apiKey").asText());
assertEquals("http://engine.example/v1", models.get("baseUrl").asText());
JsonNode rag = root.get("rag");
assertEquals("custom", rag.get("embeddingProvider").asText());
assertEquals("embed-model-x", rag.get("embeddingModel").asText());
assertEquals("embed-key-abc", rag.get("embeddingApiKey").asText());
assertEquals("http://embed.example/v1", rag.get("embeddingBaseUrl").asText());
assertEquals(33, rag.get("topK").asInt());
assertEquals(7, rag.get("maxSearches").asInt());
JsonNode limits = root.get("limits");
assertEquals(111, limits.get("maxPages").asInt());
assertEquals(222222, limits.get("maxCharacters").asInt());
assertEquals(9, limits.get("modelMaxConcurrency").asInt());
}
@Test
void livePushSkippedForNonEngineRelevantChange() throws Exception {
// features.* is processor-side only; no engine push is warranted.
sync.pushLiveAfterSave(Map.of("aiEngine.features.chat", false));
verify(aiEngineClient, never()).post(anyString(), anyString(), isNull());
}
}
@@ -0,0 +1,54 @@
package stirling.software.proprietary.service;
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.http.HttpStatus;
import org.springframework.web.server.ResponseStatusException;
import stirling.software.common.model.ApplicationProperties;
/**
* Every AI capability entry point calls the matching {@code require*} before reaching the engine.
* These lock in fail-closed behaviour: a 503 when the engine is disabled OR the individual feature
* flag is off, and a clean pass only when both are on.
*/
class AiFeatureGateTest {
private ApplicationProperties props;
private AiFeatureGate gate;
@BeforeEach
void setUp() {
props = new ApplicationProperties();
props.getAiEngine().setEnabled(true); // features default all-on
gate = new AiFeatureGate(props);
}
@Test
void passesWhenEngineEnabledAndFeatureOn() {
assertDoesNotThrow(() -> gate.requireClassify());
assertDoesNotThrow(() -> gate.requireChat());
}
@Test
void throws503WhenFeatureFlagOff() {
props.getAiEngine().getFeatures().setClassify(false);
ResponseStatusException ex =
assertThrows(ResponseStatusException.class, () -> gate.requireClassify());
assertEquals(HttpStatus.SERVICE_UNAVAILABLE, ex.getStatusCode());
}
@Test
void throws503WhenEngineDisabledEvenIfFeatureOn() {
props.getAiEngine().setEnabled(false); // feature flag still true
ResponseStatusException ex =
assertThrows(ResponseStatusException.class, () -> gate.requireChat());
assertEquals(HttpStatus.SERVICE_UNAVAILABLE, ex.getStatusCode());
}
}
@@ -1,6 +1,12 @@
# Stirling-PDF SaaS profile. Pure multi-tenant cloud.
# Activated when the :saas module is on the classpath.
# ---------- AI engine ----------
# The SaaS AI backend is env-driven and reached through the saas AiProxyController, so the
# processor must never push settings-derived config to it. Pin the config-push off; the engine
# stays entirely environment-controlled.
aiEngine.pushConfigToEngine=false
# ---------- Datasource ----------
system.datasource.enableCustomDatabase=true
system.datasource.customDatabaseUrl=${SAAS_DB_URL:}
+1 -1
View File
@@ -91,7 +91,7 @@ springBoot {
allprojects {
group = 'stirling.software'
version = '2.14.1'
version = '2.14.2'
configurations.configureEach {
exclude group: "org.springframework.boot", module: "spring-boot-starter-tomcat"
+5
View File
@@ -88,3 +88,8 @@ STIRLING_LOG_FILE=
# Use when diagnosing worker stalls: a hung call shows a "Request" line with
# no matching "Response" line. Noisy; leave off in normal use.
STIRLING_HTTP_DEBUG=false
# Allow the Java processor to push admin-configured AI settings (model,
# credentials, limits) to POST /api/v1/config at startup. Set false in
# environment-driven deployments so the environment is the single source of truth.
#STIRLING_ALLOW_CONFIG_PUSH=true
+1
View File
@@ -4,6 +4,7 @@ version = "0.1.0"
description = "AI Document Engine"
requires-python = ">=3.13"
dependencies = [
"cryptography>=44.0.0",
"fastapi>=0.116.0",
"jinja2>=3.1.0",
"pgvector>=0.3.6",
@@ -15,6 +15,7 @@ from __future__ import annotations
from pydantic import Field
from pydantic_ai import Agent
from stirling.agents.output_mode import output_retries
from stirling.contracts import (
MathAuditorToolReportArtifact,
OrchestratorRequest,
@@ -68,6 +69,10 @@ class MathIntentClassifier:
self._agent: Agent[None, _MathIntentDecision] = Agent(
model=runtime.fast_model,
output_type=_MathIntentDecision,
# Local models emit valid structured output only intermittently (they
# sometimes wrap the JSON in prose); a few extra retries make this
# hot-path classifier reliable. No-op for real providers.
retries=output_retries(runtime.settings.chat_provider),
system_prompt=_MATH_INTENT_SYSTEM_PROMPT,
model_settings=runtime.fast_model_settings,
)
+85 -2
View File
@@ -2,12 +2,14 @@ from __future__ import annotations
import logging
from dataclasses import dataclass
from typing import assert_never
from typing import Literal, assert_never
from pydantic import ConfigDict, Field
from pydantic_ai import Agent
from pydantic_ai.output import ToolOutput
from pydantic_ai.output import NativeOutput, ToolOutput
from pydantic_ai.tools import RunContext
from stirling.agents.output_mode import output_retries, uses_tool_output
from stirling.agents.pdf_create import PdfCreateAgent
from stirling.agents.pdf_edit import PdfEditAgent
from stirling.agents.pdf_questions import PdfQuestionAgent
@@ -27,6 +29,7 @@ from stirling.contracts import (
format_file_names,
)
from stirling.contracts.pdf_create import PdfCreateOrchestrateResponse
from stirling.models import ApiModel
from stirling.services import AppRuntime
logger = logging.getLogger(__name__)
@@ -38,6 +41,38 @@ class OrchestratorDeps:
request: OrchestratorRequest
# Enum-style routing used for Ollama/custom (OpenAI-compatible local) models. The
# function-ToolOutput delegates below take no arguments (they read everything from
# ctx), but local models reliably try to pass the user's message as tool args, which
# the zero-arg delegate schemas reject. Having the model pick a capability by name and
# dispatching in Python (via the same _run_* methods used on resume) sidesteps that.
_RouteCapability = Literal["pdf_edit", "pdf_question", "user_spec", "pdf_review", "pdf_create", "unsupported"]
class _RouteDecision(ApiModel):
# Local models routinely add stray tool args (the filename, the question echoed back)
# and send null for optional fields; tolerate both so routing never fails on cosmetics.
model_config = ConfigDict(extra="ignore")
capability: _RouteCapability
message: str | None = Field(
default=None,
description="Only for capability='unsupported': a short, helpful message to show the user.",
)
_ROUTER_SYSTEM_PROMPT = (
"You are the top-level router. Choose exactly one capability that best handles the request:\n"
"- pdf_edit: modify or convert one or more attached PDFs.\n"
"- pdf_question: answer questions about the contents of the attached PDFs.\n"
"- user_spec: create or define an agent spec.\n"
"- pdf_review: return the PDF with review comments/annotations attached.\n"
"- pdf_create: generate a NEW document from scratch (invoice, report, letter) - no input file.\n"
"- unsupported: none of the above fit, or the user asks about the assistant itself; put a "
"helpful message in 'message'.\n"
"Respond with the capability and (only for unsupported) a message."
)
class OrchestratorAgent:
def __init__(self, runtime: AppRuntime) -> None:
self.runtime = runtime
@@ -86,6 +121,9 @@ class OrchestratorAgent:
description="Return this when none of the delegate outputs fit the request.",
),
],
# Local models (Ollama/custom) pick a delegate less reliably; give the
# routing a few extra validation retries. No-op for real providers.
retries=output_retries(runtime.settings.chat_provider),
deps_type=OrchestratorDeps,
system_prompt=(
"You are the top-level orchestrator. "
@@ -103,6 +141,24 @@ class OrchestratorAgent:
),
model_settings=runtime.fast_model_settings,
)
# Local models can't drive the zero-arg function-delegate routing above, so
# route them by capability name and dispatch in Python instead.
self._route_via_enum = uses_tool_output(runtime.settings.chat_provider)
# The router has no tools of its own, so NativeOutput (a direct structured
# response) works on Ollama here - unlike tool-using agents, which need
# ToolOutput. A single output tool would tempt a local model to answer in
# plain text and never call it ("include your response in a tool call").
self._router = (
Agent(
model=runtime.fast_model,
output_type=NativeOutput([_RouteDecision]),
retries=output_retries(runtime.settings.chat_provider),
system_prompt=_ROUTER_SYSTEM_PROMPT,
model_settings=runtime.fast_model_settings,
)
if self._route_via_enum
else None
)
async def handle(self, request: OrchestratorRequest) -> OrchestratorResponse:
logger.info(
@@ -114,6 +170,8 @@ class OrchestratorAgent:
)
if request.resume_with is not None:
return await self._resume(request, request.resume_with)
if self._router is not None:
return await self._route_and_dispatch(request)
result = await self.agent.run(
self._build_prompt(request),
deps=OrchestratorDeps(runtime=self.runtime, request=request),
@@ -121,6 +179,31 @@ class OrchestratorAgent:
logger.info("[orchestrator] routed -> %s", type(result.output).__name__)
return result.output
async def _route_and_dispatch(self, request: OrchestratorRequest) -> OrchestratorResponse:
"""Local-model routing: pick a capability by name, then dispatch in Python."""
assert self._router is not None
result = await self._router.run(self._build_prompt(request))
decision = result.output
logger.info("[orchestrator] enum-routed -> %s", decision.capability)
match decision.capability:
case "pdf_edit":
return await self._run_pdf_edit(request)
case "pdf_question":
return await self._run_pdf_question(request)
case "user_spec":
return await self._run_agent_draft(request)
case "pdf_review":
return await self._run_pdf_review(request)
case "pdf_create":
return await self._run_pdf_create(request)
case "unsupported":
return UnsupportedCapabilityResponse(
capability="orchestrate",
message=decision.message or "I can't help with that request.",
)
case _ as unreachable:
assert_never(unreachable)
async def _resume(self, request: OrchestratorRequest, capability: SupportedCapability) -> OrchestratorResponse:
"""Fast-path to get back to the correct endpoint without having to call AI.
+46
View File
@@ -0,0 +1,46 @@
"""Provider-aware structured-output strategy.
OpenAI-compatible local models (Ollama, and 'custom' OpenAI-compatible endpoints)
block tool-calling whenever a native json-schema ``response_format`` is set: the
API constrains every completion to the output schema, so the model can never emit
a tool-call turn. Agents that combine structured output with tools (RAG search,
whole-document read, etc.) therefore get an ungrounded answer - the model fills the
schema directly instead of calling a retrieval tool.
Delivering the structured result via a tool call (:class:`ToolOutput`) sidesteps
this: it's all tool-calling, which these endpoints handle, so the model can call a
retrieval tool and then the output tool. Real providers (Anthropic, OpenAI) keep
:class:`NativeOutput`, which is unaffected and preferred there.
"""
from __future__ import annotations
from collections.abc import Sequence
from typing import Any
from pydantic_ai.output import NativeOutput, ToolOutput
# Providers whose OpenAI-compatible endpoint needs tool-delivered structured output.
_TOOL_OUTPUT_PROVIDERS = frozenset({"ollama", "custom"})
def uses_tool_output(chat_provider: str) -> bool:
return chat_provider in _TOOL_OUTPUT_PROVIDERS
def structured_output(output_types: Sequence[Any], *, chat_provider: str) -> Any:
"""Pick a structured-output spec compatible with the active chat provider.
Ollama/custom deliver each output variant via a :class:`ToolOutput`; every other
provider uses a single :class:`NativeOutput` over the variants.
"""
types = list(output_types)
if uses_tool_output(chat_provider):
return [ToolOutput(t) for t in types]
return NativeOutput(types)
def output_retries(chat_provider: str, *, native: int = 1, tool: int = 6) -> int:
"""Local models delivering via ToolOutput need more output-validation retries -
small models produce valid complex/nested structured output only intermittently."""
return tool if uses_tool_output(chat_provider) else native
+10 -2
View File
@@ -3,10 +3,10 @@ from __future__ import annotations
import logging
from pydantic_ai import Agent
from pydantic_ai.output import NativeOutput
from stirling.agents.contradiction import ContradictionCapability, ContradictionDetector
from stirling.agents.math_presentation import MathIntentClassifier, extract_math_verdict
from stirling.agents.output_mode import output_retries, structured_output
from stirling.agents.shared import ChunkedReasoner, WholeDocReaderCapability
from stirling.contracts import (
AiFile,
@@ -196,9 +196,17 @@ class PdfQuestionAgent:
files=request.files,
principals=principals,
)
# Ollama/custom (OpenAI-compatible local) models block tool-calling under a
# native json-schema response format, so deliver the structured result via a
# tool call instead - otherwise the model never calls the retrieval tools and
# answers ungrounded. Real providers keep NativeOutput. See agents.output_mode.
provider = self.runtime.settings.chat_provider
agent = Agent(
model=self.runtime.smart_model,
output_type=NativeOutput([PdfQuestionAnswerResponse, PdfQuestionNotFoundResponse]),
output_type=structured_output(
[PdfQuestionAnswerResponse, PdfQuestionNotFoundResponse], chat_provider=provider
),
retries=output_retries(provider),
system_prompt=PDF_QUESTION_SYSTEM_PROMPT,
# pydantic-ai accepts a list of (string-or-callable) instruction sources;
# it resolves each at run time and concatenates them for the model.
+59 -27
View File
@@ -3,28 +3,20 @@ from __future__ import annotations
import asyncio
import logging
from contextlib import asynccontextmanager
from typing import Annotated
from fastapi import Depends, FastAPI
from fastapi import Depends, FastAPI, Request
from pydantic_ai import Agent
from pydantic_ai.models import Model
from pydantic_ai.models.instrumented import InstrumentationSettings
from stirling.agents import (
DocumentClassifierAgent,
ExecutionPlanningAgent,
OrchestratorAgent,
PdfEditAgent,
PdfQuestionAgent,
UserSpecAgent,
)
from stirling.agents.ledger import MathAuditorAgent
from stirling.agents.pdf_comment import PdfCommentAgent
from stirling.api.bootstrap import apply_app_state, build_app_state
from stirling.api.dependencies import enforce_required_user_id
from stirling.api.engine_auth import EngineSharedSecretMiddleware
from stirling.api.middleware import UserIdMiddleware
from stirling.api.routes import (
agent_capabilities_router,
agent_draft_router,
config_router,
document_classifier_router,
document_router,
execution_router,
@@ -34,10 +26,12 @@ from stirling.api.routes import (
pdf_edit_router,
pdf_question_router,
)
from stirling.api.routes.config import CONFIG_APPLY_ERRORS, resolve_and_apply
from stirling.config import AppSettings, load_settings
from stirling.config.config_cache import load_config
from stirling.contracts import HealthResponse
from stirling.documents import DocumentService
from stirling.services import build_runtime, setup_posthog_tracking
from stirling.documents import DocumentService, EmbeddingService
from stirling.services import setup_posthog_tracking
logger = logging.getLogger(__name__)
@@ -83,22 +77,52 @@ def _load_startup_settings(fast_api: FastAPI) -> AppSettings:
return load_settings()
def _restore_cached_config(
settings: AppSettings,
) -> tuple[AppSettings, Model | None, Model | None, EmbeddingService | None]:
"""Restore the last-applied pushed config from the encrypted on-disk cache.
Returns the effective settings plus the pre-built smart/fast models and
embedder to inject into the initial app state. Falls back to the env settings
(all-None) when config push is disabled, no cache exists, or the cached config
can't be applied (bad/unavailable model) - the cache never crashes boot.
"""
if not settings.allow_config_push:
return settings, None, None, None
cached = load_config()
if cached is None:
return settings, None, None, None
try:
effective, smart_model, fast_model, embedder, notes = resolve_and_apply(settings, cached)
except CONFIG_APPLY_ERRORS:
logger.warning("Cached AI config could not be applied; falling back to env settings", exc_info=True)
return settings, None, None, None
logger.info(
"Restored cached AI config: smart_model=%s fast_model=%s%s",
effective.smart_model_name,
effective.fast_model_name,
f"; {'; '.join(notes)}" if notes else "",
)
return effective, smart_model, fast_model, embedder
@asynccontextmanager
async def lifespan(fast_api: FastAPI):
# Load env vars on startup so we can immediately crash if required env vars aren't set
settings = _load_startup_settings(fast_api)
runtime = build_runtime(settings)
fast_api.state.settings = settings
fast_api.state.runtime = runtime
fast_api.state.orchestrator_agent = OrchestratorAgent(runtime)
fast_api.state.pdf_edit_agent = PdfEditAgent(runtime)
fast_api.state.pdf_question_agent = PdfQuestionAgent(runtime)
fast_api.state.user_spec_agent = UserSpecAgent(runtime)
fast_api.state.execution_planning_agent = ExecutionPlanningAgent(runtime)
fast_api.state.math_auditor_agent = MathAuditorAgent(runtime)
fast_api.state.pdf_comment_agent = PdfCommentAgent(runtime)
fast_api.state.document_classifier_agent = DocumentClassifierAgent(runtime)
tracer_provider = setup_posthog_tracking(settings)
# Precedence: env < persisted cache < live push. Restore the last-applied pushed
# config unless config push is disabled (then env is the single source of truth).
effective, smart_model, fast_model, embedder = _restore_cached_config(settings)
app_state = build_app_state(
effective,
fast_model=fast_model,
smart_model=smart_model,
embedder=embedder,
)
fast_api.state.settings = effective
apply_app_state(fast_api.state, app_state)
runtime = app_state.runtime
tracer_provider = setup_posthog_tracking(effective)
if tracer_provider:
Agent.instrument_all(InstrumentationSettings(tracer_provider=tracer_provider))
reaper_task = asyncio.create_task(
@@ -135,10 +159,18 @@ app.include_router(ledger_router, dependencies=_user_gate)
app.include_router(pdf_comments_router, dependencies=_user_gate)
app.include_router(agent_capabilities_router, dependencies=_user_gate)
app.include_router(document_classifier_router, dependencies=_user_gate)
# Config push is a system/admin sync from the Java processor with no X-User-Id, so
# it is guarded by the X-Engine-Auth shared secret (global middleware) and the
# allow_config_push flag only, deliberately NOT the per-user identity gate.
app.include_router(config_router)
@app.get("/health", response_model=HealthResponse)
async def healthcheck(settings: Annotated[AppSettings, Depends(load_settings)]) -> HealthResponse:
async def healthcheck(http_request: Request) -> HealthResponse:
# Report the LIVE config (env < cache < push) held on app.state, not the
# boot-time env cache, so an admin "Test connection" check shows the model
# actually in use after a config push. Falls back to env if state isn't up yet.
settings: AppSettings = getattr(http_request.app.state, "settings", None) or load_settings()
return HealthResponse(
status="ok",
smart_model=settings.smart_model_name,
+86
View File
@@ -0,0 +1,86 @@
"""Assemble the runtime + every agent into a single swappable bundle.
Both the lifespan startup and the config-push endpoint build the same object
graph: one :class:`AppRuntime` plus one agent instance per flow, each of which
captures ``runtime.smart_model``/``runtime.fast_model`` at construction. To change
models at runtime the whole bundle has to be rebuilt and swapped into
``app.state`` atomically, which is what :func:`build_app_state` /
:func:`apply_app_state` exist for.
"""
from __future__ import annotations
from dataclasses import dataclass, fields
from typing import Any
from pydantic_ai.models import Model
from stirling.agents import (
DocumentClassifierAgent,
ExecutionPlanningAgent,
OrchestratorAgent,
PdfEditAgent,
PdfQuestionAgent,
UserSpecAgent,
)
from stirling.agents.ledger import MathAuditorAgent
from stirling.agents.pdf_comment import PdfCommentAgent
from stirling.config import AppSettings
from stirling.documents import DocumentService, EmbeddingService
from stirling.services import AppRuntime, build_runtime
@dataclass(frozen=True)
class AppState:
"""Every object the lifespan assigns onto ``fast_api.state``."""
runtime: AppRuntime
orchestrator_agent: OrchestratorAgent
pdf_edit_agent: PdfEditAgent
pdf_question_agent: PdfQuestionAgent
user_spec_agent: UserSpecAgent
execution_planning_agent: ExecutionPlanningAgent
math_auditor_agent: MathAuditorAgent
pdf_comment_agent: PdfCommentAgent
document_classifier_agent: DocumentClassifierAgent
def build_app_state(
settings: AppSettings,
*,
documents: DocumentService | None = None,
fast_model: Model | None = None,
smart_model: Model | None = None,
embedder: EmbeddingService | None = None,
) -> AppState:
"""Build the runtime and every agent from ``settings``.
The keyword arguments are threaded straight to :func:`build_runtime` so a
config-push rebuild can reuse the live document store and inject already
validated models (and, on a cache-restore boot, a pre-built embedder); all
None reproduces the original startup path.
"""
runtime = build_runtime(
settings,
documents=documents,
fast_model=fast_model,
smart_model=smart_model,
embedder=embedder,
)
return AppState(
runtime=runtime,
orchestrator_agent=OrchestratorAgent(runtime),
pdf_edit_agent=PdfEditAgent(runtime),
pdf_question_agent=PdfQuestionAgent(runtime),
user_spec_agent=UserSpecAgent(runtime),
execution_planning_agent=ExecutionPlanningAgent(runtime),
math_auditor_agent=MathAuditorAgent(runtime),
pdf_comment_agent=PdfCommentAgent(runtime),
document_classifier_agent=DocumentClassifierAgent(runtime),
)
def apply_app_state(state: Any, app_state: AppState) -> None:
"""Copy every field of ``app_state`` onto a Starlette ``app.state`` object."""
for field in fields(app_state):
setattr(state, field.name, getattr(app_state, field.name))
@@ -1,5 +1,6 @@
from .agent_capabilities import router as agent_capabilities_router
from .agent_drafts import router as agent_draft_router
from .config import router as config_router
from .document_classifier import router as document_classifier_router
from .documents import router as document_router
from .execution import router as execution_router
@@ -12,6 +13,7 @@ from .pdf_questions import router as pdf_question_router
__all__ = [
"agent_capabilities_router",
"agent_draft_router",
"config_router",
"document_classifier_router",
"document_router",
"execution_router",
+251
View File
@@ -0,0 +1,251 @@
from __future__ import annotations
import ipaddress
import logging
from fastapi import APIRouter, HTTPException, Request, status
from openai import OpenAIError
from pydantic_ai.exceptions import UserError
from pydantic_ai.models import Model
from stirling.api.bootstrap import apply_app_state, build_app_state
from stirling.config import AppSettings
from stirling.config.config_cache import save_config
from stirling.contracts import ConfigApplyResponse, ConfigPushRequest
from stirling.documents import EmbeddingService
from stirling.services import AppRuntime
from stirling.services.runtime import _build_model, validate_structured_output_support
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/v1/config", tags=["config"])
# Model/provider construction + validation failures. The HTTP route maps these to a
# 400 (no swap); boot catches them to fall back to env when a cached config is bad.
CONFIG_APPLY_ERRORS = (ValueError, UserError, OpenAIError)
_REINDEX_NOTE = (
"Embedding model changed; existing indexed documents were embedded with the previous model and "
"must be re-indexed. If the embedding dimensionality changed, re-ingest before searching."
)
def _strip_provider_prefix(model_name: str) -> str:
"""Drop a leading ``provider:`` from an env model string ("anthropic:x" -> "x").
Only used when falling back to the env-configured name under an explicit
pushed provider; a bare pushed name (which may itself contain ``:`` such as
"llama3:8b") is used verbatim and never passed through here.
"""
_, sep, rest = model_name.partition(":")
return rest if sep else model_name
def _compose_embedding_model(provider: str, model: str) -> str:
"""Compose the engine's ``provider:model`` embedding string from pushed parts."""
provider = provider.strip()
return f"{provider}:{model}" if provider else model
def _split_embedding_ref(ref: str) -> tuple[str, str]:
"""Split an env embedding string ("voyageai:voyage-4") into (provider, model)."""
provider, sep, model = ref.partition(":")
return (provider, model) if sep else ("", ref)
def _keep(pushed: int | None, current: int) -> int:
"""Return the pushed value, or the current one when the push omitted it."""
return pushed if pushed is not None else current
def _is_loopback_client(request: Request) -> bool:
"""True when the request originates from the local host (127.0.0.0/8, ::1)."""
client = request.client
if client is None:
return False
try:
return ipaddress.ip_address(client.host).is_loopback
except ValueError:
return client.host == "localhost"
def resolve_and_apply(
current: AppSettings,
request: ConfigPushRequest,
) -> tuple[AppSettings, Model, Model, EmbeddingService | None, list[str]]:
"""Resolve a pushed config against the running settings.
Builds and validates the smart/fast models, resolves the scalar overrides,
and builds a new embedder when the embedding config changed. Empty/None pushed
fields keep the current value. Returns the effective settings, the two built
models, an optional new embedder (None when the embedding config is unchanged),
and human-readable notes.
Raises one of :data:`CONFIG_APPLY_ERRORS` if a chosen model/embedder fails to
build or validate; callers decide whether that becomes a 400 (HTTP route) or an
env fallback (boot). It never swaps any live state - the caller owns that.
"""
models = request.models
rag = request.rag
limits = request.limits
notes: list[str] = []
provider = models.provider.strip()
api_key = models.api_key
base_url = models.base_url
use_explicit_provider = bool(provider or api_key or base_url)
if use_explicit_provider:
smart_name = models.smart_model or _strip_provider_prefix(current.smart_model_name)
fast_name = models.fast_model or _strip_provider_prefix(current.fast_model_name)
else:
# No provider/credentials pushed: keep the fully env-driven model strings.
smart_name = models.smart_model or current.smart_model_name
fast_name = models.fast_model or current.fast_model_name
def _build(bare: str) -> Model:
if use_explicit_provider:
return _build_model(bare, provider=provider or None, api_key=api_key or None, base_url=base_url or None)
return _build_model(bare)
smart_model = _build(smart_name)
fast_model = _build(fast_name)
validate_structured_output_support(smart_model, smart_name)
validate_structured_output_support(fast_model, fast_name)
# Scalars: None / empty keep the current value.
smart_max_tokens = _keep(models.smart_max_tokens, current.smart_model_max_tokens)
fast_max_tokens = _keep(models.fast_max_tokens, current.fast_model_max_tokens)
top_k = _keep(rag.top_k, current.rag_default_top_k)
max_searches = _keep(rag.max_searches, current.rag_max_searches)
max_pages = _keep(limits.max_pages, current.max_pages)
max_characters = _keep(limits.max_characters, current.max_characters)
model_max_concurrency = _keep(limits.model_max_concurrency, current.model_max_concurrency)
# Embedding: any non-empty embedding field triggers a rebuild; empty fields fall
# back to the running provider/model/creds so a partial push never clobbers env.
embedding_changed = bool(
rag.embedding_provider.strip() or rag.embedding_model.strip() or rag.embedding_api_key or rag.embedding_base_url
)
rag_embedding_model = current.rag_embedding_model
new_embedder: EmbeddingService | None = None
if embedding_changed:
current_provider, current_model = _split_embedding_ref(current.rag_embedding_model)
embed_provider = rag.embedding_provider.strip() or current_provider
embed_model = rag.embedding_model.strip() or current_model
rag_embedding_model = _compose_embedding_model(embed_provider, embed_model)
new_embedder = EmbeddingService(
model_name=embed_model,
chunk_size=current.rag_chunk_size,
chunk_overlap=current.rag_chunk_overlap,
provider=embed_provider or None,
api_key=rag.embedding_api_key or None,
base_url=rag.embedding_base_url or None,
)
notes.append(_REINDEX_NOTE)
effective = current.model_copy(
update={
"chat_provider": provider,
"smart_model_name": smart_name,
"fast_model_name": fast_name,
"smart_model_max_tokens": smart_max_tokens,
"fast_model_max_tokens": fast_max_tokens,
"rag_embedding_model": rag_embedding_model,
"rag_default_top_k": top_k,
"rag_max_searches": max_searches,
"max_pages": max_pages,
"max_characters": max_characters,
"model_max_concurrency": model_max_concurrency,
}
)
return effective, smart_model, fast_model, new_embedder, notes
@router.post("", response_model=ConfigApplyResponse)
async def apply_config(request: ConfigPushRequest, http_request: Request) -> ConfigApplyResponse:
"""Apply admin-pushed AI settings by rebuilding the runtime + agents in place.
Gated by the X-Engine-Auth shared secret (global middleware) plus the
``allow_config_push`` flag. Empty credential/model fields keep the engine's
env-configured values. Returns 403 when config push is disabled, 400 (without
swapping state) when a chosen model fails to build/validate. On success the
config is also persisted (encrypted) so it survives an engine restart.
"""
app = http_request.app
current: AppSettings = app.state.settings
if not current.allow_config_push:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Config push is disabled on this deployment (STIRLING_ALLOW_CONFIG_PUSH is false).",
)
# Secure-by-default for this sensitive endpoint. When a shared secret is set the
# global middleware has already authenticated the caller (only the processor has the
# secret). When NO secret is set, only trust a loopback caller - a remote party must
# never be able to push a config unauthenticated, since a pushed base_url/model could
# repoint the engine to exfiltrate document content.
if not current.engine_shared_secret and not _is_loopback_client(http_request):
client_host = http_request.client.host if http_request.client else "unknown"
logger.warning("Rejected config push from non-local caller %s with no shared secret set", client_host)
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=(
"Config push from a non-local caller requires STIRLING_ENGINE_SHARED_SECRET"
" to be set on both the engine and the processor."
),
)
runtime: AppRuntime = app.state.runtime
try:
effective, smart_model, fast_model, new_embedder, notes = resolve_and_apply(current, request)
except CONFIG_APPLY_ERRORS as exc:
# Reject without touching the running config.
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc
# Rebuild runtime + every agent, reusing the existing document store, then
# swap the whole bundle onto app.state so in-flight lookups see one config.
new_state = build_app_state(
effective,
documents=runtime.documents,
fast_model=fast_model,
smart_model=smart_model,
)
app.state.settings = effective
apply_app_state(app.state, new_state)
# Retune retrieval breadth on the reused store without rebuilding it.
runtime.documents.default_top_k = effective.rag_default_top_k
if new_embedder is not None:
# Swap the embedder onto the reused DocumentService so we never tear down
# the live vector store / connection pool.
runtime.documents.embedder = new_embedder
# Persist the applied config (encrypted) so it is restored on the next boot.
try:
save_config(request)
except OSError:
logger.warning("Applied AI config but failed to persist the encrypted cache", exc_info=True)
notes.append("Config applied but could not be persisted; it will not survive an engine restart.")
logger.info(
"Applied pushed AI config: provider=%s smart_model=%s fast_model=%s top_k=%s",
request.models.provider.strip() or "<env>",
effective.smart_model_name,
effective.fast_model_name,
effective.rag_default_top_k,
)
return ConfigApplyResponse(
status="applied",
provider=request.models.provider.strip(),
smart_model=effective.smart_model_name,
fast_model=effective.fast_model_name,
smart_max_tokens=effective.smart_model_max_tokens,
fast_max_tokens=effective.fast_model_max_tokens,
rag_embedding_model=effective.rag_embedding_model,
rag_top_k=effective.rag_default_top_k,
rag_max_searches=effective.rag_max_searches,
max_pages=effective.max_pages,
max_characters=effective.max_characters,
model_max_concurrency=effective.model_max_concurrency,
notes=notes,
)
+114
View File
@@ -0,0 +1,114 @@
"""Persistent, encrypted cache of the last-applied config-push.
The engine otherwise holds pushed config in RAM only, so a restart reverts to the
env values until the processor pushes again. We persist the last-applied
:class:`ConfigPushRequest` to ``<engine data dir>/ai_config_cache.enc`` (Fernet
encrypted) and reload it on boot so the effective config survives restarts.
Key derivation: when ``STIRLING_ENGINE_SHARED_SECRET`` is set, the Fernet key is
HKDF-SHA256 over that secret (constant salt/info) so no key material touches disk.
Otherwise a random Fernet key is generated once and stored in a sibling
``ai_config_cache.key`` (0600, best-effort) with a one-time warning.
"""
from __future__ import annotations
import base64
import logging
import os
import stat
from pathlib import Path
from cryptography.fernet import Fernet, InvalidToken
from cryptography.hazmat.primitives import hashes
from cryptography.hazmat.primitives.kdf.hkdf import HKDF
from stirling.config.settings import ENGINE_ROOT, load_settings
from stirling.contracts import ConfigPushRequest
logger = logging.getLogger(__name__)
_CACHE_FILENAME = "ai_config_cache.enc"
_KEY_FILENAME = "ai_config_cache.key"
# Constant salt/info so the same shared secret always derives the same Fernet key.
_HKDF_SALT = b"stirling-ai-config-cache/v1/salt"
_HKDF_INFO = b"stirling-ai-config-cache/v1/fernet-key"
_keyfile_warned = False
def _default_data_dir() -> Path:
"""The engine data dir (where the sqlite store lives by default)."""
return ENGINE_ROOT / "data"
def _shared_secret() -> str:
return load_settings().engine_shared_secret
def _derive_key_from_secret(secret: str) -> bytes:
hkdf = HKDF(algorithm=hashes.SHA256(), length=32, salt=_HKDF_SALT, info=_HKDF_INFO)
return base64.urlsafe_b64encode(hkdf.derive(secret.encode("utf-8")))
def _chmod_600(path: Path) -> None:
# Best-effort owner-only perms; silently ignored where unsupported (e.g. Windows).
try:
os.chmod(path, stat.S_IRUSR | stat.S_IWUSR)
except OSError:
pass
def _load_or_create_keyfile(data_dir: Path) -> bytes:
global _keyfile_warned
key_path = data_dir / _KEY_FILENAME
if key_path.exists():
return key_path.read_bytes().strip()
key = Fernet.generate_key()
data_dir.mkdir(parents=True, exist_ok=True)
key_path.write_bytes(key)
_chmod_600(key_path)
if not _keyfile_warned:
logger.warning(
"STIRLING_ENGINE_SHARED_SECRET is not set; encrypting the AI config cache with a local"
" keyfile at %s (0600, best-effort). This is modest protection only - set a shared secret"
" for HKDF key derivation in any deployment where the cache must be strongly protected.",
key_path,
)
_keyfile_warned = True
return key
def _fernet(data_dir: Path) -> Fernet:
secret = _shared_secret()
if secret:
return Fernet(_derive_key_from_secret(secret))
return Fernet(_load_or_create_keyfile(data_dir))
def save_config(request: ConfigPushRequest, *, data_dir: Path | None = None) -> None:
"""Encrypt and persist the last-applied pushed config, overwriting any prior file."""
data_dir = data_dir or _default_data_dir()
data_dir.mkdir(parents=True, exist_ok=True)
payload = request.model_dump_json(by_alias=True).encode("utf-8")
token = _fernet(data_dir).encrypt(payload)
(data_dir / _CACHE_FILENAME).write_bytes(token)
def load_config(*, data_dir: Path | None = None) -> ConfigPushRequest | None:
"""Load + decrypt the persisted pushed config.
Returns None (never raises) when the cache is absent, corrupt, or was written
under a different key, so a bad cache can never crash boot.
"""
data_dir = data_dir or _default_data_dir()
cache_path = data_dir / _CACHE_FILENAME
if not cache_path.exists():
return None
try:
token = cache_path.read_bytes()
payload = _fernet(data_dir).decrypt(token)
return ConfigPushRequest.model_validate_json(payload)
except (InvalidToken, ValueError, OSError) as exc:
logger.warning("Ignoring unreadable AI config cache at %s: %s", cache_path, exc)
return None
+11
View File
@@ -25,6 +25,12 @@ class AppSettings(BaseSettings):
smart_model_name: str = Field(validation_alias="STIRLING_SMART_MODEL")
fast_model_name: str = Field(validation_alias="STIRLING_FAST_MODEL")
# Provider backing the active chat models. Empty for env/native providers; set to
# 'ollama'/'custom' by a config push. Agents that combine structured output with
# tools read this to pick a tool-compatible output strategy, because OpenAI-compatible
# local models (Ollama) block tool calls when a native json-schema response format is
# set, so their structured result must be delivered via a tool call instead.
chat_provider: str = Field(default="")
smart_model_max_tokens: int = Field(validation_alias="STIRLING_SMART_MODEL_MAX_TOKENS")
fast_model_max_tokens: int = Field(validation_alias="STIRLING_FAST_MODEL_MAX_TOKENS")
# Process-wide ceiling on concurrent model API calls, shared by both model
@@ -123,6 +129,11 @@ class AppSettings(BaseSettings):
engine_shared_secret: str = Field(default="", validation_alias="STIRLING_ENGINE_SHARED_SECRET")
engine_require_auth: bool = Field(default=False, validation_alias="STIRLING_ENGINE_REQUIRE_AUTH")
# When true, the Java processor may push admin-configured AI settings (model,
# credentials, limits) to POST /api/v1/config at startup. Turn this off in
# environment-driven deployments so the environment is the single source of truth.
allow_config_push: bool = Field(default=True, validation_alias="STIRLING_ALLOW_CONFIG_PUSH")
def _configure_logging(level_name: str, log_file: str, http_debug: bool) -> None:
"""Configure the ``stirling`` logger hierarchy."""
+12
View File
@@ -29,6 +29,13 @@ from .common import (
format_conversation_history,
format_file_names,
)
from .config import (
ConfigApplyResponse,
ConfigLimitsSection,
ConfigModelsSection,
ConfigPushRequest,
ConfigRagSection,
)
from .contradiction import (
Claim,
Contradiction,
@@ -139,6 +146,11 @@ __all__ = [
"Claim",
"CommentSpec",
"CompletedExecutionAction",
"ConfigApplyResponse",
"ConfigLimitsSection",
"ConfigModelsSection",
"ConfigPushRequest",
"ConfigRagSection",
"Contradiction",
"ContradictionReport",
"ContradictionSeverity",
+78
View File
@@ -0,0 +1,78 @@
from __future__ import annotations
from pydantic import ConfigDict, Field
from stirling.models import ApiModel
# Tolerate unknown fields so a newer processor pushing a field this engine version
# doesn't know is ignored rather than rejecting the whole push. Overrides only the
# ``extra`` policy from ApiModel; the camelCase alias generator is inherited.
_TOLERANT = ConfigDict(extra="ignore")
class ConfigModelsSection(ApiModel):
"""Model provider + credentials pushed by the Java processor.
Field names on the wire are camelCase (``smartModel`` etc.) via the
:class:`ApiModel` alias generator. Empty ``provider``/``apiKey``/``baseUrl``
or empty model names mean "keep the engine's env-configured value".
"""
model_config = _TOLERANT
provider: str = ""
smart_model: str = ""
fast_model: str = ""
smart_max_tokens: int | None = None
fast_max_tokens: int | None = None
api_key: str = ""
base_url: str = ""
class ConfigRagSection(ApiModel):
model_config = _TOLERANT
embedding_provider: str = ""
embedding_model: str = ""
embedding_api_key: str = ""
# OpenAI-compatible endpoint URL for ollama/custom embedding providers.
# Empty keeps the engine's env value, same convention as the other fields.
embedding_base_url: str = ""
top_k: int | None = None
max_searches: int | None = None
class ConfigLimitsSection(ApiModel):
model_config = _TOLERANT
max_pages: int | None = None
max_characters: int | None = None
model_max_concurrency: int | None = None
class ConfigPushRequest(ApiModel):
"""Admin-configured AI settings pushed at processor startup."""
model_config = _TOLERANT
models: ConfigModelsSection = Field(default_factory=ConfigModelsSection)
rag: ConfigRagSection = Field(default_factory=ConfigRagSection)
limits: ConfigLimitsSection = Field(default_factory=ConfigLimitsSection)
class ConfigApplyResponse(ApiModel):
"""Summary of the effective config after a push. Never echoes credentials."""
status: str
provider: str
smart_model: str
fast_model: str
smart_max_tokens: int
fast_max_tokens: int
rag_embedding_model: str
rag_top_k: int
rag_max_searches: int
max_pages: int
max_characters: int
model_max_concurrency: int
notes: list[str] = Field(default_factory=list)
+38 -1
View File
@@ -1,6 +1,8 @@
from __future__ import annotations
from pydantic_ai import Embedder
from pydantic_ai.embeddings.openai import OpenAIEmbeddingModel
from pydantic_ai.providers.openai import OpenAIProvider
from stirling.documents.chunker import chunk_text
from stirling.documents.store import Document
@@ -12,6 +14,37 @@ from stirling.documents.store import Document
DEFAULT_EMBED_BATCH_SIZE = 256
def _build_embedder(
model_name: str,
*,
provider: str | None = None,
api_key: str | None = None,
base_url: str | None = None,
) -> Embedder:
"""Construct an :class:`Embedder` for ``model_name``.
With no explicit ``provider``/``api_key``/``base_url`` this keeps the original
behaviour: ``model_name`` is the ``provider:model`` string form (e.g.
"voyageai:voyage-4") and credentials come from the provider's native env var.
With an explicit provider (config-push path) ``model_name`` is the bare model
without a prefix. ``voyageai``/``openai`` compose the same string form; the
OpenAI-compatible ``ollama``/``custom`` build an OpenAI embedding model against
``base_url`` (Ollama ignores the key but the SDK still needs a non-empty one).
"""
if not provider and not api_key and not base_url:
return Embedder(model_name)
provider_name = (provider or "").lower()
key = api_key or None
if provider_name in ("voyageai", "openai"):
return Embedder(f"{provider_name}:{model_name}")
if provider_name in ("ollama", "custom"):
openai_provider = OpenAIProvider(base_url=base_url or None, api_key=key or "ollama")
return Embedder(OpenAIEmbeddingModel(model_name, provider=openai_provider))
raise ValueError(f"Unsupported embedding provider {provider!r}.")
class EmbeddingService:
"""Wraps Pydantic AI's Embedder to provide document chunking and embedding."""
@@ -21,8 +54,12 @@ class EmbeddingService:
chunk_size: int = 512,
chunk_overlap: int = 64,
embed_batch_size: int = DEFAULT_EMBED_BATCH_SIZE,
*,
provider: str | None = None,
api_key: str | None = None,
base_url: str | None = None,
) -> None:
self._embedder = Embedder(model_name)
self._embedder = _build_embedder(model_name, provider=provider, api_key=api_key, base_url=base_url)
self._chunk_size = chunk_size
self._chunk_overlap = chunk_overlap
self._embed_batch_size = embed_batch_size
+19
View File
@@ -43,6 +43,25 @@ class DocumentService:
self._store = store
self._default_top_k = default_top_k
@property
def default_top_k(self) -> int:
return self._default_top_k
@default_top_k.setter
def default_top_k(self, value: int) -> None:
# Lets a config-push retune retrieval breadth without rebuilding the store.
self._default_top_k = value
@property
def embedder(self) -> EmbeddingService:
return self._embedder
@embedder.setter
def embedder(self, value: EmbeddingService) -> None:
# Lets a config-push swap the embedding model while keeping the live store
# (and its connection pool). Existing vectors need re-indexing afterwards.
self._embedder = value
async def ingest(
self,
collection: FileId,
+156 -27
View File
@@ -1,7 +1,9 @@
from __future__ import annotations
import asyncio
import json
import logging
import os
from collections.abc import AsyncIterator
from contextlib import asynccontextmanager
from dataclasses import dataclass
@@ -12,8 +14,10 @@ from pydantic_ai import RunContext
from pydantic_ai.messages import ModelMessage, ModelResponse
from pydantic_ai.models import Model, ModelRequestParameters, StreamedResponse, infer_model
from pydantic_ai.models.anthropic import AnthropicModel
from pydantic_ai.models.openai import OpenAIChatModel
from pydantic_ai.models.wrapper import WrapperModel
from pydantic_ai.providers.anthropic import AnthropicProvider
from pydantic_ai.providers.openai import OpenAIProvider
from pydantic_ai.settings import ModelSettings
from stirling.config import ENGINE_ROOT, AppSettings, DocumentsBackend
@@ -49,6 +53,79 @@ def _build_anthropic_http_client() -> httpx.AsyncClient:
)
# Placeholder used when no Anthropic key is configured; lets the engine boot so a
# deployment that only uses another provider (e.g. Ollama via the admin UI / config
# push) isn't blocked. Anthropic calls then fail with a clear 401 instead.
_UNCONFIGURED_ANTHROPIC_KEY = "unconfigured"
_warned_missing_anthropic_key = False
def _anthropic_provider(explicit_key: str | None = None) -> AnthropicProvider:
"""Build the Anthropic provider, tolerating a missing key at startup.
pydantic-ai's ``AnthropicProvider`` raises at construction when no key is
available, which would crash the whole engine at boot even for deployments
that never call Anthropic (Ollama/OpenAI-configured, or awaiting a config
push that supplies credentials). We fall back to a placeholder key and warn
once so startup succeeds; the placeholder only surfaces as a clear
provider-side auth error if an Anthropic call is actually made.
"""
http_client = _build_anthropic_http_client()
key = explicit_key or os.environ.get("ANTHROPIC_API_KEY")
if key:
return AnthropicProvider(api_key=key, http_client=http_client)
global _warned_missing_anthropic_key
if not _warned_missing_anthropic_key:
_warned_missing_anthropic_key = True
logger.warning(
"ANTHROPIC_API_KEY is not set - the engine will start, but Anthropic model "
"calls will fail until a key is provided or a different provider is configured "
"(admin AI settings / config push)."
)
return AnthropicProvider(api_key=_UNCONFIGURED_ANTHROPIC_KEY, http_client=http_client)
class _NullContentCoercingTransport(httpx.AsyncHTTPTransport):
"""Coerce assistant ``content: null`` to ``""`` in outgoing OpenAI chat requests.
OpenAI's schema uses ``content: null`` for assistant messages that carry only
tool calls, but Ollama's OpenAI-compatible endpoint rejects it ("invalid message
content type: <nil>"), which intermittently breaks multi-turn tool conversations
(RAG search + tool-delivered output). Rewriting null -> "" makes them acceptable
without changing semantics. Scoped to the ollama/custom clients only.
"""
async def handle_async_request(self, request: httpx.Request) -> httpx.Response:
if request.headers.get("content-type", "").startswith("application/json") and request.content:
try:
body = json.loads(request.content)
except ValueError:
return await super().handle_async_request(request)
messages = body.get("messages")
if isinstance(messages, list):
changed = False
for message in messages:
if isinstance(message, dict) and message.get("content", "") is None:
message["content"] = ""
changed = True
if changed:
new_body = json.dumps(body).encode("utf-8")
headers = [(k, v) for k, v in request.headers.raw if k.lower() != b"content-length"]
request = httpx.Request(
method=request.method,
url=request.url,
headers=headers,
content=new_body,
extensions=request.extensions,
)
return await super().handle_async_request(request)
def _openai_compat_http_client() -> httpx.AsyncClient:
"""httpx client for Ollama/custom OpenAI-compatible endpoints (null-content fix)."""
return httpx.AsyncClient(transport=_NullContentCoercingTransport())
class ConcurrencyLimitedModel(WrapperModel):
"""Caps in-flight model API calls with a semaphore shared across the process."""
@@ -131,45 +208,97 @@ def _build_document_store(settings: AppSettings) -> DocumentStore:
assert_never(settings.documents_backend)
def _build_documents(settings: AppSettings) -> DocumentService:
"""Build the document service used by per-request RAG capabilities."""
logger.info("Documents: embedding_model=%s", settings.rag_embedding_model)
embedder = EmbeddingService(
model_name=settings.rag_embedding_model,
chunk_size=settings.rag_chunk_size,
chunk_overlap=settings.rag_chunk_overlap,
)
def _build_documents(settings: AppSettings, embedder: EmbeddingService | None = None) -> DocumentService:
"""Build the document service used by per-request RAG capabilities.
``embedder`` lets a cache-restore boot inject an already-built embedder (e.g.
an OpenAI-compatible ollama/custom one whose base_url the plain
``settings.rag_embedding_model`` string can't encode); None builds from settings.
"""
if embedder is None:
logger.info("Documents: embedding_model=%s", settings.rag_embedding_model)
embedder = EmbeddingService(
model_name=settings.rag_embedding_model,
chunk_size=settings.rag_chunk_size,
chunk_overlap=settings.rag_chunk_overlap,
)
store = _build_document_store(settings)
return DocumentService(embedder=embedder, store=store, default_top_k=settings.rag_default_top_k)
def build_runtime(settings: AppSettings) -> AppRuntime:
fast_model = _build_model(settings.fast_model_name)
smart_model = _build_model(settings.smart_model_name)
validate_structured_output_support(fast_model, settings.fast_model_name)
validate_structured_output_support(smart_model, settings.smart_model_name)
def build_runtime(
settings: AppSettings,
*,
documents: DocumentService | None = None,
fast_model: Model | None = None,
smart_model: Model | None = None,
embedder: EmbeddingService | None = None,
) -> AppRuntime:
"""Assemble the shared runtime.
``documents``/``fast_model``/``smart_model`` let a config-push rebuild reuse
the live vector-store connection pool and inject already-built (and already
validated) models instead of reconstructing them from the model-name strings.
``embedder`` injects a pre-built embedder when building a fresh document store
(cache-restore boot); it is ignored when ``documents`` is supplied. All None
(the lifespan path) preserves the original startup behaviour.
"""
fast = fast_model if fast_model is not None else _build_model(settings.fast_model_name)
smart = smart_model if smart_model is not None else _build_model(settings.smart_model_name)
validate_structured_output_support(fast, settings.fast_model_name)
validate_structured_output_support(smart, settings.smart_model_name)
# One semaphore across both tiers: the cap protects the provider account
# and process resources, which the tiers share.
model_semaphore = asyncio.Semaphore(settings.model_max_concurrency)
return AppRuntime(
settings=settings,
fast_model=ConcurrencyLimitedModel(fast_model, model_semaphore),
smart_model=ConcurrencyLimitedModel(smart_model, model_semaphore),
documents=_build_documents(settings),
fast_model=ConcurrencyLimitedModel(fast, model_semaphore),
smart_model=ConcurrencyLimitedModel(smart, model_semaphore),
documents=documents if documents is not None else _build_documents(settings, embedder),
)
def _build_model(model_name: str) -> Model:
"""Construct a model, injecting our keepalive-free httpx client for
Anthropic models so workers don't pick up stale pooled connections.
def _build_model(
model_name: str,
*,
provider: str | None = None,
api_key: str | None = None,
base_url: str | None = None,
) -> Model:
"""Construct a model for ``model_name``.
Other providers fall back to ``infer_model`` defaults; the stale-pool
issue is specific to the Cloudflare-fronted Anthropic API in our
observations and the fix doesn't necessarily apply elsewhere.
With no explicit ``provider``/``api_key``/``base_url`` this keeps the original
env-driven behaviour: Anthropic-prefixed names get our keepalive-free httpx
client (so workers don't pick up stale pooled connections) and everything
else falls back to ``infer_model``.
When the caller passes an explicit provider (config-push path), ``model_name``
is the bare model without a ``provider:`` prefix and credentials come from the
call. An empty ``api_key``/``base_url`` is treated as unset so the provider
reads its native environment variable (e.g. ANTHROPIC_API_KEY / OPENAI_API_KEY).
"""
if model_name.startswith("anthropic:"):
bare_name = model_name.removeprefix("anthropic:")
provider = AnthropicProvider(http_client=_build_anthropic_http_client())
return AnthropicModel(bare_name, provider=provider)
return infer_model(model_name)
if not provider and not api_key and not base_url:
if model_name.startswith("anthropic:"):
bare_name = model_name.removeprefix("anthropic:")
return AnthropicModel(bare_name, provider=_anthropic_provider())
return infer_model(model_name)
provider_name = (provider or "").lower()
key = api_key or None
if provider_name == "anthropic":
return AnthropicModel(model_name, provider=_anthropic_provider(key))
if provider_name == "openai":
openai_provider = OpenAIProvider(api_key=key) if key else OpenAIProvider()
return OpenAIChatModel(model_name, provider=openai_provider)
if provider_name in ("ollama", "custom"):
# OpenAI-compatible endpoint. Ollama ignores the key but the SDK still
# requires a non-empty one, so default to a harmless placeholder. The custom
# http client coerces null assistant content so multi-turn tool calls work.
openai_provider = OpenAIProvider(
base_url=base_url or None,
api_key=key or "ollama",
http_client=_openai_compat_http_client(),
)
return OpenAIChatModel(model_name, provider=openai_provider)
raise ValueError(f"Unsupported model provider {provider!r}.")
+24
View File
@@ -0,0 +1,24 @@
{
"models": {
"provider": "ollama",
"smartModel": "smart-model-x",
"fastModel": "fast-model-x",
"smartMaxTokens": 1111,
"fastMaxTokens": 2222,
"apiKey": "provider-key-abc",
"baseUrl": "http://engine.example/v1"
},
"rag": {
"embeddingProvider": "custom",
"embeddingModel": "embed-model-x",
"embeddingApiKey": "embed-key-abc",
"embeddingBaseUrl": "http://embed.example/v1",
"topK": 33,
"maxSearches": 7
},
"limits": {
"maxPages": 111,
"maxCharacters": 222222,
"modelMaxConcurrency": 9
}
}
+73
View File
@@ -0,0 +1,73 @@
"""Tests for the encrypted config cache (stirling.config.config_cache)."""
from __future__ import annotations
from pathlib import Path
import pytest
from stirling.config import config_cache
from stirling.contracts import ConfigPushRequest
def _sample() -> ConfigPushRequest:
return ConfigPushRequest.model_validate(
{
"models": {
"provider": "anthropic",
"smartModel": "claude-haiku-4-5",
"fastModel": "claude-haiku-4-5",
"smartMaxTokens": 8192,
"fastMaxTokens": 2048,
"apiKey": "secret-key-value",
"baseUrl": "",
},
"rag": {
"embeddingProvider": "voyageai",
"embeddingModel": "voyage-4",
"embeddingApiKey": "embed-secret",
"embeddingBaseUrl": "",
"topK": 20,
"maxSearches": 5,
},
"limits": {"maxPages": 200, "maxCharacters": 200000, "modelMaxConcurrency": 32},
}
)
def test_roundtrip_with_shared_secret(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
monkeypatch.setattr(config_cache, "_shared_secret", lambda: "the-shared-secret")
req = _sample()
config_cache.save_config(req, data_dir=tmp_path)
# HKDF-from-secret path: no keyfile is written.
assert not (tmp_path / "ai_config_cache.key").exists()
assert config_cache.load_config(data_dir=tmp_path) == req
# Secrets are encrypted at rest.
assert b"secret-key-value" not in (tmp_path / "ai_config_cache.enc").read_bytes()
def test_roundtrip_with_keyfile_when_no_secret(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
monkeypatch.setattr(config_cache, "_shared_secret", lambda: "")
req = _sample()
config_cache.save_config(req, data_dir=tmp_path)
# No shared secret: a random keyfile is generated and reused for decrypt.
assert (tmp_path / "ai_config_cache.key").exists()
assert config_cache.load_config(data_dir=tmp_path) == req
def test_load_missing_returns_none(tmp_path: Path) -> None:
assert config_cache.load_config(data_dir=tmp_path) is None
def test_load_corrupt_returns_none(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
monkeypatch.setattr(config_cache, "_shared_secret", lambda: "a-secret")
(tmp_path / "ai_config_cache.enc").write_bytes(b"not-a-valid-fernet-token")
assert config_cache.load_config(data_dir=tmp_path) is None
def test_wrong_key_returns_none(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
monkeypatch.setattr(config_cache, "_shared_secret", lambda: "secret-a")
config_cache.save_config(_sample(), data_dir=tmp_path)
# A different secret derives a different key -> decrypt fails, returns None.
monkeypatch.setattr(config_cache, "_shared_secret", lambda: "secret-b")
assert config_cache.load_config(data_dir=tmp_path) is None
+80
View File
@@ -0,0 +1,80 @@
"""Wire-contract test for the processor -> engine config push.
The Java processor (AiEngineConfigSync.buildConfigNode) serialises the admin AI settings as
camelCase JSON and POSTs them to /api/v1/config. Because ConfigPushRequest uses extra="ignore"
(so version skew never 422s), a field the engine can no longer map is silently dropped to its
default instead of erroring. This test pins the exact camelCase contract: every field in the
shared fixture must round-trip onto the model, so a rename on either side fails loudly.
The same fixture (tests/fixtures/processor_config_push.json) is the contract the Java
AiEngineConfigSyncTest asserts its serialiser produces - keep the two in sync.
"""
from __future__ import annotations
import json
from pathlib import Path
from stirling.contracts import ConfigPushRequest
FIXTURE = Path(__file__).parent / "fixtures" / "processor_config_push.json"
def _load() -> dict:
return json.loads(FIXTURE.read_text(encoding="utf-8"))
def test_processor_contract_round_trips_every_field() -> None:
payload = _load()
req = ConfigPushRequest.model_validate(payload)
m = payload["models"]
assert req.models.provider == m["provider"]
assert req.models.smart_model == m["smartModel"]
assert req.models.fast_model == m["fastModel"]
assert req.models.smart_max_tokens == m["smartMaxTokens"]
assert req.models.fast_max_tokens == m["fastMaxTokens"]
assert req.models.api_key == m["apiKey"]
assert req.models.base_url == m["baseUrl"]
r = payload["rag"]
assert req.rag.embedding_provider == r["embeddingProvider"]
assert req.rag.embedding_model == r["embeddingModel"]
assert req.rag.embedding_api_key == r["embeddingApiKey"]
assert req.rag.embedding_base_url == r["embeddingBaseUrl"]
assert req.rag.top_k == r["topK"]
assert req.rag.max_searches == r["maxSearches"]
limits = payload["limits"]
assert req.limits.max_pages == limits["maxPages"]
assert req.limits.max_characters == limits["maxCharacters"]
assert req.limits.model_max_concurrency == limits["modelMaxConcurrency"]
def test_processor_contract_has_no_unmapped_keys() -> None:
"""Guard the fixture itself: every wire key must map to a model field (none silently
absorbed by extra="ignore"). If the processor adds a field, extend the contract here."""
payload = _load()
expected = {
"models": {
"provider",
"smartModel",
"fastModel",
"smartMaxTokens",
"fastMaxTokens",
"apiKey",
"baseUrl",
},
"rag": {
"embeddingProvider",
"embeddingModel",
"embeddingApiKey",
"embeddingBaseUrl",
"topK",
"maxSearches",
},
"limits": {"maxPages", "maxCharacters", "modelMaxConcurrency"},
}
assert set(payload) == set(expected)
for section, keys in expected.items():
assert set(payload[section]) == keys
+249
View File
@@ -0,0 +1,249 @@
"""Tests for the config-push endpoint (POST /api/v1/config).
These drive the real lifespan (via ``with TestClient(app)``) so ``app.state`` is
populated, then exercise the gate + model-swap behaviour against the in-memory
"test" runtime built by :func:`build_app_settings`.
"""
from __future__ import annotations
from collections.abc import Callable, Iterator
from contextlib import contextmanager
from pathlib import Path
from unittest.mock import patch
import pytest
from conftest import build_app_settings
from fastapi.testclient import TestClient
from stirling.api import app
from stirling.config import AppSettings, config_cache, load_settings
from stirling.contracts import ConfigPushRequest
@pytest.fixture(autouse=True)
def _isolate_config_cache(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
"""Point the encrypted config cache at a per-test tmp dir so persistence never
touches the real engine/data dir or leaks between tests."""
monkeypatch.setattr(config_cache, "_default_data_dir", lambda: tmp_path)
@contextmanager
def _client(
settings_factory: Callable[[], AppSettings],
*,
client_addr: tuple[str, int] = ("127.0.0.1", 12345),
) -> Iterator[TestClient]:
"""Enter a TestClient whose lifespan builds app.state from ``settings_factory``.
Defaults to a loopback client address so the config endpoint's local-caller gate
(which blocks remote unauthenticated pushes) allows the request through.
"""
previous = app.dependency_overrides.get(load_settings)
app.dependency_overrides[load_settings] = settings_factory
try:
with TestClient(app, client=client_addr) as client:
yield client
finally:
if previous is None:
app.dependency_overrides.pop(load_settings, None)
else:
app.dependency_overrides[load_settings] = previous
def _anthropic_push() -> dict[str, object]:
return {
"models": {
"provider": "anthropic",
"smartModel": "claude-haiku-4-5",
"fastModel": "claude-haiku-4-5",
"smartMaxTokens": 4096,
"fastMaxTokens": 1024,
"apiKey": "test-key-not-a-real-secret",
"baseUrl": "",
},
"rag": {
"embeddingProvider": "",
"embeddingModel": "",
"embeddingApiKey": "",
"embeddingBaseUrl": "",
"topK": 7,
"maxSearches": 3,
},
"limits": {"maxPages": 50, "maxCharacters": 12345, "modelMaxConcurrency": 8},
}
def test_config_push_forbidden_when_disabled() -> None:
def factory() -> AppSettings:
return build_app_settings().model_copy(update={"allow_config_push": False})
with _client(factory) as client:
response = client.post("/api/v1/config", json=_anthropic_push())
assert response.status_code == 403
def test_config_push_from_non_local_caller_without_secret_returns_403() -> None:
"""Secure-by-default: with no shared secret, a remote caller cannot push a config."""
with _client(build_app_settings, client_addr=("203.0.113.9", 4444)) as client:
response = client.post("/api/v1/config", json=_anthropic_push())
assert response.status_code == 403
assert "STIRLING_ENGINE_SHARED_SECRET" in response.json()["detail"]
def test_config_push_applies_model_and_limits() -> None:
with _client(build_app_settings) as client:
response = client.post("/api/v1/config", json=_anthropic_push())
assert response.status_code == 200
body = response.json()
# Wire summary is camelCase and never echoes the api key.
assert body["smartModel"] == "claude-haiku-4-5"
assert body["fastModel"] == "claude-haiku-4-5"
assert body["smartMaxTokens"] == 4096
assert body["ragTopK"] == 7
assert body["maxPages"] == 50
assert body["modelMaxConcurrency"] == 8
assert "test-key-not-a-real-secret" not in response.text
# State was swapped: the running settings now reflect the push.
assert app.state.settings.smart_model_name == "claude-haiku-4-5"
assert app.state.settings.max_pages == 50
assert app.state.runtime.documents.default_top_k == 7
def test_config_push_unsupported_provider_returns_400_without_swap() -> None:
with _client(build_app_settings) as client:
before = app.state.runtime
payload = _anthropic_push()
payload["models"]["provider"] = "nonsense-provider" # type: ignore[index]
response = client.post("/api/v1/config", json=payload)
assert response.status_code == 400
# Running runtime is untouched when the push is rejected.
assert app.state.runtime is before
assert app.state.settings.smart_model_name == "test"
def test_config_push_unsupported_model_returns_400() -> None:
"""A model that fails structured-output validation is rejected with 400."""
with _client(build_app_settings) as client:
before = app.state.runtime
with patch(
"stirling.api.routes.config.validate_structured_output_support",
side_effect=ValueError("Unsupported model foo. This model does not support structured outputs."),
):
response = client.post("/api/v1/config", json=_anthropic_push())
assert response.status_code == 400
assert "does not support structured outputs" in response.json()["detail"]
assert app.state.runtime is before
def test_config_push_ollama_embedding_rebuilds_embedder() -> None:
"""A pushed ollama/custom embedding provider builds an OpenAI-compatible
embedder and swaps it onto the reused DocumentService without tearing down
the store; the response carries a re-index note."""
with _client(build_app_settings) as client:
store_before = app.state.runtime.documents
embedder_before = app.state.runtime.documents.embedder
payload = _anthropic_push()
payload["rag"] = {
"embeddingProvider": "ollama",
"embeddingModel": "nomic-embed-text",
"embeddingApiKey": "",
"embeddingBaseUrl": "http://localhost:11434/v1",
"topK": 9,
"maxSearches": 4,
}
response = client.post("/api/v1/config", json=payload)
assert response.status_code == 200
body = response.json()
assert body["ragEmbeddingModel"] == "ollama:nomic-embed-text"
assert any("re-index" in note for note in body["notes"])
docs = app.state.runtime.documents
# Same store object reused (connection pool intact), embedder swapped.
assert docs is store_before
assert docs.embedder is not embedder_before
assert docs.default_top_k == 9
def test_config_push_unsupported_embedding_provider_returns_400() -> None:
with _client(build_app_settings) as client:
embedder_before = app.state.runtime.documents.embedder
payload = _anthropic_push()
payload["rag"] = {
"embeddingProvider": "totally-bogus",
"embeddingModel": "x",
"embeddingApiKey": "",
"embeddingBaseUrl": "",
"topK": None,
"maxSearches": None,
}
response = client.post("/api/v1/config", json=payload)
assert response.status_code == 400
# Embedder untouched when the push is rejected.
assert app.state.runtime.documents.embedder is embedder_before
def test_config_push_empty_models_keep_env_value() -> None:
"""An empty models block keeps the engine's current (env) models but still
applies pushed limits."""
with _client(build_app_settings) as client:
payload = _anthropic_push()
payload["models"] = {
"provider": "",
"smartModel": "",
"fastModel": "",
"smartMaxTokens": None,
"fastMaxTokens": None,
"apiKey": "",
"baseUrl": "",
}
response = client.post("/api/v1/config", json=payload)
assert response.status_code == 200
# Env "test" model preserved for both tiers; limits still applied.
assert app.state.settings.smart_model_name == "test"
assert app.state.settings.fast_model_name == "test"
assert app.state.settings.max_pages == 50
def test_config_push_ignores_unknown_fields() -> None:
"""A newer processor pushing fields this engine doesn't know must not 422;
unknown fields are ignored and the rest of the push still applies."""
with _client(build_app_settings) as client:
payload = _anthropic_push()
payload["futureTopLevelField"] = {"anything": 1}
payload["models"]["experimentalFlag"] = True # type: ignore[index]
response = client.post("/api/v1/config", json=payload)
assert response.status_code == 200
assert app.state.settings.smart_model_name == "claude-haiku-4-5"
assert app.state.settings.max_pages == 50
def test_boot_restores_cached_config() -> None:
"""A persisted config is decrypted and applied on boot, overriding env."""
config_cache.save_config(ConfigPushRequest.model_validate(_anthropic_push()))
with _client(build_app_settings):
# Env model is "test"; the cache pushed claude-haiku-4-5 + limits.
assert app.state.settings.smart_model_name == "claude-haiku-4-5"
assert app.state.settings.fast_model_name == "claude-haiku-4-5"
assert app.state.settings.max_pages == 50
assert app.state.settings.smart_model_max_tokens == 4096
assert app.state.runtime.documents.default_top_k == 7
def test_boot_ignores_cache_when_push_disabled() -> None:
"""With allow_config_push false, env wins and the cache is ignored."""
config_cache.save_config(ConfigPushRequest.model_validate(_anthropic_push()))
def factory() -> AppSettings:
return build_app_settings().model_copy(update={"allow_config_push": False})
with _client(factory):
assert app.state.settings.smart_model_name == "test"
assert app.state.settings.max_pages == 200
def test_boot_proceeds_on_corrupt_cache(tmp_path: Path) -> None:
"""A corrupt cache file is ignored and boot falls back to env, never crashing."""
(tmp_path / "ai_config_cache.enc").write_bytes(b"not-a-valid-fernet-token")
with _client(build_app_settings):
assert app.state.settings.smart_model_name == "test"
assert app.state.settings.max_pages == 200
+2
View File
@@ -603,6 +603,7 @@ name = "engine"
version = "0.1.0"
source = { editable = "." }
dependencies = [
{ name = "cryptography" },
{ name = "fastapi" },
{ name = "jinja2" },
{ name = "opentelemetry-sdk" },
@@ -630,6 +631,7 @@ dev = [
[package.metadata]
requires-dist = [
{ name = "cryptography", specifier = ">=44.0.0" },
{ name = "fastapi", specifier = ">=0.116.0" },
{ name = "jinja2", specifier = ">=3.1.0" },
{ name = "opentelemetry-sdk", specifier = ">=1.39.0" },
@@ -692,6 +692,160 @@ manualLinks = "Manual downloads: click the links and place the files into the te
noLanguages = "No tessdata languages found in the configured directory."
permissionNotice = "The tessdata path is not writable. Downloads will be opened in the browser; please save the .traineddata files manually into the tessdata folder."
# AI engine admin settings (AI nav group)
[admin.settings.ai.documents]
description = "Configure the embedding model and retrieval settings used to answer questions over documents. Applied to the AI engine when saved."
title = "Documents & RAG"
[admin.settings.ai.documents.embeddingApiKey]
description = "Leave blank to use the engine's own environment credential. Applies to self-hosted single-engine deployments."
generic = "Embedding API key"
openai = "OpenAI API key"
voyageai = "VoyageAI API key"
[admin.settings.ai.documents.embeddingBaseUrl]
description = "Base URL of the OpenAI-compatible / Ollama embeddings endpoint, e.g. http://ollama:11434/v1. Must point at a trusted internal endpoint (SSRF-sensitive)."
label = "Embedding base URL"
[admin.settings.ai.documents.embeddingModel]
description = "Embedding model name. Free text; suggestions are hints only."
label = "Embedding model"
[admin.settings.ai.documents.embeddingProvider]
description = "Provider used to turn document text into vector embeddings."
label = "Embedding provider"
[admin.settings.ai.documents.maxSearches]
description = "Maximum number of retrieval searches the agent may run per request."
label = "Max searches"
[admin.settings.ai.documents.reindexNote]
body = "Changing the embedding model invalidates previously-embedded documents. A full engine restart and re-index of existing documents is required for search to work correctly."
title = "Re-index required"
[admin.settings.ai.documents.topK]
description = "Number of most-relevant chunks retrieved per search."
label = "Top K"
[admin.settings.ai.general]
description = "Connect Stirling to the Python AI engine and choose which AI capabilities are exposed. Changes apply on restart."
title = "AI Engine"
[admin.settings.ai.general.capabilities]
description = "Turn individual AI features on or off. Disabled features are hidden in the app."
title = "Capabilities"
[admin.settings.ai.general.enabled]
description = "Master switch. When off, no AI tools, agents, or engine calls are available."
label = "Enable AI"
[admin.settings.ai.general.features.chat]
description = "Conversational assistant for working with PDFs."
label = "Chat assistant"
[admin.settings.ai.general.features.classify]
description = "Automatically categorise documents by type or content."
label = "Document classification"
[admin.settings.ai.general.features.createPdf]
description = "Generate a new PDF (e.g. from HTML) via an AI agent."
label = "Create PDF from prompt"
[admin.settings.ai.general.features.documentQuestions]
description = "Ask questions and get answers grounded in an uploaded document."
label = "Document questions"
[admin.settings.ai.general.features.mathAuditor]
description = "Review documents for mathematical and numerical errors."
label = "Math auditor"
[admin.settings.ai.general.features.pdfComment]
description = "Add AI-authored review comments and annotations to a PDF."
label = "PDF comment agent"
[admin.settings.ai.general.longRunningTimeoutSeconds]
description = "Timeout for heavier agent operations such as document generation."
label = "Long-running timeout (seconds)"
[admin.settings.ai.general.note]
body = "The AI engine runs as a separate service. Its shared secret"
body2 = "is set via a container environment variable. Provider API keys can be entered on these pages or supplied as engine environment variables; keys entered here are pushed to the engine when saved."
title = "About the AI engine"
[admin.settings.ai.general.streamTimeoutSeconds]
description = "Timeout for streamed (token-by-token) chat responses."
label = "Stream timeout (seconds)"
[admin.settings.ai.general.test]
button = "Test connection"
failBody = "The AI engine did not respond. Check the URL, that the engine container is running, and that AI is enabled (a restart is needed after enabling)."
failTitle = "AI engine unreachable"
okBody = "The AI engine responded to a health check."
okTitle = "AI engine reachable"
[admin.settings.ai.general.timeoutSeconds]
description = "Timeout for standard AI requests to the engine."
label = "Request timeout (seconds)"
[admin.settings.ai.general.url]
description = "Internal URL of the Python AI engine, e.g. http://stirling-pdf-engine:5001."
label = "AI engine URL"
[admin.settings.ai.limits]
description = "Guardrails for how much work AI requests may do and how many run concurrently. Applied to the AI engine when saved."
title = "Limits & Performance"
[admin.settings.ai.limits.maxCharacters]
description = "Guardrail: reject AI requests whose extracted text exceeds this length."
label = "Max characters per request"
[admin.settings.ai.limits.maxPages]
description = "Guardrail: reject AI requests over this many PDF pages."
label = "Max pages per request"
[admin.settings.ai.limits.modelMaxConcurrency]
description = "Maximum simultaneous in-flight model calls across the whole engine."
label = "Model max concurrency"
[admin.settings.ai.models]
description = "Choose the LLM provider and the smart/fast models the AI engine uses. Applied to the AI engine when saved."
title = "Models & Providers"
[admin.settings.ai.models.apiKey]
anthropic = "Anthropic API key"
description = "Leave blank to use the engine's own environment credential. Applies to self-hosted single-engine deployments."
generic = "API key"
openai = "OpenAI API key"
[admin.settings.ai.models.baseUrl]
description = "Base URL of the OpenAI-compatible / Ollama endpoint, e.g. http://ollama:11434/v1."
label = "Provider base URL"
warning = "The base URL must point at a trusted internal endpoint. The engine will make server-side requests to it, so an untrusted value is SSRF-sensitive."
[admin.settings.ai.models.fastMaxTokens]
description = "Maximum output tokens for the fast model."
label = "Fast model max tokens"
[admin.settings.ai.models.fastModel]
description = "Cheaper, faster model for lightweight tasks. Free text; suggestions are hints only."
label = "Fast model"
[admin.settings.ai.models.provider]
description = "Which LLM provider the engine talks to."
label = "Provider"
[admin.settings.ai.models.smartMaxTokens]
description = "Maximum output tokens for the smart model."
label = "Smart model max tokens"
[admin.settings.ai.models.smartModel]
description = "High-capability model for complex reasoning. Free text; suggestions are hints only."
label = "Smart model"
[admin.settings.ai.saved]
body = "Changes are pushed to the AI engine automatically."
title = "AI settings saved"
[admin.settings.badge]
clickToUpgrade = "Click to view plan details"
@@ -6240,6 +6394,10 @@ muted = "muted"
nameLabel = "Name"
namePlaceholder = "e.g. Compliance escalation"
[portal.agentBuilder.status]
draft = "Draft"
published = "Published"
[portal.agentBuilder.tabs]
evals = "Evals"
scenarios = "Scenarios"
@@ -6463,7 +6621,7 @@ install = "Install"
usage = "Usage"
[portal.catalogue.detail.locked]
description = "{{name}} is included from the {{tier}} plan. Upgrade to embed it."
description = "{{name}} is included from the {{plan}}. Upgrade to embed it."
title = "Not available on your plan"
[portal.catalogue.detail.preview]
@@ -6530,6 +6688,35 @@ title = "No components available"
description = "GA components are available on Pay-as-you-go; a few Beta components are enterprise-only. Locked cards show an upgrade nudge."
title = "Some components need a paid plan"
[portal.connections]
createTitle = "New S3 connection"
delete = "Delete"
edit = "Edit"
editTitle = "Edit S3 connection"
subtitle = "Reusable S3 credentials that sources and pipeline outputs connect to."
[portal.connections.actions]
new = "New connection"
[portal.connections.empty]
description = "Add an S3 connection to reuse the same bucket and credentials across sources and pipeline outputs."
title = "No connections yet"
[portal.connections.picker]
cancel = "Cancel"
createNew = "New connection..."
placeholder = "Select a connection"
save = "Save connection"
[portal.connections.s3.fields]
name = "Connection name"
namePlaceholder = "e.g. Claims bucket"
[portal.connections.table]
bucket = "Bucket"
name = "Name"
region = "Region"
[portal.docs.authentication]
codeCaption = "every request"
eyebrow = "GETTING STARTED"
@@ -7341,10 +7528,6 @@ operations_one = "Operation ({{count}})"
operations_other = "Operations ({{count}})"
output = "Output"
removeStep = "Remove operation"
s3Configure = "Configure"
s3Done = "Done"
s3ModalTitle = "Amazon S3 output"
s3NotConfigured = "Not configured"
s3PrefixHelp = "Outputs are uploaded under this key prefix."
save = "Save changes"
scheduleEvery = "Run every"
@@ -7415,6 +7598,7 @@ manual = "Manual"
schedule = "Scheduled"
[portal.policies]
defaultName = "{{category}} Policy"
subtitle = "Standing automations that enforce a tool pipeline on every document. Each policy fires on upload or export, runs its tool chain, and saves the enforced version alongside the original."
title = "Policies"
@@ -7986,34 +8170,29 @@ primaryNav = "Primary navigation"
switchApp = "Switch app"
[portal.sources]
subtitle = "Reusable input connections that feed documents into Stirling. Configure a connection once, then reference it from any number of policies. Click a row for its config and which policies use it."
subtitle = "Reusable input connections that feed documents into Stirling. Configure a source once, then reference it from any number of pipelines."
title = "Sources"
[portal.sources.actions]
agentBuilder = "Agent Builder"
connectSource = "Connect source"
[portal.sources.builder]
back = "Back to sources"
cancel = "Cancel"
create = "Create source"
createTitle = "Connect a source"
delete = "Delete"
editTitle = "Edit source"
enabled = "Enabled"
save = "Save changes"
[portal.sources.delete]
body = "Delete \"{{name}}\"? This can't be undone. Policies that reference it would need to be updated."
cancel = "Cancel"
confirm = "Delete"
title = "Delete source?"
[portal.sources.detail]
closeAriaLabel = "Close detail"
delete = "Delete source"
docs24h = "Last 24h"
docs30d = "Last 30 days"
docsTotal = "Total seen"
docsTrend = "Documents over the last 30 days"
documents = "Documents"
edit = "Edit"
notReferenced = "Not referenced by any policy, so it's safe to delete."
pause = "Pause"
resume = "Resume"
subtitle = "{{type}} · {{status}}"
usedBy = "Used by"
[portal.sources.empty]
description = "Connect a folder (and, soon, cloud storage) so your policies have somewhere to pull documents from."
title = "No sources connected yet"
@@ -8029,10 +8208,15 @@ disabled = "Disabled"
unused = "Unused"
[portal.sources.table]
documents = "Documents"
source = "Source"
status = "Status"
usedBy = "Policies"
[portal.sources.tabs]
connections = "Connections"
sources = "Sources"
[portal.sources.types.editor]
description = "Documents your team has processed in the editor, across policy and AI runs."
label = "Editor"
@@ -8080,6 +8264,10 @@ label = "Access key ID"
label = "Bucket"
placeholder = "my-company-inbox"
[portal.sources.types.s3.fields.connection]
helperText = "The stored connection holding the bucket and credentials. Reused by every source and pipeline output that references it."
label = "Connection"
[portal.sources.types.s3.fields.endpoint]
helperText = "Leave blank for Amazon S3. Set to use an S3-compatible service such as MinIO."
label = "Custom endpoint"
@@ -8109,21 +8297,14 @@ label = "Secret access key"
label = "Source"
[portal.sources.wizard]
back = "Back"
cancel = "Cancel"
continue = "Continue"
editTitle = "Edit source"
name = "Name"
namePlaceholder = "e.g. Claims intake"
save = "Save changes"
subtitle = "Step {{current}} of {{total}} · {{label}}"
title = "Connect a source"
type = "Type"
[portal.sources.wizard.steps]
chooseType = "Choose type"
configure = "Configure"
review = "Review & connect"
[portal.tier]
enterprise = "Enterprise plan"
free = "Editor plan"
pro = "Processor plan"
[portal.usage]
managePayment = "Manage Payment"
@@ -8925,6 +9106,13 @@ title = "Signature Order"
[settings]
close = "Close"
[settings.ai]
documents = "Documents & RAG"
general = "General"
limits = "Limits & Performance"
models = "Models & Providers"
title = "AI"
[settings.configuration]
advanced = "Advanced"
database = "Database"
@@ -10139,6 +10327,19 @@ resetPw = "Reset password"
suspend = "Suspend"
unlock = "Unlock account"
[users.activity]
daysAgo = "{{count}}d ago"
hoursAgo = "{{count}}h ago"
justNow = "Just now"
minutesAgo = "{{count}}m ago"
monthsAgo_one = "{{count}} month ago"
monthsAgo_other = "{{count}} months ago"
never = "Never"
weeksAgo_one = "{{count}} week ago"
weeksAgo_other = "{{count}} weeks ago"
yearsAgo_one = "{{count}} year ago"
yearsAgo_other = "{{count}} years ago"
[users.cap]
addProcessor = "+ Processor"
approver = "Approves policy"
@@ -10209,7 +10410,9 @@ by = "Invited by {{who}}"
cancel = "Cancel"
count = "{{count}} pending"
desc = "Invited people who haven't joined yet. They hold a seat until they accept."
expires = "Expires"
expiresInDays_one = "Expires in {{count}} day"
expiresInDays_other = "Expires in {{count}} days"
expiresToday = "Expires today"
title = "Pending invitations"
[users.loadError]
+1 -1
View File
@@ -2,7 +2,7 @@
"$schema": "../node_modules/@tauri-apps/cli/config.schema.json",
"productName": "Stirling PDF",
"mainBinaryName": "Stirling-PDF",
"version": "2.14.1",
"version": "2.14.2",
"identifier": "stirling.pdf.dev",
"build": {
"frontendDist": "../dist",
@@ -32,6 +32,10 @@ export const VALID_NAV_KEYS = [
"adminEndpoints",
"adminStorageSharing",
"adminMcp",
"adminAiGeneral",
"adminAiModels",
"adminAiDocuments",
"adminAiLimits",
"help",
"legal",
"backendThirdPartyLicenses",
@@ -13,7 +13,6 @@ import {
import {
deserializeToolStep,
getExecutableTools,
serializeStepFromEndpoint,
serializeToolStep,
stepRequiresUpload,
type WorkingToolStep,
@@ -199,41 +198,6 @@ describe("serialize/deserialize round-trip", () => {
});
});
describe("serializeStepFromEndpoint", () => {
test("maps a wizard step's UI params to the backend contract, filling defaults", () => {
// The shape the policy setup wizard holds: an endpoint plus UI-shaped params
// (redact's `wordsToRedact`), with several fields left to their defaults.
const api = serializeStepFromEndpoint(
"/api/v1/security/auto-redact",
{ mode: "automatic", useRegex: true, wordsToRedact: ["ssn", "card"] },
dynamicRegistry,
);
expect(api.operation).toBe("/api/v1/security/auto-redact");
// wordsToRedact -> listOfText (the field the backend actually reads), and the
// frontend-only `mode` is dropped.
expect(api.parameters).toMatchObject({ listOfText: "ssn\ncard" });
expect(api.parameters).not.toHaveProperty("wordsToRedact");
expect(api.parameters).not.toHaveProperty("mode");
// Fields the wizard never set still get their defaults so the body is complete.
expect(api.parameters).toHaveProperty("wholeWordSearch");
expect(api.parameters).toHaveProperty("customPadding");
});
test("passes an unmapped endpoint's params through unchanged", () => {
expect(
serializeStepFromEndpoint(
"/api/v1/unknown/thing",
{ keep: true },
dynamicRegistry,
),
).toEqual({
operation: "/api/v1/unknown/thing",
parameters: { keep: true },
});
});
});
describe("stepRequiresUpload", () => {
const step = (params: Record<string, unknown>): WorkingToolStep => ({
toolId: "compress" as ToolId,
@@ -197,31 +197,6 @@ export function serializeToolStep(
return { operation, parameters };
}
/**
* Serialize a step held as an endpoint path plus frontend-shaped params - the form the policy setup
* wizard keeps, where params match the tool's UI shape (e.g. redact's `wordsToRedact`) rather than
* the backend contract - into the backend step contract, mapping params through the tool's
* `toApiParams` (merged over its defaults, so fields the wizard never set still get their defaults).
* The endpoint maps to a tool by path, so this works for dynamic-endpoint tools whose config
* endpoint is a function. Endpoints that map to no known tool pass through unchanged.
*/
export function serializeStepFromEndpoint(
operation: string,
params: ErasedToolParams,
registry: Partial<ToolRegistry>,
): ToolApiStep {
const match = findToolByEndpoint({ operation, parameters: params }, registry);
const config = match?.[1].operationConfig;
if (!config) return { operation, parameters: params };
const merged = { ...(config.defaultParameters ?? {}), ...params };
return {
operation: resolveEndpoint(config, merged) ?? operation,
parameters: config.toApiParams
? (config.toApiParams(merged) as Record<string, unknown>)
: {},
};
}
/**
* Find the registry tool for a stored step's endpoint: exact match for static endpoints, else
* membership in a dynamic tool's declared `endpoints` set (replaying its function can't recover a
@@ -0,0 +1,37 @@
import { describe, expect, test } from "vitest";
import { describeToolOperation } from "@app/hooks/tools/shared/toolOperationDescriptor";
interface Params {
a: number;
}
// A minimal config that type-checks against the flatten endpoint's model.
const CONFIG = {
endpoint: "/api/v1/misc/flatten" as const,
defaultParameters: { a: 1 } satisfies Params,
toApiParams: (p: Params) => ({ renderDpi: p.a }),
fromApiParams: (api: { renderDpi?: number }) => ({ a: api.renderDpi ?? 0 }),
};
describe("describeToolOperation", () => {
test("wraps the config's mappers and endpoint into a descriptor", () => {
const d = describeToolOperation("/api/v1/misc/flatten", CONFIG);
expect(d.endpoint).toBe("/api/v1/misc/flatten");
expect(d.toApi({ a: 200 })).toEqual({ renderDpi: 200 });
});
test("fromApi merges the mapped values over the defaults", () => {
const d = describeToolOperation("/api/v1/misc/flatten", CONFIG);
expect(d.fromApi({ renderDpi: 72 })).toEqual({ a: 72 });
});
test("throws when the config lacks a mapper", () => {
expect(() =>
describeToolOperation("/api/v1/misc/flatten", {
endpoint: "/api/v1/misc/flatten" as const,
defaultParameters: { a: 1 },
toApiParams: (p: Params) => ({ renderDpi: p.a }),
}),
).toThrow(/mappers/);
});
});
@@ -0,0 +1,59 @@
/**
* Typed wrapper over a tool's `toApiParams`/`fromApiParams` mappers, binding one endpoint to safe
* frontend<->backend parameter conversion.
*/
import type { ToolApiParams, ToolEndpoint } from "@app/types/toolApiTypes";
export interface ToolOperationDescriptor<E extends ToolEndpoint, TParams> {
readonly endpoint: E;
readonly defaultParameters: TParams;
toApi(params: TParams): ToolApiParams[E];
/** Backend model -> full frontend params (defaults merged under the mapped values). */
fromApi(api: ToolApiParams[E]): TParams;
}
/**
* Structural subset of a tool's config. `CE` is the config's declared endpoint type, inferred from
* the `endpoint` field: the literal for static tools, or the whole `ToolEndpoint` union for
* dynamic-endpoint tools (whose endpoint is a function typed against the union).
*/
export interface BidirectionalToolConfig<TParams, CE extends ToolEndpoint> {
endpoint: CE | null | ((params: TParams) => CE | null);
defaultParameters?: TParams;
toApiParams?(params: TParams): ToolApiParams[CE];
fromApiParams?(api: ToolApiParams[CE]): Partial<TParams>;
}
/**
* Pin a config to `endpoint` (passed explicitly, since dynamic-endpoint tools declare `endpoint` as
* a function). `E extends CE` rejects pairing a static tool's config with the wrong endpoint, while
* allowing a dynamic tool whose `CE` is the full union. Throws when mappers or defaults are missing.
*/
export function describeToolOperation<
E extends CE,
CE extends ToolEndpoint,
TParams,
>(
endpoint: E,
config: BidirectionalToolConfig<TParams, CE>,
): ToolOperationDescriptor<E, TParams> {
const { toApiParams, fromApiParams, defaultParameters } = config;
if (!toApiParams || !fromApiParams || defaultParameters === undefined) {
throw new Error(
`describeToolOperation: "${endpoint}" is missing mappers or defaults`,
);
}
return {
endpoint,
defaultParameters,
// A dynamic tool's mapper is typed against the union; narrow to this endpoint (sound - the
// runtime mapper produces this endpoint's model).
toApi: (params) => toApiParams(params) as ToolApiParams[E],
fromApi: (api) =>
({
...defaultParameters,
...fromApiParams(api as ToolApiParams[CE]),
}) as TParams,
};
}
@@ -38,7 +38,7 @@ const FREE_LICENSE_INFO: LicenseInfo = {
const BASE_NO_LOGIN_CONFIG: AppConfig = {
enableAnalytics: true,
appVersion: "2.14.1",
appVersion: "2.14.2",
serverCertificateEnabled: false,
enableAlphaFunctionality: false,
serverPort: 8080,
+28 -3
View File
@@ -270,8 +270,29 @@
--grad-banner: linear-gradient(135deg, #0f172a 0%, #111827 50%, #1a1535 100%);
}
/* Always-dark code palette. Not theme-switched. */
:root {
/* Code palette — theme-aware: a light GitHub-style box in light mode, the dark
terminal palette in dark mode. (CodeBlock renders plain text, so only the
base colours are load-bearing; the syntax slots are kept for future Shiki. */
:root,
[data-theme="light"] {
--code-bg: #f6f8fa;
--code-bg-alt: #eef1f4;
--code-bg-header: #eaeef2;
--code-text: #1f2328;
--code-dim: #656d76;
--code-muted: #8c959f;
--code-keyword: #cf222e;
--code-string: #0a3069;
--code-number: #0550ae;
--code-fn: #8250df;
--code-type: #953800;
--code-property: #0550ae;
--code-comment: #6e7781;
--code-border: #d0d7de;
/* Window-chrome traffic-light dots in the code-block header. */
--code-dot: #d0d7de;
}
[data-theme="dark"] {
--code-bg: #0f172a;
--code-bg-alt: #1e293b;
--code-bg-header: #1a2332;
@@ -286,12 +307,16 @@
--code-property: #93c5fd;
--code-comment: #475569;
--code-border: #1e293b;
/* Window-chrome traffic-light dots in the code-block header. */
--code-dot: #475569;
}
/* Radii / typography / motion / spacing / z-index — theme-stable */
:root {
/* Home hero strip navy. Theme-stable by design — the hero keeps this deep
navy in both light and dark (it's a branded surface, like the assistant
header), so it's defined once here rather than in the light/dark blocks. */
--color-hero-navy: #16213e;
--radius-xs: 0.1875rem;
--radius-sm: 0.25rem;
--radius-md: 0.375rem;
+9 -6
View File
@@ -61,11 +61,14 @@
background: var(--button-bg, transparent) !important;
}
/* Disabled buttons in dark read as a muted surface (not a dimmed accent that
still looks clickable, and not an invisible transparent pill). Covers every
variant so a disabled primary and a disabled secondary look alike. */
[data-theme="dark"] .sui-btn.mantine-Button-root:disabled:not([data-loading]),
[data-theme="dark"]
.sui-btn--primary.mantine-Button-root:disabled:not([data-loading]),
[data-theme="dark"]
.sui-btn--primary.mantine-Button-root[data-disabled]:not([data-loading]) {
background: var(--button-bg);
color: var(--button-color);
opacity: 0.55;
.sui-btn.mantine-Button-root[data-disabled]:not([data-loading]) {
background: var(--color-bg-muted);
color: var(--color-text-5);
border-color: transparent;
opacity: 1;
}
+2 -1
View File
@@ -51,7 +51,8 @@
transition: background var(--motion-fast);
}
.sui-code__copy:hover {
background: rgba(255, 255, 255, 0.05);
/* Subtle tint that reads on both the light and dark code surfaces. */
background: color-mix(in srgb, var(--code-text) 8%, transparent);
}
.sui-code__pre {
margin: 0;
+42 -31
View File
@@ -19,6 +19,12 @@ export interface TableProps<T> {
rowKey: (row: T) => string;
/** Makes rows interactive (hover + click + keyboard). */
onRowClick?: (row: T) => void;
/**
* Per-row gate for interactivity, checked only when {@link onRowClick} is set. A row for which
* this returns false is inert: no click/keyboard, and not announced as a button. Defaults to
* all rows interactive.
*/
isRowInteractive?: (row: T) => boolean;
/** Rendered in place of the body when there are no rows. */
empty?: ReactNode;
className?: string;
@@ -35,6 +41,7 @@ export function Table<T>({
rows,
rowKey,
onRowClick,
isRowInteractive,
empty,
className,
}: TableProps<T>) {
@@ -66,38 +73,42 @@ export function Table<T>({
</td>
</tr>
) : (
rows.map((row) => (
<tr
key={rowKey(row)}
className={
interactive
? "sui-table__row sui-table__row--interactive"
: "sui-table__row"
}
onClick={onRowClick ? () => onRowClick(row) : undefined}
tabIndex={interactive ? 0 : undefined}
role={interactive ? "button" : undefined}
onKeyDown={
interactive
? (e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
onRowClick?.(row);
rows.map((row) => {
const rowInteractive =
interactive && (isRowInteractive?.(row) ?? true);
return (
<tr
key={rowKey(row)}
className={
rowInteractive
? "sui-table__row sui-table__row--interactive"
: "sui-table__row"
}
onClick={rowInteractive ? () => onRowClick?.(row) : undefined}
tabIndex={rowInteractive ? 0 : undefined}
role={rowInteractive ? "button" : undefined}
onKeyDown={
rowInteractive
? (e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
onRowClick?.(row);
}
}
}
: undefined
}
>
{columns.map((c) => (
<td
key={c.key}
className={`sui-table__td sui-table__td--${c.align ?? "left"}`}
>
{c.render(row)}
</td>
))}
</tr>
))
: undefined
}
>
{columns.map((c) => (
<td
key={c.key}
className={`sui-table__td sui-table__td--${c.align ?? "left"}`}
>
{c.render(row)}
</td>
))}
</tr>
);
})
)}
</tbody>
</table>
@@ -5,6 +5,7 @@ import { Documents } from "@portal/views/Documents";
import { Pipelines } from "@portal/views/Pipelines";
import { PipelineBuilder } from "@portal/views/PipelineBuilder";
import { Sources } from "@portal/views/Sources";
import { SourceBuilder } from "@portal/views/SourceBuilder";
import { AgentBuilder } from "@portal/views/AgentBuilder";
import { Policies } from "@portal/views/Policies";
import { Components } from "@portal/views/Components";
@@ -36,6 +37,14 @@ export function ViewRouter() {
element={<PipelineBuilder />}
/>
<Route path={rel(VIEW_PATHS.sources)} element={<Sources />} />
<Route
path={`${rel(VIEW_PATHS.sources)}/new`}
element={<SourceBuilder />}
/>
<Route
path={`${rel(VIEW_PATHS.sources)}/:id`}
element={<SourceBuilder />}
/>
<Route
path={rel(VIEW_PATHS["agent-builder"])}
element={<AgentBuilder />}
+5
View File
@@ -99,6 +99,11 @@ export const AGENT_STATUS_TONE: Record<AgentStatus, "success" | "neutral"> = {
draft: "neutral",
};
export const AGENT_STATUS_LABEL: Record<AgentStatus, string> = {
published: "portal.agentBuilder.status.published",
draft: "portal.agentBuilder.status.draft",
};
/**
* Catalogue of tools an agent can be granted or denied. Surfaced as the chip
* palette in restricted mode so the deny list is picked from a known set
@@ -0,0 +1,74 @@
/**
* Integrations service layer: stored connections (S3 today; MCP/API later) that
* policy sources and pipeline outputs reference by id instead of embedding
* credentials. Secrets are write-only - reads return them masked, and sending
* the mask back on update keeps the stored value.
*/
import { apiClient } from "@portal/api/http";
export type IntegrationType = "S3" | "MCP" | "API";
export type OwnerScope = "USER" | "TEAM" | "SERVER";
/** Mirrors the backend IntegrationConfigResponse; `config` values are masked. */
export interface IntegrationConfig {
id: number;
integrationType: IntegrationType;
name: string;
scope: OwnerScope;
ownerUserId: number | null;
ownerTeamId: number | null;
enabled: boolean;
locked: boolean;
defaultAccess: string;
config: Record<string, unknown>;
canManage: boolean;
createdAt: string;
updatedAt: string;
}
/** Create/update body; omitted fields keep their stored values on update. */
export interface IntegrationConfigRequest {
integrationType?: IntegrationType;
name?: string;
scope?: OwnerScope;
ownerTeamId?: number | null;
enabled?: boolean;
config?: Record<string, unknown>;
}
export async function fetchIntegrations(): Promise<IntegrationConfig[]> {
return apiClient.local.json<IntegrationConfig[]>("/api/v1/integrations");
}
/** The S3 connections the caller may use, for source/output pickers. */
export async function fetchS3Connections(): Promise<IntegrationConfig[]> {
return (await fetchIntegrations()).filter(
(integration) => integration.integrationType === "S3",
);
}
export async function createIntegration(
body: IntegrationConfigRequest,
): Promise<IntegrationConfig> {
return apiClient.local.json<IntegrationConfig>("/api/v1/integrations", {
method: "POST",
body,
});
}
export async function updateIntegration(
id: number,
body: IntegrationConfigRequest,
): Promise<IntegrationConfig> {
return apiClient.local.json<IntegrationConfig>(
`/api/v1/integrations/${encodeURIComponent(id)}`,
{ method: "PUT", body },
);
}
export async function deleteIntegration(id: number): Promise<void> {
await apiClient.local.json<void>(
`/api/v1/integrations/${encodeURIComponent(id)}`,
{ method: "DELETE" },
);
}
+39 -52
View File
@@ -9,9 +9,12 @@
* approach the editor uses for its own catalogue view.
*/
import type { TFunction } from "i18next";
import { apiClient } from "@portal/api/http";
import { fromWirePolicy, toWirePolicy } from "@app/policies/codec";
import { runsToActivity, runsToStats } from "@app/policies/runs";
import { policyStep, type PolicyToolStep } from "@app/policies/operations";
import type { ToolEndpoint } from "@app/types/toolApiTypes";
import type {
PolicyDecodedState,
PolicyRunView,
@@ -65,7 +68,7 @@ export interface PolicyConfigDef {
rules: string[];
scopeLabel: string;
fields: PolicyField[];
defaultOperations: WirePipelineStep[];
defaultOperations: PolicyToolStep[];
}
export interface PolicyState {
@@ -127,20 +130,11 @@ export interface CatalogueEntry {
}
/* ──────────────────────────────────────────────────────────────────────── */
/* Tool → endpoint registry */
/* Endpoint display labels */
/* ──────────────────────────────────────────────────────────────────────── */
export const TOOL_ENDPOINTS: Record<string, string> = {
redact: "/api/v1/security/auto-redact",
sanitize: "/api/v1/security/sanitize-pdf",
watermark: "/api/v1/security/add-watermark",
ocr: "/api/v1/misc/ocr-pdf",
flatten: "/api/v1/misc/flatten",
compress: "/api/v1/misc/compress-pdf",
};
/** Values are i18n keys — render with t(). */
export const ENDPOINT_LABELS: Record<string, string> = {
/** i18n keys keyed by {@link ToolEndpoint}; labels stored steps in the detail view. */
export const ENDPOINT_LABELS: Partial<Record<ToolEndpoint, string>> = {
"/api/v1/security/auto-redact": "portal.policies.endpoints.autoRedact",
"/api/v1/security/sanitize-pdf": "portal.policies.endpoints.sanitizePdf",
"/api/v1/security/add-watermark": "portal.policies.endpoints.addWatermark",
@@ -153,7 +147,8 @@ export function humanizeEndpoint(
path: string,
t: (key: string) => string,
): string {
if (ENDPOINT_LABELS[path]) return t(ENDPOINT_LABELS[path]);
const label = ENDPOINT_LABELS[path as ToolEndpoint];
if (label) return t(label);
const last = path.split("/").filter(Boolean).pop() ?? path;
return last
.replace(/-/g, " ")
@@ -229,10 +224,7 @@ export const POLICY_CONFIG: Record<string, PolicyConfigDef> = {
"portal.policies.config.ingestion.rules.3",
],
scopeLabel: "portal.policies.config.scopeAll",
defaultOperations: [
{ operation: TOOL_ENDPOINTS.ocr, parameters: {} },
{ operation: TOOL_ENDPOINTS.flatten, parameters: {} },
],
defaultOperations: [policyStep("ocr"), policyStep("flatten")],
fields: [
{
label: "portal.policies.config.ingestion.fields.minConfidence",
@@ -259,32 +251,16 @@ export const POLICY_CONFIG: Record<string, PolicyConfigDef> = {
],
scopeLabel: "portal.policies.config.scopeAll",
defaultOperations: [
{
operation: TOOL_ENDPOINTS.redact,
parameters: {
mode: "automatic",
useRegex: true,
convertPDFToImage: true,
wordsToRedact: DEFAULT_PII_PATTERNS,
},
},
{
operation: TOOL_ENDPOINTS.sanitize,
parameters: {
removeJavaScript: true,
removeEmbeddedFiles: false,
removeMetadata: false,
removeLinks: false,
removeFonts: false,
},
},
{
operation: TOOL_ENDPOINTS.watermark,
// convertPDFToImage bakes the watermark in so it can't be stripped
parameters: {
convertPDFToImage: true,
},
},
// Flatten to image so redactions can't be lifted off.
policyStep("redact", {
useRegex: true,
convertPDFToImage: true,
wordsToRedact: DEFAULT_PII_PATTERNS,
}),
// JavaScript removal only; the tool enables removeEmbeddedFiles by default, so turn it off.
policyStep("sanitize", { removeEmbeddedFiles: false }),
// Bake in via image so it can't be stripped.
policyStep("watermark", { convertPDFToImage: true }),
],
fields: [],
},
@@ -296,10 +272,7 @@ export const POLICY_CONFIG: Record<string, PolicyConfigDef> = {
"portal.policies.config.compliance.rules.2",
],
scopeLabel: "portal.policies.config.scopeAll",
defaultOperations: [
{ operation: TOOL_ENDPOINTS.sanitize, parameters: {} },
{ operation: TOOL_ENDPOINTS.flatten, parameters: {} },
],
defaultOperations: [policyStep("sanitize"), policyStep("flatten")],
fields: [
{
label: "portal.policies.config.compliance.fields.frameworks",
@@ -342,7 +315,7 @@ export const POLICY_CONFIG: Record<string, PolicyConfigDef> = {
"portal.policies.config.routing.rules.2",
],
scopeLabel: "portal.policies.config.scopeAll",
defaultOperations: [{ operation: TOOL_ENDPOINTS.compress, parameters: {} }],
defaultOperations: [policyStep("compress")],
fields: [
{
label: "portal.policies.config.routing.fields.destination",
@@ -373,7 +346,7 @@ export const POLICY_CONFIG: Record<string, PolicyConfigDef> = {
"portal.policies.config.retention.rules.2",
],
scopeLabel: "portal.policies.config.scopeAll",
defaultOperations: [{ operation: TOOL_ENDPOINTS.compress, parameters: {} }],
defaultOperations: [policyStep("compress")],
fields: [
{
label: "portal.policies.config.retention.fields.keepFor",
@@ -556,17 +529,30 @@ const DEFAULT_RETRY_DELAY = 5;
// POST /api/v1/policies endpoint. The real backend ignores unknown fields.
type CatalogueWireBody = WirePolicy & { categoryId: string };
/**
* The persisted policy name derived from its category, e.g. "Security Policy".
* `category.label` is an i18n key, so translate it before building the name;
* otherwise the raw key is persisted and surfaces in the UI (e.g. the Sources
* "Used by" pill).
*/
function policyDisplayName(entry: CatalogueEntry, t: TFunction): string {
return t("portal.policies.defaultName", {
category: t(entry.category.label),
});
}
/** Build a wire policy from a setup wizard result. */
export function buildWireFromSetup(
entry: CatalogueEntry,
result: PolicySetupResult,
t: TFunction,
enabled = true,
): CatalogueWireBody {
return {
categoryId: entry.category.id,
...toWirePolicy({
id: entry.policy?.state.backendId ?? "",
name: `${entry.category.label} Policy`,
name: policyDisplayName(entry, t),
enabled,
categoryId: entry.category.id,
sources: result.sources,
@@ -589,13 +575,14 @@ export function buildWireFromState(
entry: CatalogueEntry,
policy: DecoratedPolicy,
enabled: boolean,
t: TFunction,
): CatalogueWireBody {
const s = policy.state;
return {
categoryId: entry.category.id,
...toWirePolicy({
id: s.backendId ?? "",
name: `${entry.category.label} Policy`,
name: policyDisplayName(entry, t),
enabled,
categoryId: entry.category.id,
sources: s.sources,
+14 -9
View File
@@ -1,3 +1,7 @@
// The bare i18next singleton (the same instance @app/i18n initializes at
// startup), imported directly so this data module doesn't pull i18n's
// init side effects into unit tests that mock react-i18next.
import i18n from "i18next";
import { apiClient } from "@portal/api/http";
import type { Tier } from "@portal/contexts/TierContext";
@@ -269,22 +273,23 @@ function roleIdFor(u: AdminUserSummaryDto): RoleId {
/** A member's last-seen time as plain language; "Never" when no session is tracked. */
function relativeTime(value: number | string | undefined): string {
if (value === undefined || value === null) return "Never";
if (value === undefined || value === null)
return i18n.t("users.activity.never");
const ts = typeof value === "string" ? Date.parse(value) : value;
if (!Number.isFinite(ts) || ts <= 0) return "Never";
if (!Number.isFinite(ts) || ts <= 0) return i18n.t("users.activity.never");
const mins = Math.max(0, Math.round((Date.now() - ts) / 60000));
if (mins < 1) return "Just now";
if (mins < 60) return `${mins}m ago`;
if (mins < 1) return i18n.t("users.activity.justNow");
if (mins < 60) return i18n.t("users.activity.minutesAgo", { count: mins });
const hours = Math.round(mins / 60);
if (hours < 24) return `${hours}h ago`;
if (hours < 24) return i18n.t("users.activity.hoursAgo", { count: hours });
const days = Math.round(hours / 24);
if (days < 7) return `${days}d ago`;
if (days < 7) return i18n.t("users.activity.daysAgo", { count: days });
const weeks = Math.round(days / 7);
if (weeks < 5) return weeks === 1 ? "1 week ago" : `${weeks} weeks ago`;
if (weeks < 5) return i18n.t("users.activity.weeksAgo", { count: weeks });
const months = Math.round(days / 30);
if (months < 12) return months <= 1 ? "1 month ago" : `${months} months ago`;
if (months < 12) return i18n.t("users.activity.monthsAgo", { count: months });
const years = Math.round(days / 365);
return years <= 1 ? "1 year ago" : `${years} years ago`;
return i18n.t("users.activity.yearsAgo", { count: years });
}
/** 0 / huge sentinel license values mean "no seat limit". */
@@ -15,7 +15,7 @@
align-items: center;
gap: 1.25rem;
padding: 1rem 1.25rem;
background: #16213e;
background: var(--color-hero-navy);
}
.portal-editor-hero__logo {
@@ -123,7 +123,7 @@
.portal-editor-hero__action .portal-editor-hero__cta.sui-btn {
background: #ffffff;
border-color: #ffffff;
color: #16213e;
color: var(--color-hero-navy);
}
.portal-editor-hero__action .portal-editor-hero__cta.sui-btn:hover {
background: rgba(255, 255, 255, 0.88);
@@ -26,7 +26,7 @@ export function ProcessingStatusStrip() {
style={{ background: TIER_INFO[tier].dotColor }}
aria-hidden
/>
{TIER_INFO[tier].label}
{t(TIER_INFO[tier].labelKey)}
</span>
<span className="portal-statusstrip__sep" aria-hidden>
·
@@ -18,7 +18,7 @@
gap: 1rem;
flex-wrap: wrap;
padding: 0.875rem 1.25rem;
background: #16213e;
background: var(--color-hero-navy);
}
.portal-welcome__brand {
@@ -90,7 +90,7 @@
.portal-welcome__header .portal-welcome__cta.sui-btn {
background: #ffffff;
border-color: #ffffff;
color: #16213e;
color: var(--color-hero-navy);
}
.portal-welcome__header .portal-welcome__cta.sui-btn:hover {
background: rgba(255, 255, 255, 0.88);
@@ -1,7 +1,11 @@
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { StatusBadge, Tabs, type TabItem } from "@app/ui";
import { type Agent, AGENT_STATUS_TONE } from "@portal/api/agents";
import {
type Agent,
AGENT_STATUS_LABEL,
AGENT_STATUS_TONE,
} from "@portal/api/agents";
import { ScenariosPanel } from "@portal/components/agent-builder/ScenariosPanel";
import { ToolsPanel } from "@portal/components/agent-builder/ToolsPanel";
import { EvalsPanel } from "@portal/components/agent-builder/EvalsPanel";
@@ -52,7 +56,7 @@ export function AgentBuilderPanel({
</div>
<div className="portal-agents__builder-meta">
<StatusBadge tone={AGENT_STATUS_TONE[agent.status]} size="sm">
{agent.status}
{t(AGENT_STATUS_LABEL[agent.status])}
</StatusBadge>
<code className="portal-agents__builder-version">
{agent.version}
@@ -1,5 +1,9 @@
import { useTranslation } from "react-i18next";
import { type Agent, AGENT_STATUS_TONE } from "@portal/api/agents";
import {
type Agent,
AGENT_STATUS_LABEL,
AGENT_STATUS_TONE,
} from "@portal/api/agents";
import { Button, StatusBadge } from "@app/ui";
import "@portal/views/AgentBuilder.css";
@@ -40,7 +44,7 @@ export function AgentSelector({
</span>
<span className="portal-agents__selector-meta">
<StatusBadge tone={AGENT_STATUS_TONE[a.status]} size="sm">
{a.status}
{t(AGENT_STATUS_LABEL[a.status])}
</StatusBadge>
<code className="portal-agents__selector-version">{a.version}</code>
</span>
@@ -1,6 +1,10 @@
import { useTranslation } from "react-i18next";
import { Button, StatusBadge } from "@app/ui";
import { type Agent, AGENT_STATUS_TONE } from "@portal/api/agents";
import {
type Agent,
AGENT_STATUS_LABEL,
AGENT_STATUS_TONE,
} from "@portal/api/agents";
import "@portal/views/AgentBuilder.css";
interface VersionsPanelProps {
@@ -52,7 +56,7 @@ export function VersionsPanel({ agent, historyUnlocked }: VersionsPanelProps) {
{v.version}
</code>
<StatusBadge tone={AGENT_STATUS_TONE[v.status]} size="sm">
{v.status}
{t(AGENT_STATUS_LABEL[v.status])}
</StatusBadge>
{isCurrent && (
<StatusBadge tone="info" size="sm" showDot={false}>
@@ -12,9 +12,11 @@ import {
} from "@app/ui";
import {
type SdkComponent,
BILLING_UNIT_LABEL,
MATURITY_META,
formatPrice,
} from "@portal/api/sdkComponents";
import { TIER_INFO } from "@portal/contexts/TierContext";
import { ComponentPropsTable } from "@portal/components/catalogue/ComponentPropsTable";
import "@portal/views/Components.css";
@@ -113,7 +115,7 @@ export function ComponentDetailModal({
title={t("portal.catalogue.detail.locked.title")}
description={t("portal.catalogue.detail.locked.description", {
name: component.name,
tier: component.minTier,
plan: t(TIER_INFO[component.minTier].labelKey),
})}
/>
)}
@@ -205,7 +207,7 @@ export function ComponentDetailModal({
/>
<StatTile
label={t("portal.catalogue.detail.stats.billedOn")}
value={component.pricing.unit}
value={t(BILLING_UNIT_LABEL[component.pricing.unit])}
/>
<StatTile
label={t("portal.catalogue.detail.stats.freeQuota")}
@@ -220,7 +222,7 @@ export function ComponentDetailModal({
</div>
<p className="portal-components__pricing-note">
{t("portal.catalogue.detail.pricing.note", {
unit: component.pricing.unit,
unit: t(BILLING_UNIT_LABEL[component.pricing.unit]),
})}
</p>
</div>
@@ -157,7 +157,7 @@ export function ReviewQueue({ documents, loading }: ReviewQueueProps) {
<Button
variant="secondary"
onClick={() =>
navigate(`${toPortalPath(VIEW_PATHS.sources)}?new`)
navigate(`${toPortalPath(VIEW_PATHS.sources)}/new`)
}
>
{t("portal.documents.queue.empty.connectSource")}
@@ -23,7 +23,7 @@ export function ApiKeyCard({ apiKey }: { apiKey: ApiKey }) {
rightSection={
<span className="portal-infra__key-head-right">
<StatusBadge tone={KEY_TONE[apiKey.status]} size="sm">
{KEY_LABEL[apiKey.status]}
{t(KEY_LABEL[apiKey.status])}
</StatusBadge>
<span
className={"portal-infra__chevron" + (open ? " is-open" : "")}
@@ -0,0 +1,52 @@
import { describe, expect, it, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import { MantineProvider } from "@mantine/core";
import { Tooltip } from "@app/components/shared/Tooltip";
import type { ToolRegistry } from "@app/data/toolsTaxonomy";
import type { WorkingToolStep } from "@app/hooks/tools/shared/toolAutomation";
import { PipelineStepSettings } from "@portal/components/pipelines/PipelineStepSettings";
vi.mock("react-i18next", () => ({
useTranslation: () => ({
t: (key: string, fallback?: string) => fallback ?? key,
}),
}));
// A stand-in tool-settings UI that uses the shared editor Tooltip. The Tooltip
// pulls in the Preferences + Sidebar contexts, which the portal does not mount
// app-wide — so this reproduces the "usePreferences must be used within a
// PreferencesProvider" crash unless PipelineStepSettings supplies them.
function TooltipSettings() {
return (
<Tooltip content="help">
<button type="button">field</button>
</Tooltip>
);
}
const step = {
support: "editable",
toolId: "compress",
params: {},
} as unknown as WorkingToolStep;
const registry = {
compress: { automationSettings: TooltipSettings },
} as unknown as Partial<ToolRegistry>;
describe("PipelineStepSettings", () => {
it("renders reused editor tool settings (which use the shared Tooltip) without app-wide Preferences/Sidebar providers", () => {
expect(() =>
render(
<MantineProvider>
<PipelineStepSettings
step={step}
registry={registry}
onChange={() => {}}
/>
</MantineProvider>,
),
).not.toThrow();
expect(screen.getByText("field")).toBeInTheDocument();
});
});
@@ -1,6 +1,8 @@
import { Suspense } from "react";
import { useTranslation } from "react-i18next";
import { Banner } from "@app/ui";
import { PreferencesProvider } from "@app/contexts/PreferencesContext";
import { SidebarProvider } from "@app/contexts/SidebarContext";
import { type ToolRegistry } from "@app/data/toolsTaxonomy";
import { type ErasedToolParams } from "@app/hooks/tools/shared/toolOperationTypes";
import { type WorkingToolStep } from "@app/hooks/tools/shared/toolAutomation";
@@ -46,14 +48,18 @@ export function PipelineStepSettings({
}
return (
<Suspense fallback={null}>
<Settings
parameters={step.params}
onParameterChange={(key, value) =>
onChange({ ...step.params, [key]: value })
}
disabled={false}
/>
</Suspense>
<PreferencesProvider>
<SidebarProvider>
<Suspense fallback={null}>
<Settings
parameters={step.params}
onParameterChange={(key, value) =>
onChange({ ...step.params, [key]: value })
}
disabled={false}
/>
</Suspense>
</SidebarProvider>
</PreferencesProvider>
);
}
@@ -0,0 +1,134 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import {
fireEvent,
render as baseRender,
screen,
waitFor,
} from "@testing-library/react";
import { MantineProvider } from "@mantine/core";
import { PolicySetupWizard } from "@portal/components/policies/PolicySetupWizard";
import {
POLICY_CATEGORIES,
POLICY_CONFIG,
type CatalogueEntry,
type DecoratedPolicy,
type PolicySetupResult,
type PipelineStep,
} from "@portal/api/policies";
const render = (ui: Parameters<typeof baseRender>[0]) =>
baseRender(ui, { wrapper: MantineProvider });
// Deterministic i18n: return the fallback when given, else the key. initReactI18next is stubbed
// because the import graph pulls core/i18n.ts, which registers it as a plugin.
vi.mock("react-i18next", () => ({
useTranslation: () => ({
// Second arg is a string fallback in some call sites and an interpolation object in others;
// only treat a string as the fallback.
t: (key: string, fallback?: unknown) =>
typeof fallback === "string" ? fallback : key,
i18n: { changeLanguage: vi.fn() },
}),
initReactI18next: { type: "3rdParty", init: vi.fn() },
}));
const fetchSources = vi.fn();
vi.mock("@portal/api/sources", () => ({
fetchSources: () => fetchSources(),
}));
const CONTINUE = "portal.policies.wizard.actions.continue";
const SAVE_CHANGES = "portal.policies.wizard.actions.saveChanges";
const ENABLE = "portal.policies.wizard.actions.enablePolicy";
const security = POLICY_CATEGORIES.find((c) => c.id === "security")!;
const securityConfig = POLICY_CONFIG.security;
function editEntry(steps: PipelineStep[]): CatalogueEntry {
const policy: DecoratedPolicy = {
category: security,
config: securityConfig,
state: {
configured: true,
status: "active",
sources: ["editor"],
scopeTypes: [],
reviewerEmail: "",
fieldValues: {},
runOn: "upload",
outputMode: "new_version",
outputName: "",
outputNamePosition: "suffix",
maxRetries: 0,
retryDelayMinutes: 0,
backendId: "pol-1",
isDefault: true,
},
steps,
stats: { enforced: 0, dataProcessed: "-", activeFor: "-" },
activity: [],
};
return { category: security, config: securityConfig, policy };
}
/** Advance the wizard from the workflow tab to the settings tab and submit. */
async function submitWizard(saveLabel: string) {
fireEvent.click(await screen.findByRole("button", { name: CONTINUE }));
fireEvent.click(await screen.findByRole("button", { name: saveLabel }));
}
describe("PolicySetupWizard", () => {
beforeEach(() => {
fetchSources.mockResolvedValue({ sources: [] });
});
it("round-trips a saved step's backend params on edit", async () => {
const onSubmit = vi.fn().mockResolvedValue(undefined);
const entry = editEntry([
{
operation: "/api/v1/security/auto-redact",
parameters: { listOfText: "foo\nbar", useRegex: true },
},
]);
render(
<PolicySetupWizard entry={entry} onClose={vi.fn()} onSubmit={onSubmit} />,
);
await submitWizard(SAVE_CHANGES);
await waitFor(() => expect(onSubmit).toHaveBeenCalled());
const result = onSubmit.mock.calls[0][1] as PolicySetupResult;
// Only the saved tool is enabled on edit, and its patterns survive the wire -> UI -> wire trip.
expect(result.steps).toEqual([
expect.objectContaining({
operation: "/api/v1/security/auto-redact",
parameters: expect.objectContaining({ listOfText: "foo\nbar" }),
}),
]);
});
it("seeds the preset chain for a new policy (redact + sanitize on, watermark off)", async () => {
const onSubmit = vi.fn().mockResolvedValue(undefined);
const entry: CatalogueEntry = {
category: security,
config: securityConfig,
policy: null,
};
render(
<PolicySetupWizard entry={entry} onClose={vi.fn()} onSubmit={onSubmit} />,
);
await submitWizard(ENABLE);
await waitFor(() => expect(onSubmit).toHaveBeenCalled());
const result = onSubmit.mock.calls[0][1] as PolicySetupResult;
const endpoints = result.steps.map((s) => s.operation);
expect(endpoints).toEqual([
"/api/v1/security/auto-redact",
"/api/v1/security/sanitize-pdf",
]);
// Redact carries the preset PII patterns as the backend's listOfText.
const redact = result.steps[0].parameters as { listOfText?: string };
expect(redact.listOfText).toBeTruthy();
});
});
@@ -13,23 +13,24 @@ import {
} from "@app/ui";
import { SettingsRow } from "@app/ui/SettingsRow";
import {
TOOL_ENDPOINTS,
humanizeEndpoint,
type CatalogueEntry,
type PipelineStep,
type PolicySetupResult,
} from "@portal/api/policies";
import type { ToolRegistry, ToolRegistryEntry } from "@app/data/toolsTaxonomy";
import {
deserializeToolStep,
serializeStepFromEndpoint,
} from "@app/hooks/tools/shared/toolAutomation";
policyEndpoint,
policyStepFromWire,
policyStepToWire,
type PolicyParams,
type PolicyToolId,
type PolicyToolStep,
} from "@app/policies/operations";
import { fetchSources } from "@portal/api/sources";
import { useAsync } from "@portal/hooks/useAsync";
import { PolicyFieldRow } from "@portal/components/policies/PolicyFieldRow";
import { policyIcon } from "@portal/components/policies/policyIcons";
import { sourceTypeMeta } from "@portal/components/sources/sourceTypes";
import { useToolRegistry } from "@app/contexts/ToolRegistryContext";
import { PolicyRedactConfig } from "@app/components/policies/PolicyRedactConfig";
import { PolicyWatermarkConfig } from "@app/components/policies/PolicyWatermarkConfig";
import "@portal/views/Policies.css";
@@ -47,12 +48,8 @@ interface PolicySetupWizardProps {
type Step = "workflow" | "settings";
/** A configurable tool in the workflow step: whether it runs + its params. */
interface ToolState {
operation: string;
enabled: boolean;
parameters: Record<string, unknown>;
}
/** A policy step plus whether it runs. */
type ToolState = PolicyToolStep & { enabled: boolean };
/** Resolve each field's effective value: saved override, else definition default. */
function resolveFieldValues(
@@ -69,9 +66,8 @@ function resolveFieldValues(
* round-trips); otherwise the category preset's default chain. Each preset step
* starts enabled — the user toggles tools off in the workflow.
*/
// Temporary: tracks which tools start disabled until the tool registry lands in
// the portal and can drive this via registry metadata or a defaultEnabled flag.
const DISABLED_BY_DEFAULT = new Set(["/api/v1/security/add-watermark"]);
// Temporary until the catalogue carries a defaultEnabled flag.
const DISABLED_BY_DEFAULT = new Set<PolicyToolId>(["watermark"]);
/**
* Policy-facing framing for each capability a policy can include. Labels and
@@ -81,43 +77,43 @@ const DISABLED_BY_DEFAULT = new Set(["/api/v1/security/add-watermark"]);
* the humanised endpoint name with no description.
*/
const CAPABILITY_META: Record<
string,
PolicyToolId,
{ labelKey: string; labelEn: string; descKey: string; descEn: string }
> = {
[TOOL_ENDPOINTS.redact]: {
redact: {
labelKey: "portal.policies.wizard.capability.redact.label",
labelEn: "Redact sensitive information",
descKey: "portal.policies.wizard.capability.redact.desc",
descEn:
"Finds and blacks out sensitive details — like Social Security and card numbers — so they can't be read.",
},
[TOOL_ENDPOINTS.sanitize]: {
sanitize: {
labelKey: "portal.policies.wizard.capability.sanitize.label",
labelEn: "Strip active content",
descKey: "portal.policies.wizard.capability.sanitize.desc",
descEn:
"Removes hidden JavaScript so nothing can run automatically when the document is opened.",
},
[TOOL_ENDPOINTS.watermark]: {
watermark: {
labelKey: "portal.policies.wizard.capability.watermark.label",
labelEn: "Apply a watermark",
descKey: "portal.policies.wizard.capability.watermark.desc",
descEn: "Stamps a visible mark (e.g. “Confidential”) across every page.",
},
[TOOL_ENDPOINTS.ocr]: {
ocr: {
labelKey: "portal.policies.wizard.capability.ocr.label",
labelEn: "Make text searchable",
descKey: "portal.policies.wizard.capability.ocr.desc",
descEn: "Runs OCR so scanned pages become selectable, searchable text.",
},
[TOOL_ENDPOINTS.flatten]: {
flatten: {
labelKey: "portal.policies.wizard.capability.flatten.label",
labelEn: "Flatten the document",
descKey: "portal.policies.wizard.capability.flatten.desc",
descEn:
"Merges form fields and annotations into the page so they can't be edited.",
},
[TOOL_ENDPOINTS.compress]: {
compress: {
labelKey: "portal.policies.wizard.capability.compress.label",
labelEn: "Reduce file size",
descKey: "portal.policies.wizard.capability.compress.desc",
@@ -125,29 +121,24 @@ const CAPABILITY_META: Record<
},
};
function seedTools(
entry: CatalogueEntry,
registry: Partial<ToolRegistry>,
): ToolState[] {
function seedTools(entry: CatalogueEntry): ToolState[] {
const savedSteps = entry.policy?.steps ?? [];
const savedByOp = new Map(savedSteps.map((s) => [s.operation, s]));
// Always use defaultOperations as the canonical list so tools added after a
// policy was first saved still appear when editing.
return entry.config.defaultOperations.map((s) => {
const saved = savedByOp.get(s.operation);
const savedByTool = new Map<PolicyToolId, PolicyToolStep>();
for (const wire of savedSteps) {
const step = policyStepFromWire(wire);
if (step) savedByTool.set(step.toolId, step);
}
// defaultOperations is the canonical list (so tools added later still show on edit); a saved
// step's params win over the preset.
return entry.config.defaultOperations.map((preset) => {
const saved = savedByTool.get(preset.toolId);
return {
operation: s.operation,
...(saved ?? preset),
enabled: saved
? true
: savedSteps.length > 0
? false
: !DISABLED_BY_DEFAULT.has(s.operation),
// Saved steps are in the backend contract shape; map them back to the UI
// shape the config controls edit (e.g. `listOfText` -> `wordsToRedact`).
// Presets are already authored in the UI shape, so use them as-is.
parameters: saved
? deserializeToolStep(saved, registry).params
: s.parameters,
: !DISABLED_BY_DEFAULT.has(preset.toolId),
};
});
}
@@ -185,26 +176,12 @@ function PolicySetupWizardBody({
onSubmit: (entry: CatalogueEntry, result: PolicySetupResult) => Promise<void>;
}) {
const { t } = useTranslation();
const { allTools: toolRegistry } = useToolRegistry();
// Portal tool operations are endpoint paths (/api/v1/…), not short registry IDs.
// Build a reverse map so we can look up icons and display names by endpoint.
const registryByEndpoint = useMemo(() => {
const map = new Map<string, ToolRegistryEntry>();
for (const entry of Object.values(toolRegistry)) {
const ep = (entry as ToolRegistryEntry).operationConfig?.endpoint;
if (typeof ep === "string") map.set(ep, entry as ToolRegistryEntry);
}
return map;
}, [toolRegistry]);
const { category, config, policy } = entry;
const isEdit = policy != null;
const [step, setStep] = useState<Step>("workflow");
const [tools, setTools] = useState<ToolState[]>(() =>
seedTools(entry, toolRegistry),
);
const [tools, setTools] = useState<ToolState[]>(() => seedTools(entry));
const [fieldValues, setFieldValues] = useState(() =>
resolveFieldValues(entry),
);
@@ -213,22 +190,11 @@ function PolicySetupWizardBody({
);
const sourcesAsync = useAsync(() => fetchSources(), []);
const availableSources = useMemo(() => {
const backendSources = (sourcesAsync.data?.sources ?? []).filter(
(s) => s.status !== "disabled",
);
const editorSource = {
id: "editor",
name: t("portal.sources.types.editor.label"),
type: "editor",
status: "active" as const,
referenceCount: 0,
referencingPolicies: [],
config: [],
docsTotal: null,
};
return [editorSource, ...backendSources];
}, [sourcesAsync.data, t]);
const availableSources = useMemo(
() =>
(sourcesAsync.data?.sources ?? []).filter((s) => s.status !== "disabled"),
[sourcesAsync.data],
);
// Document-type scoping has no UI; preserve any saved scope on edit and
// default new policies to all document types.
const [scopeTypes] = useState<string[]>(policy?.state.scopeTypes ?? []);
@@ -256,9 +222,20 @@ function PolicySetupWizardBody({
const enabledTools = useMemo(() => tools.filter((tl) => tl.enabled), [tools]);
function patchTool(operation: string, patch: Partial<ToolState>) {
function setToolEnabled(toolId: PolicyToolId, enabled: boolean) {
setTools((prev) =>
prev.map((tl) => (tl.operation === operation ? { ...tl, ...patch } : tl)),
prev.map((tl) => (tl.toolId === toolId ? { ...tl, enabled } : tl)),
);
}
function setToolParams<Id extends PolicyToolId>(
toolId: Id,
params: PolicyParams<Id>,
) {
setTools((prev) =>
prev.map((tl) =>
tl.toolId === toolId ? ({ ...tl, params } as ToolState) : tl,
),
);
}
@@ -277,11 +254,8 @@ function PolicySetupWizardBody({
}
setError(null);
setSubmitting(true);
// Map each tool's UI-shaped params (e.g. redact's `wordsToRedact`) into the
// backend step contract (e.g. `listOfText`) via its `toApiParams`; saving the
// UI shape verbatim would drop those fields and the step would run with none.
const steps: PipelineStep[] = enabledTools.map((tl) =>
serializeStepFromEndpoint(tl.operation, tl.parameters, toolRegistry),
policyStepToWire(tl),
);
try {
await onSubmit(entry, {
@@ -386,20 +360,16 @@ function PolicySetupWizardBody({
<Card padding="none">
<div className="portal-policies__capabilities">
{tools.map((tl) => {
const meta = CAPABILITY_META[tl.operation];
const meta = CAPABILITY_META[tl.toolId];
const label = meta
? t(meta.labelKey, meta.labelEn)
: (registryByEndpoint.get(tl.operation)?.name ??
humanizeEndpoint(tl.operation, t));
: humanizeEndpoint(policyEndpoint(tl.toolId), t);
const description = meta
? t(meta.descKey, meta.descEn)
: undefined;
const hasConfig =
tl.operation === TOOL_ENDPOINTS.redact ||
tl.operation === TOOL_ENDPOINTS.watermark;
return (
<div
key={tl.operation}
key={tl.toolId}
className="portal-policies__capability"
data-on={tl.enabled || undefined}
>
@@ -411,27 +381,27 @@ function PolicySetupWizardBody({
size="sm"
checked={tl.enabled}
onChange={(checked) =>
patchTool(tl.operation, { enabled: checked })
setToolEnabled(tl.toolId, checked)
}
label=""
/>
}
/>
{tl.enabled && hasConfig && (
{tl.enabled && (
<div className="portal-policies__capability-config">
{tl.operation === TOOL_ENDPOINTS.redact && (
{tl.toolId === "redact" && (
<PolicyRedactConfig
parameters={tl.parameters}
onChange={(parameters) =>
patchTool(tl.operation, { parameters })
parameters={tl.params}
onChange={(params) =>
setToolParams("redact", params)
}
/>
)}
{tl.operation === TOOL_ENDPOINTS.watermark && (
{tl.toolId === "watermark" && (
<PolicyWatermarkConfig
parameters={tl.parameters}
onChange={(parameters) =>
patchTool(tl.operation, { parameters })
parameters={tl.params}
onChange={(params) =>
setToolParams("watermark", params)
}
/>
)}
@@ -475,9 +445,8 @@ function PolicySetupWizardBody({
{t("portal.policies.wizard.sources.loading")}
</p>
) : (
// The editor is always an available source (unconditionally prepended
// to availableSources), so the list is never empty — no "no sources"
// state exists.
// The backend always returns the editor as a virtual source, so the
// loaded list is never empty - no "no sources" state exists.
<div className="portal-policies__sources">
{availableSources.map((src) => (
// A selectable multi-line tile (icon + name + type + check).
@@ -75,7 +75,7 @@ export function DocumentLedger({
className="portal-proc__stage-label"
data-current={cur || undefined}
>
{group.label}
{t(group.label)}
</span>
{blurb && (
<span className="portal-proc__stage-hint">
@@ -1,16 +0,0 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { ConnectWizard } from "@portal/components/sources/ConnectWizard";
const meta: Meta<typeof ConnectWizard> = {
title: "Portal/Sources/ConnectWizard",
component: ConnectWizard,
parameters: { layout: "fullscreen" },
args: { open: true, onClose: () => {}, onCreated: () => {} },
};
export default meta;
type Story = StoryObj<typeof ConnectWizard>;
/** Opens on the type-picker step; Continue/Back walk through the three steps. */
export const Open: Story = {};
export const Closed: Story = { args: { open: false } };
@@ -1,192 +0,0 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { MantineProvider } from "@mantine/core";
import { HttpError } from "@portal/api/http";
import { ConnectWizard } from "@portal/components/sources/ConnectWizard";
function renderWithMantine(ui: React.ReactElement) {
return render(<MantineProvider>{ui}</MantineProvider>);
}
// Deterministic i18n: keys come back verbatim so the test never waits on the
// async TOML backend.
vi.mock("react-i18next", () => ({
useTranslation: () => ({
t: (key: string) => key,
i18n: { changeLanguage: vi.fn() },
}),
}));
// Mock the service layer so no real fetch is issued.
const createSource = vi.fn();
vi.mock("@portal/api/sources", () => ({
createSource: (...args: unknown[]) => createSource(...args),
}));
/** Step through the folder-source flow: choose type -> configure -> review. */
function stepToReview() {
// Step 0: folder is the default-selected type. Continue.
fireEvent.click(screen.getByText("portal.sources.wizard.continue"));
// Step 1: fill the required name + directory fields.
const inputs = screen.getAllByRole("textbox");
// First textbox is the name field, second is the folder's directory field.
fireEvent.change(inputs[0], { target: { value: "Claims intake" } });
fireEvent.change(inputs[1], { target: { value: "/data/incoming" } });
fireEvent.click(screen.getByText("portal.sources.wizard.continue"));
}
describe("ConnectWizard", () => {
beforeEach(() => {
createSource.mockReset();
});
it("creates a folder source with the configured options", async () => {
createSource.mockResolvedValue({ id: "src-1" });
const onCreated = vi.fn();
const onClose = vi.fn();
renderWithMantine(
<ConnectWizard open onClose={onClose} onCreated={onCreated} />,
);
stepToReview();
// Step 2: submit via the final "connect source" action.
fireEvent.click(screen.getByText("portal.sources.actions.connectSource"));
await waitFor(() => {
expect(createSource).toHaveBeenCalledTimes(1);
});
expect(createSource).toHaveBeenCalledWith({
name: "Claims intake",
type: "folder",
options: {
directory: "/data/incoming",
mode: "consume",
recursive: "false",
identity: "stat",
},
enabled: true,
});
await waitFor(() => {
expect(onCreated).toHaveBeenCalledTimes(1);
});
});
it("creates an s3 source with a masked secret in review", async () => {
createSource.mockResolvedValue({ id: "src-2" });
renderWithMantine(
<ConnectWizard open onClose={vi.fn()} onCreated={vi.fn()} />,
);
// Step 0: pick the S3 type card.
fireEvent.click(screen.getByText("portal.sources.types.s3.label"));
fireEvent.click(screen.getByText("portal.sources.wizard.continue"));
// Step 1: name, bucket, credentials. The secret renders as a password
// input, so it is not part of the textbox roles.
const inputs = screen.getAllByRole("textbox") as HTMLInputElement[];
fireEvent.change(inputs[0], { target: { value: "Claims bucket" } });
fireEvent.change(inputs[1], { target: { value: "claims-inbox" } });
fireEvent.change(inputs[4], { target: { value: "AKIAEXAMPLE" } });
const secret = document.querySelector(
'input[type="password"]',
) as HTMLInputElement;
fireEvent.change(secret, { target: { value: "shh-secret" } });
fireEvent.click(screen.getByText("portal.sources.wizard.continue"));
// Step 2: the secret is masked in review, never echoed.
expect(screen.queryByText("shh-secret")).not.toBeInTheDocument();
expect(screen.getByText("********")).toBeInTheDocument();
fireEvent.click(screen.getByText("portal.sources.actions.connectSource"));
await waitFor(() => {
expect(createSource).toHaveBeenCalledTimes(1);
});
expect(createSource).toHaveBeenCalledWith({
name: "Claims bucket",
type: "s3",
options: {
bucket: "claims-inbox",
region: "us-east-1",
prefix: "",
accessKeyId: "AKIAEXAMPLE",
secretAccessKey: "shh-secret",
endpoint: "",
mode: "consume",
},
enabled: true,
});
});
it("edits an existing source: prefilled, skips type, submits with its id", async () => {
createSource.mockResolvedValue({ id: "s1" });
const onCreated = vi.fn();
renderWithMantine(
<ConnectWizard
open
source={{
id: "s1",
name: "James",
type: "folder",
options: { directory: "'/data/in'", mode: "consume" },
enabled: true,
}}
onClose={vi.fn()}
onCreated={onCreated}
/>,
);
// Edit opens on the configure step with name + directory prefilled.
const inputs = screen.getAllByRole("textbox") as HTMLInputElement[];
expect(inputs[0].value).toBe("James");
expect(inputs[1].value).toBe("'/data/in'");
// Fix the stray quotes, continue to review, save.
fireEvent.change(inputs[1], { target: { value: "/data/in" } });
fireEvent.click(screen.getByText("portal.sources.wizard.continue"));
fireEvent.click(screen.getByText("portal.sources.wizard.save"));
await waitFor(() => {
expect(createSource).toHaveBeenCalledTimes(1);
});
// Options absent from the stored source are submitted at their defaults.
expect(createSource).toHaveBeenCalledWith({
id: "s1",
name: "James",
type: "folder",
options: {
directory: "/data/in",
mode: "consume",
recursive: "false",
identity: "stat",
},
enabled: true,
});
await waitFor(() => {
expect(onCreated).toHaveBeenCalledTimes(1);
});
});
it("renders the inline error message when create fails", async () => {
createSource.mockRejectedValue(
new HttpError(400, "Bad Request", {
detail: "Directory is not readable",
}),
);
renderWithMantine(
<ConnectWizard open onClose={vi.fn()} onCreated={vi.fn()} />,
);
stepToReview();
fireEvent.click(screen.getByText("portal.sources.actions.connectSource"));
expect(
await screen.findByText("Directory is not readable"),
).toBeInTheDocument();
});
});
@@ -1,298 +0,0 @@
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import {
Banner,
Button,
FormField,
Input,
Modal,
Select,
StatTile,
} from "@app/ui";
import { errorMessage } from "@portal/api/http";
import { createSource, type Source } from "@portal/api/sources";
import { creatableSourceTypes } from "@portal/components/sources/creatableSourceTypes";
import {
defaultOptions,
sourceTypeMeta,
type CreatableSourceType,
} from "@portal/components/sources/sourceTypes";
import "@portal/views/Sources.css";
const OFFERED_TYPES = creatableSourceTypes();
const DEFAULT_TYPE = OFFERED_TYPES[0];
/** Wizard steps. Editing skips type selection (the type is fixed once created). */
type StepId = "type" | "configure" | "review";
const CREATE_STEPS: StepId[] = ["type", "configure", "review"];
const EDIT_STEPS: StepId[] = ["configure", "review"];
interface ConnectWizardProps {
open: boolean;
onClose: () => void;
/** Called after a source is created or updated so the page can refetch. */
onCreated: () => void;
/** When set, the wizard edits this existing source instead of creating one. */
source?: Source;
}
/** The creatable-type metadata for a source's stored type, falling back to the first offered. */
function typeFor(type: string | undefined): CreatableSourceType {
return OFFERED_TYPES.find((t) => t.type === type) ?? DEFAULT_TYPE;
}
/** Source options coerced to strings for the form, defaulted from the type's fields. */
function optionsFor(
type: CreatableSourceType,
options: Record<string, unknown> | undefined,
): Record<string, string> {
const out = defaultOptions(type);
for (const [key, value] of Object.entries(options ?? {})) {
out[key] = value == null ? "" : String(value);
}
return out;
}
/**
* Guided flow for connecting a source (choose type -> configure -> review) or
* editing an existing one (configure -> review, type fixed). On submit a blank
* id creates and a set id updates, matching the backend's POST contract.
*/
export function ConnectWizard({
open,
onClose,
onCreated,
source,
}: ConnectWizardProps) {
const { t } = useTranslation();
const isEdit = source !== undefined;
const steps = isEdit ? EDIT_STEPS : CREATE_STEPS;
const [stepIndex, setStepIndex] = useState(0);
const [type, setType] = useState<CreatableSourceType>(() =>
typeFor(source?.type),
);
const [name, setName] = useState(source?.name ?? "");
const [options, setOptions] = useState<Record<string, string>>(() =>
optionsFor(typeFor(source?.type), source?.options),
);
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
// Re-seed the form whenever the wizard opens (or its target source changes) so
// editing prefills the current config and a reopened create starts clean.
useEffect(() => {
if (!open) return;
const ct = typeFor(source?.type);
setStepIndex(0);
setType(ct);
setName(source?.name ?? "");
setOptions(optionsFor(ct, source?.options));
setSubmitting(false);
setError(null);
}, [open, source]);
const stepId = steps[stepIndex];
const isLast = stepIndex === steps.length - 1;
function chooseType(next: CreatableSourceType) {
setType(next);
setOptions(defaultOptions(next));
}
const requiredFilled = type.fields.every(
(f) => !f.required || (options[f.key] ?? "").trim() !== "",
);
const canContinue =
stepId === "configure" ? name.trim() !== "" && requiredFilled : true;
async function advance() {
if (!isLast) {
setStepIndex((i) => i + 1);
return;
}
setSubmitting(true);
setError(null);
try {
const fields = {
name: name.trim(),
type: type.type,
options,
enabled: source?.enabled ?? true,
};
await createSource(isEdit ? { ...fields, id: source.id } : fields);
onCreated();
onClose();
} catch (e) {
setError(errorMessage(e));
} finally {
setSubmitting(false);
}
}
const stepLabels: Record<StepId, string> = {
type: t("portal.sources.wizard.steps.chooseType"),
configure: t("portal.sources.wizard.steps.configure"),
review: t("portal.sources.wizard.steps.review"),
};
return (
<Modal
open={open}
onClose={onClose}
width="lg"
title={
isEdit
? t("portal.sources.wizard.editTitle")
: t("portal.sources.wizard.title")
}
subtitle={t("portal.sources.wizard.subtitle", {
current: stepIndex + 1,
total: steps.length,
label: stepLabels[stepId],
})}
footer={
<div className="portal-sources__wizard-footer">
<Button
variant="tertiary"
size="sm"
disabled={submitting}
onClick={() =>
stepIndex === 0 ? onClose() : setStepIndex((i) => i - 1)
}
>
{stepIndex === 0
? t("portal.sources.wizard.cancel")
: t("portal.sources.wizard.back")}
</Button>
<Button
size="sm"
onClick={advance}
loading={submitting}
disabled={!canContinue}
rightSection={!isLast ? <span aria-hidden>→</span> : undefined}
>
{!isLast
? t("portal.sources.wizard.continue")
: isEdit
? t("portal.sources.wizard.save")
: t("portal.sources.actions.connectSource")}
</Button>
</div>
}
>
<ol className="portal-sources__steps" aria-hidden>
{steps.map((id, i) => (
<li
key={id}
className={
"portal-sources__step" +
(i === stepIndex ? " is-active" : i < stepIndex ? " is-done" : "")
}
>
<span className="portal-sources__step-mark">
{i < stepIndex ? "✓" : i + 1}
</span>
{stepLabels[id]}
</li>
))}
</ol>
{stepId === "type" && (
<div className="portal-sources__type-grid">
{OFFERED_TYPES.map((ct) => (
<Button
key={ct.type}
variant="tertiary"
className={
"portal-sources__type-card" +
(type.type === ct.type ? " is-selected" : "")
}
onClick={() => chooseType(ct)}
>
<span className="portal-sources__type-icon" aria-hidden>
{sourceTypeMeta(ct.type).icon}
</span>
<span className="portal-sources__type-name">
{t(ct.labelKey)}
</span>
</Button>
))}
</div>
)}
{stepId === "configure" && (
<div className="portal-sources__wizard-body">
<FormField label={t("portal.sources.wizard.name")} required>
<Input
value={name}
placeholder={t("portal.sources.wizard.namePlaceholder")}
onChange={(e) => setName(e.target.value)}
/>
</FormField>
{type.fields.map((field) => (
<FormField
key={field.key}
label={t(field.labelKey)}
helperText={
field.helperTextKey ? t(field.helperTextKey) : undefined
}
required={field.required}
>
{field.control === "select" ? (
<Select
value={options[field.key] ?? ""}
options={(field.options ?? []).map((o) => ({
value: o.value,
label: t(o.labelKey),
}))}
onChange={(value) =>
setOptions((o) => ({ ...o, [field.key]: value ?? "" }))
}
/>
) : (
<Input
type={field.control === "password" ? "password" : undefined}
value={options[field.key] ?? ""}
placeholder={
field.placeholderKey ? t(field.placeholderKey) : undefined
}
onChange={(e) =>
setOptions((o) => ({ ...o, [field.key]: e.target.value }))
}
/>
)}
</FormField>
))}
</div>
)}
{stepId === "review" && (
<div className="portal-sources__wizard-body">
<div className="portal-sources__stat-grid">
<StatTile
label={t("portal.sources.wizard.name")}
value={name || "—"}
/>
<StatTile
label={t("portal.sources.wizard.type")}
value={t(type.labelKey)}
/>
{type.fields.map((field) => (
<StatTile
key={field.key}
label={t(field.labelKey)}
value={
field.control === "password" && options[field.key]
? "********"
: options[field.key] || "—"
}
/>
))}
</div>
{error && <Banner tone="danger" description={error} />}
</div>
)}
</Modal>
);
}
@@ -0,0 +1,82 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import {
fireEvent,
render as baseRender,
screen,
waitFor,
} from "@testing-library/react";
import { MantineProvider } from "@mantine/core";
import { HttpError } from "@portal/api/http";
import { ConnectionsTab } from "@portal/components/sources/ConnectionsTab";
import type { IntegrationConfig } from "@portal/api/integrations";
const render = (ui: Parameters<typeof baseRender>[0]) =>
baseRender(ui, { wrapper: MantineProvider });
vi.mock("react-i18next", () => ({
useTranslation: () => ({
t: (key: string) => key,
i18n: { changeLanguage: vi.fn() },
}),
}));
const fetchS3Connections = vi.fn();
const deleteIntegration = vi.fn();
vi.mock("@portal/api/integrations", () => ({
fetchS3Connections: () => fetchS3Connections(),
deleteIntegration: (id: number) => deleteIntegration(id),
createIntegration: vi.fn(),
updateIntegration: vi.fn(),
}));
const CONNECTION = {
id: 5,
integrationType: "S3",
name: "Claims bucket",
config: { bucket: "inbox", region: "us-east-1" },
canManage: true,
} as unknown as IntegrationConfig;
describe("ConnectionsTab", () => {
beforeEach(() => {
fetchS3Connections.mockReset();
deleteIntegration.mockReset();
deleteIntegration.mockResolvedValue(undefined);
});
it("shows the empty state when there are no connections", async () => {
fetchS3Connections.mockResolvedValue([]);
render(<ConnectionsTab />);
expect(
await screen.findByText("portal.connections.empty.title"),
).toBeInTheDocument();
});
it("lists connections and deletes one", async () => {
fetchS3Connections.mockResolvedValueOnce([CONNECTION]);
fetchS3Connections.mockResolvedValueOnce([]);
render(<ConnectionsTab />);
expect(await screen.findByText("Claims bucket")).toBeInTheDocument();
fireEvent.click(screen.getByText("portal.connections.delete"));
await waitFor(() => expect(deleteIntegration).toHaveBeenCalledWith(5));
});
it("surfaces the 409 when deleting a connection still in use", async () => {
fetchS3Connections.mockResolvedValue([CONNECTION]);
deleteIntegration.mockRejectedValue(
new HttpError(409, "Conflict", {
detail: "Integration is in use by: source 'Claims intake'",
}),
);
render(<ConnectionsTab />);
await screen.findByText("Claims bucket");
fireEvent.click(screen.getByText("portal.connections.delete"));
expect(
await screen.findByText(
"Integration is in use by: source 'Claims intake'",
),
).toBeInTheDocument();
});
});
@@ -0,0 +1,186 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import AddRoundedIcon from "@mui/icons-material/AddRounded";
import {
Banner,
Button,
EmptyState,
Skeleton,
Table,
type TableColumn,
} from "@app/ui";
import { errorMessage } from "@portal/api/http";
import {
deleteIntegration,
fetchS3Connections,
type IntegrationConfig,
} from "@portal/api/integrations";
import { SourcesIcon } from "@portal/components/icons";
import { S3ConnectionModal } from "@portal/components/sources/S3ConnectionModal";
/**
* The Connections tab of the Sources page: stored S3 connections that sources
* and pipeline outputs reference by id. Create/edit go through the shared
* {@link S3ConnectionModal}; deleting one the backend still references returns a
* 409, surfaced inline.
*/
export function ConnectionsTab() {
const { t } = useTranslation();
const [connections, setConnections] = useState<IntegrationConfig[] | null>(
null,
);
const [modalOpen, setModalOpen] = useState(false);
const [editing, setEditing] = useState<IntegrationConfig | null>(null);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const refresh = useCallback(async () => {
try {
setConnections(await fetchS3Connections());
} catch (e) {
setError(errorMessage(e));
}
}, []);
useEffect(() => {
void refresh();
}, [refresh]);
function openCreate() {
setEditing(null);
setModalOpen(true);
}
function openEdit(connection: IntegrationConfig) {
setEditing(connection);
setModalOpen(true);
}
async function remove(connection: IntegrationConfig) {
if (busy) return;
setBusy(true);
setError(null);
try {
await deleteIntegration(connection.id);
await refresh();
} catch (e) {
setError(errorMessage(e));
} finally {
setBusy(false);
}
}
const columns = useMemo<TableColumn<IntegrationConfig>[]>(
() => [
{
key: "name",
header: t("portal.connections.table.name"),
render: (c) => <strong>{c.name}</strong>,
},
{
key: "bucket",
header: t("portal.connections.table.bucket"),
render: (c) => (
<span className="portal-sources__connections-bucket">
{String(c.config?.bucket ?? "")}
</span>
),
},
{
key: "region",
header: t("portal.connections.table.region"),
render: (c) => String(c.config?.region ?? ""),
},
{
key: "actions",
header: "",
align: "right",
render: (c) =>
c.canManage ? (
<span className="portal-sources__connections-actions">
<Button
variant="tertiary"
size="sm"
disabled={busy}
onClick={() => openEdit(c)}
>
{t("portal.connections.edit")}
</Button>
<Button
variant="tertiary"
size="sm"
accent="danger"
disabled={busy}
onClick={() => void remove(c)}
>
{t("portal.connections.delete")}
</Button>
</span>
) : null,
},
],
// remove/openEdit are stable enough for this admin surface; busy gates them.
[t, busy],
);
const isLoading = connections === null;
const isEmpty = connections !== null && connections.length === 0;
return (
<section className="portal-sources__connections">
<div className="portal-sources__connections-head">
<p className="portal-sources__connections-sub">
{t("portal.connections.subtitle")}
</p>
<Button
onClick={openCreate}
leftSection={<AddRoundedIcon style={{ fontSize: "1.125rem" }} />}
>
{t("portal.connections.actions.new")}
</Button>
</div>
{error && <Banner tone="danger" description={error} />}
{isLoading && (
<div className="portal-sources__table-skeleton" aria-hidden>
{Array.from({ length: 3 }).map((_, i) => (
<Skeleton key={i} height="3rem" />
))}
</div>
)}
{isEmpty && (
<EmptyState
icon={<SourcesIcon size={28} />}
title={t("portal.connections.empty.title")}
description={t("portal.connections.empty.description")}
actions={
<Button
onClick={openCreate}
leftSection={<AddRoundedIcon style={{ fontSize: "1.125rem" }} />}
>
{t("portal.connections.actions.new")}
</Button>
}
/>
)}
{connections !== null && connections.length > 0 && (
<Table<IntegrationConfig>
className="portal-sources__connections-table"
columns={columns}
rows={connections}
rowKey={(c) => String(c.id)}
/>
)}
<S3ConnectionModal
open={modalOpen}
connection={editing}
onClose={() => setModalOpen(false)}
onSaved={() => void refresh()}
/>
</section>
);
}

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