Compare commits

..
Author SHA1 Message Date
EthanHealy01 3869072cf7 Merge branch 'main' of https://github.com/Stirling-Tools/Stirling-PDF into feature/documentClassifierAgent 2026-07-02 15:33:53 +01:00
EthanHealy01 3817779b9e translation 2026-07-02 14:42:07 +01:00
EthanHealy01 c4e66f2c2d Merge branch 'feature/documentClassifierAgent' of https://github.com/Stirling-Tools/Stirling-PDF into feature/documentClassifierAgent 2026-07-02 14:28:07 +01:00
EthanHealy01 c27fd4db69 added the classification taxonomy table in the db, team scoped. Allow admins to edit the taxonomy and set up the backend to query it and send it to python each time it's requested. No longer requires a backend restart to make it get the latest taxonomy. Also fixed up how the ordeering of policies is selected and how they chain together in that order. Also made a tiny fix to the my files section to keep it up to date with the indexdb 2026-07-02 14:28:02 +01:00
EthanHealy01 0ae7052dcb Merge branch 'main' of https://github.com/Stirling-Tools/Stirling-PDF into feature/documentClassifierAgent 2026-07-01 10:55:36 +01:00
EthanHealy01 69fc4d5bc1 Merge branch 'main' into feature/documentClassifierAgent 2026-06-30 19:42:24 +01:00
EthanHealy01 2e023a6e78 Merge branch 'main' into feature/documentClassifierAgent 2026-06-30 10:45:27 +01:00
EthanHealy01 7e523d48d7 rename script and remove retry test 2026-06-29 22:27:26 +01:00
EthanHealy01 b8cb020e59 Merge branch 'main' into feature/documentClassifierAgent 2026-06-29 22:10:52 +01:00
EthanHealy01 a92722ff13 remove classify as a user facing tool 2026-06-29 22:10:09 +01:00
EthanHealy01 fed7ad300f document classifier agent that runs as a policy and assigns a category and sub-category docType 2026-06-29 21:58:03 +01:00
1664 changed files with 29049 additions and 60436 deletions
@@ -1,97 +0,0 @@
---
name: feature-walkthrough
description: >-
Explain the full logic and process of the current branch end-to-end so someone
with no prior knowledge of the task can understand, review, and reproduce it.
Scopes the change from the branch diff, traces the flow across every layer it
touches (frontend tool/hook/component, Java controller/service/endpoint, Python
engine, config, i18n, tests), and produces a self-contained walkthrough document
with Mermaid diagrams (sequence/flow/architecture), annotated file map with
clickable references, before/after behavior, screenshots where a UI is involved,
a "try it locally" section, and edge cases/risks. Use when asked for a feature or
branch walkthrough, "explain what this branch does", a design/logic writeup, PR
reviewer onboarding, or a hand-off doc. Pass --html to also emit a rendered HTML
version; --no-screens to skip screenshots.
argument-hint: "[branch-or-area] [--html] [--no-screens]"
allowed-tools: Read, Write, Edit, Glob, Grep, Bash
---
# Feature / Branch Walkthrough
Turn the current branch into a walkthrough a newcomer can follow. Audience:
**someone who has never seen this task**. Explain the *why*, the *flow*, and *how to
try it* - not just a diff summary.
`$ARGUMENTS` may name a branch or area to focus on; default is the current branch
vs `main`. Flags: `--html` (also emit a rendered HTML twin), `--no-screens`.
## Process
### 1. Scope the change
- `git log --oneline main..HEAD` and `git diff --stat main...HEAD` for the shape.
- Read the PR description / commit messages for stated intent. Do **not** invent
history or motivation that isn't evidenced (state current behavior in present tense).
- Classify touched files by layer:
- **Frontend**: tools (`frontend/editor/src/core/components/tools/*` or `.../core/tools/*`),
hooks (`core/hooks/tools/*`, `useToolOperation`), contexts, routes, i18n
(`public/locales/en-US`).
- **Java backend**: controllers (`.../controller/api/...`), services, models, config.
- **Engine**: `engine/src/stirling/{agents,contracts,api,services}`.
- **Config / build / docker / tests.**
### 2. Trace the flow end-to-end
Follow one real path from user action to result. For a typical PDF tool that's:
UI control → `useToolOperation` hook → `POST /api/v1/...` → Spring controller →
service (PDFBox / LibreOffice / engine call) → response → review panel → download.
Read the actual files so the narrative is true to the code, and collect the exact
file:line anchors you'll cite.
### 3. Draw the diagrams (Mermaid)
Pick what fits; usually 2-3 of:
- **Sequence diagram** - request/response across frontend → backend → engine.
- **Flowchart** - the core decision/branching logic of the feature.
- **Architecture/component** - new pieces and how they wire to existing ones.
- **State** - if the feature has modes/steps.
Keep nodes labeled in plain language. Validate the Mermaid parses before shipping.
### 4. Screenshots (unless --no-screens)
If a UI is involved, capture key states with the stubbed Playwright harness
(see the **ui-walkthrough** skill and `files-page-screenshots.spec.ts` for the
pattern) or, for before/after, capture `main` then the branch. Drop PNGs in
`walkthrough/<feature>/` and reference them from the doc. For backend-only
changes, show request/response examples (curl + JSON) instead.
### 5. Write the walkthrough
Create `walkthrough/<feature>/FEATURE-WALKTHROUGH.md` with:
1. **TL;DR** - what the branch does and who it's for, in 3-4 sentences.
2. **Problem & approach** - what wasn't possible before; the chosen solution.
3. **Architecture diagram** + 1-paragraph orientation.
4. **End-to-end flow** - the sequence diagram + a numbered walk of each step,
each citing the real file (clickable `path:line`).
5. **Key files** - annotated map (path → one line on its role).
6. **Logic deep-dive** - the flowchart + prose for the non-obvious decisions.
7. **Behavior** - before vs after; screenshots or request/response examples.
8. **Try it locally** - exact steps (`task dev` / `task dev:all`, the route to
open or the curl to run, any env like `DOCKER_ENABLE_SECURITY` or a test
license key). Make it copy-pasteable.
9. **Edge cases, risks, follow-ups** - what's untested, known limits, gotchas.
Markdown is the primary deliverable - it renders with diagrams in GitHub PRs and
IDEs, no build step, ideal for review.
### 6. If `--html`
Also emit `walkthrough/<feature>/walkthrough.html`: the same content with Mermaid
rendered via `mermaid.initialize({startOnLoad:true})` (script from CDN; note in
the file that rendering diagrams needs network, the `.md` is the offline copy) and
screenshots inline. Keep it self-contained otherwise.
### 7. Deliver
Give the doc path and a short chat summary. Offer to `SendUserFile` it.
## Principles
- **True to the code.** Every claim traces to a file you read; cite `path:line`.
No fabricated migration/version history.
- **Newcomer-first.** Define repo-specific terms (FileContext, `useToolOperation`,
the `@app/*` layer cascade, stubbed vs live tests) on first use.
- **Show, don't assert.** Prefer a diagram + a real example over adjectives.
- Don't commit the `walkthrough/` output unless asked.
-122
View File
@@ -1,122 +0,0 @@
---
name: ui-before-after
description: >-
Analyse a branch or PR and automatically capture before/after screenshots of
every UI surface its changes touch, then pixel-diff the pairs to surface what
actually changed and assemble PR-ready before/after montage images. Generic and
diff-driven: it derives the capture targets from the diff (changed tools/routes →
URLs) instead of hand-listing screens, captures "before" from the base branch and
"after" from the head, then keeps only the views that visually differ. Each
comparison is auto-cropped to the region that actually changed (the bounding box of
differing pixels), falling back to the full page only when the change spans most of
it. Use for before/after shots, a visual diff of a branch/PR, "screenshots for the
PR description", "show what changed in the UI", or a side-by-side of UI changes.
Takes a PR number/URL (resolved via gh) or a branch; defaults to the current branch
vs its base. Flags: --scope <selector>, --base <ref|merge-base>, --theme
light|dark|both, --all (capture every route, not just changed), --no-autocrop,
--pagewide <n>, --threshold <n>.
argument-hint: "[PR# | PR-url | branch] [--scope <sel>] [--base <ref>] [--theme both] [--all] [--no-autocrop]"
allowed-tools: Read, Write, Edit, Glob, Grep, Bash
---
# UI Before / After (generic visual diff)
Point it at a branch or PR; it figures out which UI changed, screenshots every
affected surface **before** (base) and **after** (head), pixel-diffs the pairs, and
montages the ones that actually changed into images for the PR description.
`$ARGUMENTS`: a PR number/URL, a branch, or nothing (current branch vs base).
By default it captures the full viewport and auto-crops each comparison to the region
that changed. Flags: `--scope <css>` (narrow the *capture* to a container, e.g.
`[data-sidebar="tool-panel"]`, when you already know where the change is),
`--no-autocrop` (keep full frames), `--pagewide <fraction>` (above this share of the
page, skip cropping; default 0.6), `--base <ref|merge-base>`,
`--theme light|dark|both`, `--all` (walk every route, not just changed),
`--threshold <fraction>` (diff sensitivity, default 0.001).
Shares the capture harness with **ui-walkthrough** - read its SKILL.md for the
stubbed-Playwright setup, worktree node_modules + `generate-icons`, the
stale-`:5173` gotcha, and the dark-mode init-script. Bundled helpers:
[capture-spec.template.ts](capture-spec.template.ts), [diff-shots.mjs](diff-shots.mjs),
[montage-template.html](montage-template.html), [shoot-sections.mjs](shoot-sections.mjs).
## Process
### 1. Resolve target + base
```
gh pr view <pr> --json number,title,headRefName,baseRefName,url,files # PR
# or branch: base = merge-base(main, HEAD); head = HEAD
gh pr diff <pr> --name-only # or: git diff --name-only <base>...HEAD
```
### 2. Derive capture targets from the diff (the "analyse" step - no hand-listing)
Map changed frontend files to URLs generically:
- **Tools**: a changed `components/tools/<toolDir>/…` or `hooks/tools/<tool>/…`
toolId → URL via the repo's own rule `getToolUrlPath` in
[toolsTaxonomy.ts:200](frontend/editor/src/core/data/toolsTaxonomy.ts): `/` + the
id kebab-cased (`addPageNumbers``/add-page-numbers`).
- **Pages/routes**: changed `filesPage/*``/files`, etc.
- `--all`: enumerate every tool in the registry instead of just changed ones.
Write `frontend/editor/screenshots/ui-diff/targets.json` =
`[{ "id":"compress", "url":"/compress", "name":"Compress" }]`. This is what makes
it generic - the spec never names a tool.
### 3. Capture AFTER (head) then BEFORE (base)
Copy [capture-spec.template.ts](capture-spec.template.ts) →
`src/core/tests/stubbed/ui-before-after.spec.ts` (it loops `targets.json`, seeds a
sample PDF so file-dependent panels render, navigates to each URL, and screenshots
the full viewport - or the `--scope` container if given). Ensure the harness is ready
(node_modules + icons).
```
# after = current head
cd frontend/editor && PR_SHOT_SIDE=after PR_SHOT_THEME=light \
npx playwright test --project=stubbed ui-before-after.spec.ts
# before = base, in an isolated worktree (copy the spec + targets.json in)
git worktree add ../ba-base origin/<baseRefName> # or the merge-base
# set up its frontend, copy spec + screenshots/ui-diff/targets.json across, then:
cd ../ba-base/frontend/editor && PR_SHOT_SIDE=before PR_SHOT_THEME=light \
npx playwright test --project=stubbed ui-before-after.spec.ts
# copy its screenshots/ui-diff/before/ back next to after/. Repeat with
# PR_SHOT_THEME=dark if --theme includes dark. Remove worktree when done.
```
### 4. Auto-diff (surface what changed)
```
cd frontend/editor && node <skill>/diff-shots.mjs \
screenshots/ui-diff/before screenshots/ui-diff/after screenshots/ui-diff
```
Produces `diff-report.json` classifying each view `unchanged | changed | added |
removed`. For each changed view it computes the bounding box of differing pixels and
writes cropped `__before_crop.png` / `__after_crop.png` / `__diff.png` to that region
(+ padding) - **unless** the change covers more than `--pagewide` of the frame, where
it keeps the full frame (`pageWide:true`). Drop `unchanged` - that's the noise the
user doesn't want.
### 5. Montage the changes
Build the manifest from the non-unchanged entries (group by tab/tool; each becomes a
state row with before/after). For changed views use the cropped `cropBefore` /
`cropAfter` from `diff-report.json` (tight on the affected region; full frame when
`pageWide`); `added`/`removed` render the "not present" placeholder. Fill
[montage-template.html](montage-template.html) (replace the `window.__BA__` data
block; base64-inline the PNGs for portability), then render one PNG per section with
[shoot-sections.mjs](shoot-sections.mjs). Optionally include the `__diff.png` overlay
as a third column.
### 6. Deliver
Output the `montage_<tab>.png` files + a short summary (N changed / added / removed,
M unchanged skipped) and a paste-ready Markdown block. GitHub has no PR-body image
API, so tell the user to drag the PNGs into the description. Do **not** post to the
PR.
## Gotchas
- Two installs (base worktree + head); junction main's node_modules only if its deps
match that ref, else `npm ci` (see ui-walkthrough's stale-dep note).
- A view that errors on one side (refactored/removed) → that side is missing; the
diff marks it added/removed rather than failing the run.
- Pixel diff needs equal dimensions, so capture at a fixed viewport (the template
does); a view whose size changed is reported as "changed (dimensions differ)",
uncropped.
- Auto-crop uses a single bounding box, so two far-apart changes give one large crop
(or trip `--pagewide`); narrow with `--scope` if that happens.
- `getToolUrlPath` is the source of truth for tool URLs - use it, don't guess slugs.
- Don't commit `screenshots/`, the throwaway spec, or the base worktree.
@@ -1,67 +0,0 @@
// Generic before/after capturer. NOT app-specific: it walks a targets.json that
// the ui-before-after skill generates from the branch/PR diff, so nothing here is
// hand-listed. Copy to src/core/tests/stubbed/ui-before-after.spec.ts, then run
// once per (side, theme):
// PR_SHOT_SIDE=after PR_SHOT_THEME=light \
// npx playwright test --project=stubbed ui-before-after.spec.ts
//
// targets.json shape: [{ "id":"compress", "url":"/compress", "name":"Compress",
// "needsFile": true }]
import { test } from "@app/tests/helpers/stub-test-base";
import type { Page } from "@playwright/test";
import fs from "node:fs";
import path from "node:path";
const SIDE = process.env.PR_SHOT_SIDE ?? "after";
const THEME = process.env.PR_SHOT_THEME ?? "light";
// Capture the full viewport by default so the affected region is in frame
// wherever it is; diff-shots.mjs crops each comparison to what actually changed.
// Set PR_SHOT_SCOPE to a selector to narrow the capture to one container.
const SCOPE = process.env.PR_SHOT_SCOPE ?? "";
const ROOT = path.resolve(process.cwd(), "screenshots", "ui-diff");
const OUT = path.join(ROOT, SIDE);
// A tiny sample PDF so file-dependent tool panels render. Point at a real fixture.
const SAMPLE_PDF = process.env.PR_SHOT_SAMPLE ?? "src/core/tests/test-fixtures/sample.pdf";
type Target = { id: string; url: string; name?: string; needsFile?: boolean };
const targets: Target[] = JSON.parse(fs.readFileSync(path.join(ROOT, "targets.json"), "utf-8"));
test.use({ autoGoto: false, viewport: { width: 1600, height: 900 }, seedJwt: true });
async function applyTheme(page: Page): Promise<void> {
if (THEME !== "dark") return;
await page.addInitScript(() => {
localStorage.setItem("mantine-color-scheme", "dark");
localStorage.setItem("mantine-color-scheme-value", "dark");
});
await page.emulateMedia({ colorScheme: "dark" });
}
async function seedFile(page: Page): Promise<void> {
if (!fs.existsSync(SAMPLE_PDF)) return;
await page.goto("/", { waitUntil: "domcontentloaded" });
await page.getByTestId("files-button").click().catch(() => {});
await page.locator('[data-testid="file-input"]').setInputFiles(SAMPLE_PDF).catch(() => {});
await page.locator(".file-sidebar-file-item").first().isVisible({ timeout: 8_000 }).catch(() => {});
}
for (const t of targets) {
// One test per target so a single failure doesn't drop the rest.
test(`${SIDE}/${THEME} ${t.id}`, async ({ page }) => {
fs.mkdirSync(OUT, { recursive: true });
await applyTheme(page);
if (t.needsFile !== false) await seedFile(page);
await page.goto(t.url, { waitUntil: "domcontentloaded" });
await page.waitForTimeout(400); // settle Mantine portals/transitions
const shot = path.join(OUT, `${t.id}__${THEME}.png`);
if (SCOPE) {
const scope = page.locator(SCOPE).first();
if (await scope.isVisible({ timeout: 8_000 }).catch(() => false)) {
await scope.screenshot({ path: shot });
return;
}
}
// Full viewport (fixed size → stable dimensions for pixel diffing).
await page.screenshot({ path: shot });
});
}
@@ -1,106 +0,0 @@
// Auto-diff before/ vs after/ screenshots, classify each as
// unchanged | changed | added | removed, and CROP each changed pair to the
// affected region (bounding box of differing pixels + padding) - unless the
// change spans most of the page, in which case the full frame is kept.
// Run from frontend/editor (so deps resolve):
// node <skill>/diff-shots.mjs <beforeDir> <afterDir> [outDir]
// Env:
// DIFF_THRESHOLD min fraction of differing pixels to count as changed (default 0.001)
// DIFF_PAD padding px around the affected region (default 24)
// DIFF_PAGEWIDE if affected bbox area / image area exceeds this, keep full frame (default 0.6)
import fs from "node:fs";
import path from "node:path";
import { createRequire } from "node:module";
const require = createRequire(path.join(process.cwd(), "noop.js"));
const pm = require("pixelmatch");
const pixelmatch = pm.default || pm;
const { PNG } = require("pngjs");
const beforeDir = path.resolve(process.argv[2]);
const afterDir = path.resolve(process.argv[3]);
const outDir = path.resolve(process.argv[4] || afterDir);
const THRESHOLD = Number(process.env.DIFF_THRESHOLD ?? "0.001");
const PAD = Number(process.env.DIFF_PAD ?? "24");
const PAGEWIDE = Number(process.env.DIFF_PAGEWIDE ?? "0.6");
const read = (p) => PNG.sync.read(fs.readFileSync(p));
const isShot = (f) => f.endsWith(".png") && !/__(diff|before_crop|after_crop)\.png$/.test(f);
const list = (d) => (fs.existsSync(d) ? fs.readdirSync(d).filter(isShot) : []);
const names = [...new Set([...list(beforeDir), ...list(afterDir)])].sort();
fs.mkdirSync(outDir, { recursive: true });
function cropPNG(src, x, y, w, h) {
const out = new PNG({ width: w, height: h });
PNG.bitblt(src, out, x, y, w, h, 0, 0);
return out;
}
const writePNG = (p, png) => fs.writeFileSync(p, PNG.sync.write(png));
// Bounding box of differing pixels using a diff mask (alpha>0 where changed).
function changedBBox(before, after, w, h) {
const mask = new PNG({ width: w, height: h });
pixelmatch(before.data, after.data, mask.data, w, h, { threshold: 0.1, diffMask: true });
let minX = w, minY = h, maxX = -1, maxY = -1, count = 0;
for (let y = 0; y < h; y++) {
for (let x = 0; x < w; x++) {
if (mask.data[(y * w + x) * 4 + 3] > 0) {
count++;
if (x < minX) minX = x; if (x > maxX) maxX = x;
if (y < minY) minY = y; if (y > maxY) maxY = y;
}
}
}
return maxX < 0 ? null : { minX, minY, maxX, maxY, count };
}
const report = [];
for (const name of names) {
const id = name.replace(/\.png$/, "");
const bp = path.join(beforeDir, name), ap = path.join(afterDir, name);
const hasB = fs.existsSync(bp), hasA = fs.existsSync(ap);
if (hasB && !hasA) { report.push({ id, status: "removed", before: bp }); continue; }
if (!hasB && hasA) { report.push({ id, status: "added", after: ap }); continue; }
const before = read(bp), after = read(ap);
if (before.width !== after.width || before.height !== after.height) {
report.push({ id, status: "changed", note: "dimensions differ", before: bp, after: ap });
continue;
}
const w = after.width, h = after.height;
const overlay = new PNG({ width: w, height: h });
const px = pixelmatch(before.data, after.data, overlay.data, w, h, { threshold: 0.1 });
const ratio = px / (w * h);
if (ratio <= THRESHOLD) { report.push({ id, status: "unchanged", ratio: Number(ratio.toFixed(5)), before: bp, after: ap }); continue; }
const box = changedBBox(before, after, w, h);
// Pad + clamp the affected region.
const x = Math.max(0, box.minX - PAD), y = Math.max(0, box.minY - PAD);
const x2 = Math.min(w, box.maxX + 1 + PAD), y2 = Math.min(h, box.maxY + 1 + PAD);
const bw = x2 - x, bh = y2 - y;
const pageWide = (bw * bh) / (w * h) > PAGEWIDE;
const entry = { id, status: "changed", ratio: Number(ratio.toFixed(5)), before: bp, after: ap, pageWide };
if (pageWide) {
// Change spans most of the page - keep the full frame, full overlay.
const dp = path.join(outDir, `${id}__diff.png`); writePNG(dp, overlay);
entry.diff = dp;
} else {
entry.bbox = { x, y, w: bw, h: bh };
const cb = path.join(outDir, `${id}__before_crop.png`); writePNG(cb, cropPNG(before, x, y, bw, bh));
const ca = path.join(outDir, `${id}__after_crop.png`); writePNG(ca, cropPNG(after, x, y, bw, bh));
const dp = path.join(outDir, `${id}__diff.png`); writePNG(dp, cropPNG(overlay, x, y, bw, bh));
entry.cropBefore = cb; entry.cropAfter = ca; entry.diff = dp;
}
report.push(entry);
}
fs.writeFileSync(path.join(outDir, "diff-report.json"), JSON.stringify(report, null, 2));
const changed = report.filter((r) => r.status !== "unchanged");
console.log(`diffed ${report.length} view(s): ${changed.length} changed/added/removed, ${report.length - changed.length} unchanged`);
for (const r of changed) {
const tail = r.status !== "changed" ? ""
: r.pageWide ? " (page-wide → full frame)"
: ` (${(r.ratio * 100).toFixed(2)}%, cropped to ${r.bbox.w}×${r.bbox.h})`;
console.log(` ${r.status.padEnd(9)} ${r.id}${tail}${r.note ? " - " + r.note : ""}`);
}
@@ -1,48 +0,0 @@
"""Build EXAMPLE.html from montage-template.html using REAL files-page shots as
stand-in before/after pairs (layout demo, not an actual PR diff). Inlines PNGs as
data URIs so the HTML is portable. Run: python make_example.py"""
import base64
import json
import pathlib
import re
HERE = pathlib.Path(__file__).parent
SHOTS = pathlib.Path(
r"C:\Users\systo\git\Stirling-PDFNew\.claude\worktrees\kind-faraday-522a30"
r"\frontend\editor\screenshots\files-page"
)
def uri(fname):
p = SHOTS / fname
return "data:image/png;base64," + base64.b64encode(p.read_bytes()).decode() if p.exists() else None
data = {
"pr": "DEMO",
"title": "EXAMPLE — before/after montage (layout demo, real Files-page shots; not a real PR diff)",
"base": "main", "head": "demo-branch",
"cropSelector": "[data-sidebar=\"tool-panel\"] (real runs crop to the side; these demo shots are full-page)",
"tabs": [
{"id": "files", "title": "Files page", "ctx": "Each row = one flow state; left = base branch, right = this PR.",
"states": [
{"name": "Empty folder", "before": uri("01_empty_state_ctas.png"), "after": uri("02_empty_state_storage_off.png")},
{"name": "Files + details panel", "before": uri("03_subtoolbar_with_files.png"), "after": uri("06_details_panel_save_to_server.png")},
{"name": "Delete folder confirm", "before": None, "after": uri("19_delete_folder_dialog.png"), "note": "New in this PR"},
]},
{"id": "move", "title": "Move-to-folder dialog",
"states": [
{"name": "Dialog opened", "before": uri("07_move_dialog_collapsed.png"), "after": uri("08_move_dialog_create_folder_expanded.png")},
{"name": "After folder created", "before": None, "after": uri("08b_move_dialog_after_create_folder.png"), "note": "New flow"},
]},
],
}
tpl = (HERE / "montage-template.html").read_text(encoding="utf-8")
out = re.sub(
r"/\*__DATA__\*/.*?/\*__END__\*/",
lambda _m: "/*__DATA__*/" + json.dumps(data) + "/*__END__*/",
tpl, count=1, flags=re.S,
)
(HERE / "EXAMPLE.html").write_text(out, encoding="utf-8")
print("wrote", HERE / "EXAMPLE.html", "(", (HERE / "EXAMPLE.html").stat().st_size // 1024, "KB )")
@@ -1,106 +0,0 @@
<!doctype html>
<!--
Before/After montage for a PR description. The ui-before-after skill replaces
the JSON in the window.__BA__ data block below with the captured manifest, then
screenshots each .tab-section (id="section-<tabId>") into a PNG to drag into the
PR description. Self-contained; images may be relative paths or data URIs.
Data shape:
{
"pr":"6552","title":"...","base":"main","head":"feat/x",
"cropSelector":"[data-sidebar=\"tool-panel\"]",
"tabs":[
{ "id":"sign","title":"Sign tool","states":[
{"name":"Initial","before":"before/sign__initial.png","after":"after/sign__initial.png"},
{"name":"Cert selected","before":null,"after":"after/sign__cert.png","note":"New in this PR"}
]}
]
}
-->
<html lang="en">
<head>
<meta charset="utf-8" />
<title>Before / After</title>
<style>
:root { --bg:#ffffff; --ink:#0b0c0e; --muted:#6b7280; --line:#e5e7eb;
--before:#6b7280; --after:#1f883d; --frame:#f3f4f6; --note:#b45309; }
* { box-sizing: border-box; }
body { margin:0; background:var(--bg); color:var(--ink);
font:14px/1.5 -apple-system,"Segoe UI",Roboto,system-ui,sans-serif; }
.wrap { max-width:1100px; margin:0 auto; padding:24px; }
.doc-head { margin-bottom:8px; }
.doc-head h1 { font-size:18px; margin:0 0 2px; }
.doc-head .sub { color:var(--muted); font-size:12.5px; }
.legend { display:flex; gap:14px; align-items:center; margin:10px 0 4px; font-size:12px; color:var(--muted); }
.chip { font-size:10px; font-weight:700; letter-spacing:.04em; text-transform:uppercase;
padding:2px 8px; border-radius:999px; color:#fff; }
.chip.before { background:var(--before); } .chip.after { background:var(--after); }
.tab-section { border:1px solid var(--line); border-radius:14px; padding:18px 18px 8px;
margin:18px 0; background:var(--bg); }
.tab-section > h2 { font-size:16px; margin:0 0 2px; }
.tab-section > .ctx { color:var(--muted); font-size:12px; margin-bottom:14px; }
.state { margin-bottom:18px; }
.state .name { font-weight:600; font-size:13.5px; margin-bottom:8px; display:flex; gap:8px; align-items:center; }
.state .name .note { font-weight:500; color:var(--note); font-size:12px; }
.pair { display:grid; grid-template-columns:1fr 1fr; gap:14px; align-items:start; }
.cell { border:1px solid var(--line); border-radius:10px; overflow:hidden; background:var(--frame); }
.cell .cap { display:flex; align-items:center; gap:8px; padding:7px 10px; border-bottom:1px solid var(--line);
background:var(--bg); }
.cell .cap .meta { color:var(--muted); font-size:11px; }
.cell img { display:block; width:100%; height:auto; background:#fff; }
.cell.empty .ph { display:flex; align-items:center; justify-content:center; height:160px; color:var(--muted);
font-size:12.5px; text-align:center; padding:0 16px; }
.single .pair { grid-template-columns:1fr; }
.empty-doc { color:var(--muted); padding:40px; text-align:center; }
@media (max-width:760px){ .pair{ grid-template-columns:1fr; } }
</style>
</head>
<body>
<div class="wrap" id="root"></div>
<script id="data">
window.__BA__ = /*__DATA__*/{"pr":"","title":"No data","base":"","head":"","cropSelector":"","tabs":[]}/*__END__*/;
</script>
<script>
(function(){
var D = window.__BA__ || { tabs: [] };
var root = document.getElementById("root");
function el(html){ var t=document.createElement("template"); t.innerHTML=html.trim(); return t.content.firstChild; }
function esc(s){ return (s==null?"":String(s)).replace(/[&<>]/g, function(c){return {"&":"&amp;","<":"&lt;",">":"&gt;"}[c];}); }
function cell(kind, src){
if (src) {
return '<div class="cell"><div class="cap"><span class="chip '+kind+'">'+kind+'</span></div>'+
'<img src="'+esc(src)+'" alt="'+kind+'"/></div>';
}
return '<div class="cell empty"><div class="cap"><span class="chip '+kind+'">'+kind+'</span>'+
'<span class="meta">not present</span></div><div class="ph">No '+kind+' screenshot for this state</div></div>';
}
var head = '<div class="doc-head"><h1>'+esc(D.title || ("PR #"+D.pr))+'</h1>'+
'<div class="sub">Before / after &nbsp;·&nbsp; base <code>'+esc(D.base)+'</code> → head <code>'+esc(D.head)+'</code>'+
(D.cropSelector ? ' &nbsp;·&nbsp; cropped to <code>'+esc(D.cropSelector)+'</code>' : '')+'</div></div>'+
'<div class="legend"><span class="chip before">Before</span> base branch'+
'<span class="chip after">After</span> this PR</div>';
root.appendChild(el('<div>'+head+'</div>'));
if (!D.tabs || !D.tabs.length){ root.appendChild(el('<div class="empty-doc">No tabs captured yet.</div>')); return; }
D.tabs.forEach(function(tab){
var states = (tab.states||[]).map(function(s){
var onlyOne = (!s.before || !s.after);
return '<div class="state'+(onlyOne?' ':'')+'">'+
'<div class="name">'+esc(s.name)+(s.note?'<span class="note">'+esc(s.note)+'</span>':'')+'</div>'+
'<div class="pair">'+cell("before", s.before)+cell("after", s.after)+'</div></div>';
}).join("");
var sec = '<section class="tab-section" id="section-'+esc(tab.id)+'">'+
'<h2>'+esc(tab.title)+'</h2>'+
(tab.ctx?'<div class="ctx">'+esc(tab.ctx)+'</div>':'')+
states+'</section>';
root.appendChild(el(sec));
});
})();
</script>
</body>
</html>
@@ -1,25 +0,0 @@
// Render each .tab-section of a montage HTML into its own PNG (the PR-ready image).
// Run from frontend/editor (so @playwright/test resolves):
// node <skill>/shoot-sections.mjs <montage.html> <outDir>
import path from "node:path";
import { pathToFileURL } from "node:url";
import { createRequire } from "node:module";
const require = createRequire(path.join(process.cwd(), "noop.js"));
const { chromium } = require("@playwright/test");
const htmlPath = path.resolve(process.argv[2]);
const outDir = path.resolve(process.argv[3] || path.dirname(htmlPath));
const browser = await chromium.launch();
const page = await browser.newPage({ viewport: { width: 1200, height: 1200 }, deviceScaleFactor: 2 });
await page.goto(pathToFileURL(htmlPath).href, { waitUntil: "load" });
await page.waitForTimeout(250); // let images/fonts paint
const ids = await page.$$eval(".tab-section", (els) => els.map((e) => e.id));
if (!ids.length) { console.error("no .tab-section found"); process.exit(1); }
for (const id of ids) {
const name = id.replace(/^section-/, "");
await page.locator("#" + id).screenshot({ path: path.join(outDir, `montage_${name}.png`) });
console.log("wrote montage_" + name + ".png");
}
await browser.close();
-120
View File
@@ -1,120 +0,0 @@
---
name: ui-walkthrough
description: >-
Full UI investigation of the current branch's feature. Enumerates every view
and state (empty, populated, loading, error, each dialog/menu/panel, responsive
breakpoints, light + dark + RTL), captures them with the stubbed Playwright
harness, assembles a single-image HTML walkthrough with a global light/dark
toggle slider, then runs two review passes: visual/consistency (alignment,
spacing, professionalism, dark/light parity, contrast, truncation) and
UX/ease-of-use (flow, discoverability, affordances, empty/error states,
expectations). Use when asked for a UI walkthrough, screenshot review, design
or QA pass, "find anywhere to make it easier/better for users", or before
merging frontend work. Pass --fix to auto-apply safe frontend fixes and
re-capture; --theme to limit themes; --no-rtl to skip RTL.
argument-hint: "[feature/area] [--fix] [--theme light|dark|both] [--no-rtl] [--breakpoints]"
allowed-tools: Read, Write, Edit, Glob, Grep, Bash
---
# UI Walkthrough
Produce a reviewable HTML walkthrough of a feature's UI in every state and theme,
then critique it. Optionally auto-fix and re-capture.
`$ARGUMENTS` may name the feature/area to focus on. If empty, scope from the
current branch diff. Flags: `--fix`, `--theme light|dark|both` (default both),
`--no-rtl`, `--breakpoints` (also capture phone/narrow widths).
## What this repo gives you (use it, don't reinvent)
- **Stubbed Playwright project** = backend-free screenshots via `page.route()` mocks.
Reference implementation: `frontend/editor/src/core/tests/stubbed/files-page-screenshots.spec.ts`.
It already shows the light / **dark** / **RTL** passes, JWT seeding, IndexedDB
seeding, and dumping PNGs to a `screenshots/<area>/` folder. Copy its shape.
- Helpers: `frontend/editor/src/core/tests/helpers/ui-helpers.ts`
(`uploadFiles`, `openSettings`, `waitForModalOpen`, `dismissTourTooltip`, …)
and the `stub-test-base` fixtures (`autoGoto`, `seedJwt`, `viewport`).
- Config: `frontend/editor/playwright.config.ts` (run from `frontend/editor/`).
- Report template: [report-template.html](report-template.html) - self-contained,
one big image at a time, a global light/dark slider that flips every shot,
thumbnail rail, prev/next + arrow keys, and a Findings tab.
## Process
### 1. Scope the feature
- If `$ARGUMENTS` is empty: `git diff --name-only main...HEAD` and read the PR/commits.
Identify changed pages, tools (`core/components/tools/<tool>` or `core/tools/<tool>`),
dialogs, panels, and routes.
- Enumerate **every view and state** to capture, e.g.:
empty / populated / loading / error / disabled; each dialog, menu, popover, tooltip;
each tab or step; selection + multi-select; success/result panel; and (if relevant)
permission/role variants. Write the list down before capturing - it's the report's spine.
### 2. Prepare the harness (worktree-safe)
Worktrees have no `node_modules` and no generated icons. From repo root:
```
cd frontend && npm ci # or junction main's node_modules (see memory)
cd frontend/editor && node scripts/generate-icons.js
```
Kill any stale dev server first (it serves old modules):
`Get-NetTCPConnection -LocalPort 5173 -State Listen | %{ Stop-Process -Id $_.OwningProcess -Force }`
### 3. Write the capture spec
Create `frontend/editor/src/core/tests/stubbed/<feature>-walkthrough.spec.ts`,
modeled on `files-page-screenshots.spec.ts`. For each enumerated view:
- stub the APIs it needs, drive the UI to that state, wait on a real locator
(not a fixed sleep), `await settle(page)` for Mantine portals, then
`page.screenshot({ path: shotPath("NN_name_<theme>") })`.
- Capture each view in **light and dark** (and RTL unless `--no-rtl`). Reuse the
`enableDarkMode` / `enableRtl` init-script pattern from the reference spec
(`localStorage["mantine-color-scheme"]="dark"` + `emulateMedia({colorScheme:"dark"})`).
- Name shots `NN_<view>_<theme>.png` so light/dark pair up by suffix.
- Prefer **stable test-ids** over translated accessible names (RTL/i18n breaks text locators).
Run it: `cd frontend/editor && npx playwright test --project=stubbed <feature>-walkthrough.spec.ts`.
Add `--project=stubbed-firefox`/`-webkit` only if cross-browser layout matters.
### 4. Build the report
- Copy `report-template.html` to `screenshots/<feature>/walkthrough.html` (so the
relative `screenshots/...` image paths resolve, or rewrite paths to sit beside it).
- Build the manifest and inject it: replace the JSON between the
`/*__DATA__*/``/*__END__*/` markers with one `views[]` entry per view
(`{id,title,light,dark,viewport,notes}`) and an empty `findings` object you'll
fill in step 5. Keep `light`/`dark` as relative paths.
- The toggle slider answers the "one big image + flip light/dark for all" request:
it shows a single large screenshot, and switching the slider re-themes every view.
### 5. Review pass 1 - visual & consistency
Open each screenshot (Read the PNG) and judge against the others:
alignment & spacing rhythm, control placement, button hierarchy, typography,
**light/dark parity** (contrast, invisible borders, washed-out text, wrong tokens),
truncation/overflow, RTL mirroring, focus states, icon consistency, professional polish.
Record each issue as a finding `{severity:high|med|low, view, title, detail, fix}`.
### 6. Review pass 2 - UX & ease of use
Walk the flow as a first-time user: discoverability, number of steps, affordance
clarity, empty-state guidance, error recovery, destructive-action confirmation,
defaults, loading feedback, mobile reachability, accessible names, and whether the
UI matches user expectations for this kind of tool. Record findings the same way.
Write both finding lists into the report's `findings.visual` / `findings.ux`,
and add short per-view `notes`. Re-inject the manifest.
### 7. If `--fix`
Only safe, self-contained frontend fixes (spacing, alignment, tokens, missing
dark-mode colors, labels, aria, obvious copy). For each: edit the component/CSS,
mark the finding `fixed:true` with what changed, then **re-run the spec** to
re-capture the affected shots and regenerate the report. Run `task frontend:check`.
Leave anything risky or ambiguous as a finding, not a change.
### 8. Deliver
Tell the user the report path and give a tight chat summary: N views ×
themes captured, top findings by severity, and (if `--fix`) what changed.
Optionally `SendUserFile` the `walkthrough.html`.
## Gotchas
- Stale `:5173` server serves old bundles - kill it before capturing (see step 2).
- Missing `material-symbols-icons.json` → blank app → every shot times out. Run
`generate-icons.js` first.
- `await settle(page)` before shots or portals/transitions tear mid-capture.
- Don't commit the generated `screenshots/` or the throwaway spec unless asked.
@@ -1,116 +0,0 @@
"""Build a self-contained EXAMPLE.html from report-template.html with mock
light/dark screenshots, so the viewer + global theme slider can be demoed
without a real capture run. Run: python make_example.py"""
import base64
import json
import pathlib
import re
HERE = pathlib.Path(__file__).parent
def svg(bg, fg, panel, accent, muted, label, kind):
"""A simple fake 'screen' SVG: title bar, sidebar, content varies by kind."""
parts = [
f'<svg xmlns="http://www.w3.org/2000/svg" width="1600" height="900" viewBox="0 0 1600 900">',
f'<rect width="1600" height="900" fill="{bg}"/>',
# top bar
f'<rect width="1600" height="64" fill="{panel}"/>',
f'<circle cx="40" cy="32" r="12" fill="{accent}"/>',
f'<rect x="64" y="24" width="160" height="16" rx="6" fill="{muted}"/>',
f'<rect x="1430" y="20" width="130" height="24" rx="12" fill="{accent}"/>',
# left sidebar
f'<rect x="0" y="64" width="220" height="836" fill="{panel}"/>',
]
for i in range(6):
y = 100 + i * 56
parts.append(f'<rect x="24" y="{y}" width="172" height="32" rx="8" fill="{bg}"/>')
if kind == "empty":
parts += [
f'<rect x="700" y="360" width="200" height="120" rx="16" fill="none" stroke="{muted}" stroke-width="3" stroke-dasharray="10 8"/>',
f'<rect x="690" y="510" width="220" height="44" rx="10" fill="{accent}"/>',
f'<text x="800" y="600" fill="{muted}" font-family="sans-serif" font-size="26" text-anchor="middle">{label}</text>',
]
elif kind == "form":
for i in range(4):
y = 140 + i * 90
parts.append(f'<rect x="280" y="{y}" width="160" height="16" rx="6" fill="{muted}"/>')
parts.append(f'<rect x="280" y="{y+26}" width="900" height="44" rx="8" fill="{panel}" stroke="{muted}" stroke-width="1"/>')
parts.append(f'<rect x="280" y="560" width="200" height="50" rx="10" fill="{accent}"/>')
parts.append(f'<text x="800" y="850" fill="{muted}" font-family="sans-serif" font-size="24" text-anchor="middle">{label}</text>')
else: # dialog
parts += [
f'<rect width="1600" height="900" fill="{fg}" opacity="0.45"/>',
f'<rect x="520" y="280" width="560" height="360" rx="18" fill="{panel}"/>',
f'<rect x="556" y="320" width="280" height="22" rx="8" fill="{fg}"/>',
f'<rect x="556" y="372" width="488" height="14" rx="6" fill="{muted}"/>',
f'<rect x="556" y="398" width="420" height="14" rx="6" fill="{muted}"/>',
f'<rect x="820" y="560" width="110" height="44" rx="9" fill="{bg}" stroke="{muted}"/>',
f'<rect x="946" y="560" width="98" height="44" rx="9" fill="{accent}"/>',
f'<text x="800" y="700" fill="#fff" font-family="sans-serif" font-size="24" text-anchor="middle">{label}</text>',
]
parts.append("</svg>")
return "".join(parts)
def data_uri(s):
return "data:image/svg+xml;base64," + base64.b64encode(s.encode()).decode()
LIGHT = dict(bg="#ffffff", fg="#111418", panel="#f1f3f6", accent="#2f6fed", muted="#c2c8d0")
DARK = dict(bg="#16181c", fg="#000000", panel="#1f232a", accent="#5b8cff", muted="#3a414b")
def pair(kind, label):
return (
data_uri(svg(LIGHT["bg"], LIGHT["fg"], LIGHT["panel"], LIGHT["accent"], LIGHT["muted"], label, kind)),
data_uri(svg(DARK["bg"], DARK["fg"], DARK["panel"], DARK["accent"], DARK["muted"], label, kind)),
)
views = []
for idx, (kind, title, label) in enumerate([
("empty", "Empty state", "Drop a PDF to start"),
("form", "Tool options panel", "Compress options"),
("dialog", "Confirm dialog", "Replace original file?"),
], start=1):
light, dark = pair(kind, label)
views.append({
"id": f"{idx:02d}_{kind}",
"title": title,
"light": light,
"dark": dark,
"viewport": "1600x900",
"notes": ["This is mock data to demo the viewer."],
})
data = {
"feature": "EXAMPLE - Compress PDF (mock data)",
"branch": "demo",
"generated": "example",
"views": views,
"findings": {
"visual": [
{"severity": "high", "view": "03_dialog", "title": "Dialog buttons too close",
"detail": "Cancel/Confirm have only 8px gap; easy to misclick.",
"fix": "Increase gap to var(--mantine-spacing-md)."},
{"severity": "low", "view": "02_form", "title": "Field labels low contrast in dark mode",
"detail": "Muted token fails WCAG AA on the dark panel.",
"fix": "Use --mantine-color-dimmed instead of a hard-coded grey."},
],
"ux": [
{"severity": "med", "view": "01_empty", "title": "Primary CTA below the dropzone",
"detail": "Users expect the action button adjacent to the dropzone.",
"fix": "Move the button directly under the dashed zone."},
],
},
}
tpl = (HERE / "report-template.html").read_text(encoding="utf-8")
out = re.sub(
r"/\*__DATA__\*/.*?/\*__END__\*/",
lambda _m: "/*__DATA__*/" + json.dumps(data) + "/*__END__*/",
tpl, count=1, flags=re.S,
)
(HERE / "EXAMPLE.html").write_text(out, encoding="utf-8")
print("wrote", (HERE / "EXAMPLE.html"))
@@ -1,298 +0,0 @@
<!doctype html>
<!--
UI Walkthrough report template (self-contained, works from file://).
The ui-walkthrough skill replaces the JSON in the window.__WALKTHROUGH__ data
block below with the captured manifest. Do not add external CDN deps - it must open offline.
Data shape:
{
"feature": "Compress PDF tool",
"branch": "claude/...",
"generated": "2026-06-21",
"views": [
{ "id": "01_empty", "title": "Empty state",
"light": "screenshots/compress/01_empty_light.png",
"dark": "screenshots/compress/01_empty_dark.png",
"viewport": "1600x900",
"notes": ["Heading is centered", "Primary CTA below the fold on mobile"] }
],
"findings": {
"visual": [ { "severity":"high", "view":"01_empty", "title":"...", "detail":"...", "fix":"..." } ],
"ux": [ { "severity":"med", "view":"03_dialog", "title":"...", "detail":"...", "fix":"..." } ]
}
}
-->
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>UI Walkthrough</title>
<style>
:root {
--bg: #f6f7f9; --panel: #ffffff; --panel-2: #f0f2f5; --text: #1a1b1e;
--muted: #6b7280; --border: #e2e5ea; --accent: #2f6fed; --accent-weak: #e8f0fe;
--shadow: 0 1px 3px rgba(0,0,0,.08), 0 8px 24px rgba(0,0,0,.06);
--hi: #d92d20; --med: #d98e00; --low: #2f6fed; --stage: #0b0c0e;
}
html[data-theme="dark"] {
--bg: #0d0e10; --panel: #16181c; --panel-2: #1d2024; --text: #e6e8eb;
--muted: #9aa3ad; --border: #2a2e35; --accent: #5b8cff; --accent-weak: #1a2336;
--shadow: 0 1px 3px rgba(0,0,0,.5), 0 8px 24px rgba(0,0,0,.4); --stage: #000;
}
* { box-sizing: border-box; }
body { margin: 0; font: 14px/1.5 -apple-system, "Segoe UI", Roboto, system-ui, sans-serif;
background: var(--bg); color: var(--text); }
header { display: flex; align-items: center; gap: 16px; padding: 12px 20px;
background: var(--panel); border-bottom: 1px solid var(--border); position: sticky; top: 0; z-index: 5; }
header h1 { font-size: 15px; margin: 0; font-weight: 650; }
header .sub { color: var(--muted); font-size: 12px; }
.spacer { flex: 1; }
.counter { color: var(--muted); font-variant-numeric: tabular-nums; font-size: 13px; }
.tabs { display: flex; gap: 4px; }
.tab { border: 1px solid var(--border); background: var(--panel-2); color: var(--text);
padding: 6px 12px; border-radius: 8px; cursor: pointer; font-size: 13px; }
.tab.active { background: var(--accent); color: #fff; border-color: var(--accent); }
/* Light/Dark slider */
.theme-toggle { display: flex; align-items: center; gap: 9px; user-select: none; }
.theme-toggle .lbl { font-size: 12px; color: var(--muted); }
.theme-toggle .lbl.on { color: var(--text); font-weight: 600; }
.switch { position: relative; width: 52px; height: 28px; }
.switch input { opacity: 0; width: 0; height: 0; }
.slider { position: absolute; inset: 0; cursor: pointer; background: var(--panel-2);
border: 1px solid var(--border); border-radius: 999px; transition: .2s; }
.slider:before { content: ""; position: absolute; height: 20px; width: 20px; left: 3px; top: 3px;
background: #fbbf24; border-radius: 50%; transition: .2s; box-shadow: 0 1px 2px rgba(0,0,0,.3); }
.switch input:checked + .slider { background: var(--accent); }
.switch input:checked + .slider:before { transform: translateX(24px); background: #c7d2fe; }
main { display: grid; grid-template-columns: 240px 1fr; height: calc(100vh - 53px); }
.rail { border-right: 1px solid var(--border); overflow-y: auto; background: var(--panel); padding: 8px; }
.rail .group-label { font-size: 11px; text-transform: uppercase; letter-spacing: .05em;
color: var(--muted); padding: 10px 8px 4px; }
.thumb { display: flex; gap: 9px; align-items: center; padding: 7px; border-radius: 8px;
cursor: pointer; border: 1px solid transparent; }
.thumb:hover { background: var(--panel-2); }
.thumb.active { background: var(--accent-weak); border-color: var(--accent); }
.thumb img { width: 64px; height: 40px; object-fit: cover; border-radius: 4px; border: 1px solid var(--border); background: var(--stage); }
.thumb .t { font-size: 12.5px; line-height: 1.3; }
.thumb .badge { font-size: 10px; color: var(--muted); }
.thumb .dot { width: 7px; height: 7px; border-radius: 50%; margin-left: auto; flex: none; }
.stagewrap { display: flex; flex-direction: column; min-width: 0; }
.stage { flex: 1; display: flex; align-items: center; justify-content: center; padding: 22px;
background: var(--stage); position: relative; min-height: 0; }
.stage img { max-width: 100%; max-height: 100%; object-fit: contain; border-radius: 8px;
box-shadow: 0 4px 30px rgba(0,0,0,.4); background: #fff; }
html[data-theme="dark"] .stage img { background: #16181c; }
.nav-btn { position: absolute; top: 50%; transform: translateY(-50%); width: 42px; height: 42px;
border-radius: 50%; border: 1px solid var(--border); background: var(--panel);
color: var(--text); cursor: pointer; font-size: 18px; opacity: .85; }
.nav-btn:hover { opacity: 1; } .nav-btn.prev { left: 16px; } .nav-btn.next { right: 16px; }
.nav-btn:disabled { opacity: .25; cursor: default; }
.missing { color: var(--muted); font-size: 13px; text-align: center; }
.detail { border-top: 1px solid var(--border); background: var(--panel); padding: 14px 20px;
max-height: 38vh; overflow-y: auto; }
.detail h2 { margin: 0 0 4px; font-size: 15px; }
.detail .meta { color: var(--muted); font-size: 12px; margin-bottom: 10px; }
.notes { list-style: none; padding: 0; margin: 0; display: grid; gap: 6px; }
.notes li { display: flex; gap: 8px; align-items: flex-start; }
.sev { font-size: 10px; font-weight: 700; text-transform: uppercase; padding: 2px 7px; border-radius: 999px;
color: #fff; flex: none; margin-top: 1px; }
.sev.high { background: var(--hi); } .sev.med { background: var(--med); } .sev.low { background: var(--low); }
.finding .fix { color: var(--muted); font-size: 12.5px; }
.finding .fix b { color: var(--text); font-weight: 600; }
/* Summary tab */
.summary { padding: 20px 28px; overflow-y: auto; }
.summary h2 { font-size: 16px; margin: 22px 0 8px; }
.summary .empty { color: var(--muted); }
.card { background: var(--panel); border: 1px solid var(--border); border-radius: 10px;
padding: 12px 14px; margin-bottom: 8px; box-shadow: var(--shadow); }
.card .head { display: flex; gap: 8px; align-items: center; }
.card a { color: var(--accent); text-decoration: none; cursor: pointer; }
.hide { display: none !important; }
kbd { font: 11px ui-monospace, monospace; background: var(--panel-2); border: 1px solid var(--border);
border-radius: 4px; padding: 1px 5px; }
</style>
</head>
<body>
<header>
<div>
<h1 id="feature-title">UI Walkthrough</h1>
<div class="sub" id="feature-sub"></div>
</div>
<div class="spacer"></div>
<div class="tabs">
<button class="tab active" data-tab="viewer">Walkthrough</button>
<button class="tab" data-tab="summary">Findings</button>
</div>
<div class="counter" id="counter"></div>
<label class="theme-toggle" title="Toggle light / dark for every screenshot">
<span class="lbl" id="lbl-light">Light</span>
<span class="switch"><input type="checkbox" id="theme-switch" /><span class="slider"></span></span>
<span class="lbl" id="lbl-dark">Dark</span>
</label>
</header>
<main id="viewer-pane">
<aside class="rail" id="rail"></aside>
<section class="stagewrap">
<div class="stage">
<button class="nav-btn prev" id="prev" aria-label="Previous">&#8249;</button>
<img id="stage-img" alt="" />
<div class="missing hide" id="missing"></div>
<button class="nav-btn next" id="next" aria-label="Next">&#8250;</button>
</div>
<div class="detail">
<h2 id="view-title"></h2>
<div class="meta" id="view-meta"></div>
<ul class="notes" id="view-notes"></ul>
</div>
</section>
</main>
<section class="summary hide" id="summary-pane"></section>
<script id="data">
window.__WALKTHROUGH__ = /*__DATA__*/{"feature":"No data","branch":"","generated":"","views":[],"findings":{"visual":[],"ux":[]}}/*__END__*/;
</script>
<script>
(function () {
var D = window.__WALKTHROUGH__ || { views: [], findings: { visual: [], ux: [] } };
var views = D.views || [];
var state = { i: 0, theme: localStorage.getItem("ui-wt-theme") || "light", tab: "viewer" };
var $ = function (id) { return document.getElementById(id); };
function sevClass(s) { return s === "high" ? "high" : s === "med" || s === "medium" ? "med" : "low"; }
function applyChrome() {
document.documentElement.setAttribute("data-theme", state.theme);
$("theme-switch").checked = state.theme === "dark";
$("lbl-light").classList.toggle("on", state.theme === "light");
$("lbl-dark").classList.toggle("on", state.theme === "dark");
}
function srcFor(v) { return state.theme === "dark" ? (v.dark || v.light) : (v.light || v.dark); }
function findingsForView(id) {
var all = (D.findings && D.findings.visual || []).concat(D.findings && D.findings.ux || []);
return all.filter(function (f) { return f.view === id; });
}
function renderRail() {
var rail = $("rail");
rail.innerHTML = "";
if (!views.length) { rail.innerHTML = '<div class="group-label">No views captured</div>'; return; }
views.forEach(function (v, idx) {
var fs = findingsForView(v.id);
var worst = fs.some(function (f){return sevClass(f.severity)==="high";}) ? "var(--hi)"
: fs.some(function (f){return sevClass(f.severity)==="med";}) ? "var(--med)"
: fs.length ? "var(--low)" : "transparent";
var el = document.createElement("div");
el.className = "thumb" + (idx === state.i ? " active" : "");
el.innerHTML = '<img src="' + srcFor(v) + '" alt="" />' +
'<div><div class="t">' + (v.title || v.id) + '</div>' +
'<div class="badge">' + (v.viewport || "") + '</div></div>' +
'<span class="dot" style="background:' + worst + '"></span>';
el.onclick = function () { state.i = idx; render(); };
rail.appendChild(el);
});
}
function render() {
applyChrome();
if (!views.length) {
$("missing").classList.remove("hide"); $("stage-img").classList.add("hide");
$("missing").textContent = "No screenshots in this report yet.";
$("counter").textContent = ""; return;
}
var v = views[state.i];
var src = srcFor(v);
var img = $("stage-img");
if (src) {
img.classList.remove("hide"); $("missing").classList.add("hide");
img.src = src; img.alt = v.title || v.id;
} else {
img.classList.add("hide"); $("missing").classList.remove("hide");
$("missing").textContent = "No " + state.theme + " screenshot for this view.";
}
$("counter").textContent = (state.i + 1) + " / " + views.length;
$("view-title").textContent = v.title || v.id;
$("view-meta").textContent = [v.viewport, state.theme + " mode"].filter(Boolean).join(" · ");
var notes = $("view-notes"); notes.innerHTML = "";
var fs = findingsForView(v.id);
(v.notes || []).forEach(function (n) {
var li = document.createElement("li"); li.textContent = "· " + n; notes.appendChild(li);
});
fs.forEach(function (f) {
var li = document.createElement("li"); li.className = "finding";
li.innerHTML = '<span class="sev ' + sevClass(f.severity) + '">' + (f.severity || "note") + '</span>' +
'<span><b>' + (f.title || "") + '</b> — ' + (f.detail || "") +
(f.fix ? ' <span class="fix"><b>Fix:</b> ' + f.fix + '</span>' : '') + '</span>';
notes.appendChild(li);
});
$("prev").disabled = state.i === 0;
$("next").disabled = state.i === views.length - 1;
renderRail();
}
function renderSummary() {
var pane = $("summary-pane");
function block(title, arr) {
var h = '<h2>' + title + ' (' + arr.length + ')</h2>';
if (!arr.length) return h + '<div class="empty">None found.</div>';
return h + arr.map(function (f) {
return '<div class="card"><div class="head">' +
'<span class="sev ' + sevClass(f.severity) + '">' + (f.severity || "note") + '</span>' +
'<b>' + (f.title || "") + '</b>' +
(f.view ? ' <a data-jump="' + f.view + '">' + f.view + '</a>' : '') + '</div>' +
'<div style="margin-top:6px">' + (f.detail || "") + '</div>' +
(f.fix ? '<div class="finding" style="margin-top:6px"><span class="fix"><b>Fix:</b> ' + f.fix + '</span></div>' : '') +
'</div>';
}).join("");
}
pane.innerHTML = block("Visual & consistency", (D.findings && D.findings.visual) || []) +
block("UX & ease of use", (D.findings && D.findings.ux) || []);
pane.querySelectorAll("[data-jump]").forEach(function (a) {
a.onclick = function () {
var id = a.getAttribute("data-jump");
var idx = views.findIndex(function (v) { return v.id === id; });
if (idx >= 0) { state.i = idx; setTab("viewer"); }
};
});
}
function setTab(t) {
state.tab = t;
document.querySelectorAll(".tab").forEach(function (b) { b.classList.toggle("active", b.dataset.tab === t); });
$("viewer-pane").classList.toggle("hide", t !== "viewer");
$("summary-pane").classList.toggle("hide", t !== "summary");
if (t === "viewer") $("viewer-pane").style.display = "grid";
if (t === "summary") renderSummary();
}
// wiring
$("feature-title").textContent = D.feature || "UI Walkthrough";
$("feature-sub").textContent = [D.branch, D.generated].filter(Boolean).join(" · ");
$("theme-switch").onchange = function () {
state.theme = this.checked ? "dark" : "light";
localStorage.setItem("ui-wt-theme", state.theme);
render();
};
$("prev").onclick = function () { if (state.i > 0) { state.i--; render(); } };
$("next").onclick = function () { if (state.i < views.length - 1) { state.i++; render(); } };
document.addEventListener("keydown", function (e) {
if (state.tab !== "viewer") return;
if (e.key === "ArrowLeft") $("prev").click();
if (e.key === "ArrowRight") $("next").click();
if (e.key.toLowerCase() === "t") $("theme-switch").click();
});
document.querySelectorAll(".tab").forEach(function (b) { b.onclick = function () { setTab(b.dataset.tab); }; });
render();
})();
</script>
</body>
</html>
+1
View File
@@ -27,6 +27,7 @@ node_modules/
**/node_modules/
frontend/node_modules/
frontend/editor/dist/
frontend/dist-portal/
frontend/editor/playwright-report/
.npm/
.yarn/
+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.0
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.0
pkgrel=1
pkgdesc="Locally hosted, web-based PDF manipulation tool (server JAR, prebuilt)"
arch=('any')
-15
View File
@@ -87,21 +87,6 @@ engine: &engine
- Taskfile.yml
- .taskfiles/engine.yml
# Files that can make the committed generated API models (frontend tool API
# types + engine tool models) go stale: the Java tool surfaces they derive from,
# the generators, the generated files themselves (to catch a hand-edit), and the
# tasks that drive generation. Deliberately excludes the broad frontend/docker/
# testing globs, so a CSS-only PR does not boot the backend to rebuild the spec.
generated-models: &generated-models
- *openapi
- frontend/editor/scripts/generate-tool-api-types.mts
- frontend/editor/src/core/types/toolApiTypes.ts
- engine/scripts/generate_tool_models.py
- engine/src/stirling/models/tool_models.py
- .taskfiles/frontend.yml
- .taskfiles/engine.yml
- .github/workflows/check-generated-models.yml
licenses-frontend: &licenses-frontend
- ".github/workflows/frontend-backend-licenses-update.yml"
- "frontend/package.json"
+6 -108
View File
@@ -116,9 +116,6 @@ jobs:
env:
USE_DEPOT: ${{ needs.pick.outputs.is_fork != 'true' }}
DEPOT_TOKEN: ${{ secrets.DEPOT_TOKEN }}
# Single source of truth for whether this preview embeds the admin portal:
# drives the image build-arg and the deployment comment.
BUILD_PORTAL: "true"
steps:
- name: Harden Runner
@@ -249,9 +246,7 @@ jobs:
file: ./docker/embedded/Dockerfile
push: true
tags: ${{ secrets.DOCKER_HUB_USERNAME }}/test:v2-${{ steps.commit-hash.outputs.app_short }}
build-args: |
VERSION_TAG=v2-alpha
BUILD_PORTAL=${{ env.BUILD_PORTAL }}
build-args: VERSION_TAG=v2-alpha
platforms: linux/amd64
- name: Build and push V2 image (Docker fork fallback)
@@ -264,9 +259,7 @@ jobs:
cache-from: type=gha,scope=stirling-pdf-latest
cache-to: type=gha,mode=max,scope=stirling-pdf-latest
tags: ${{ secrets.DOCKER_HUB_USERNAME }}/test:v2-${{ steps.commit-hash.outputs.app_short }}
build-args: |
VERSION_TAG=v2-alpha
BUILD_PORTAL=${{ env.BUILD_PORTAL }}
build-args: VERSION_TAG=v2-alpha
platforms: linux/amd64
- name: Set up SSH
@@ -297,8 +290,6 @@ jobs:
- /stirling/V2-PR-${{ needs.check-pr.outputs.pr_number }}/storage:/storage:rw
environment:
DISABLE_ADDITIONAL_FEATURES: "false"
POLICIES_ENABLED: "true"
STIRLING_BILLING_ACCOUNT_LINK_ENABLED: "true"
SECURITY_ENABLELOGIN: "true"
SECURITY_INITIALLOGIN_USERNAME: "${{ secrets.TEST_LOGIN_USERNAME }}"
SECURITY_INITIALLOGIN_PASSWORD: "${{ secrets.TEST_LOGIN_PASSWORD }}"
@@ -342,70 +333,9 @@ jobs:
# Set port for output
echo "v2_port=${V2_PORT}" >> $GITHUB_OUTPUT
# ---- Storybook preview (only when this PR touches stories/.storybook) ----
# Runs inside the same approved-contributor-gated deploy job, so it deploys
# under the exact same access rules as the app preview.
- name: Detect Storybook changes
id: sb-changes
uses: dorny/paths-filter@fbd0ab8f3e69293af611ebaee6363fc25e6d187d # v4.0.1
with:
list-files: json
filters: |
storybook:
- 'frontend/**/*.stories.@(ts|tsx|mdx)'
- 'frontend/**/*.mdx'
- 'frontend/.storybook/**'
- name: Set up Node.js for Storybook
if: steps.sb-changes.outputs.storybook == 'true'
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: "22"
cache: "npm"
cache-dependency-path: frontend/package-lock.json
- name: Install Task for Storybook
if: steps.sb-changes.outputs.storybook == 'true'
uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0
- name: Build and deploy Storybook
id: storybook
if: steps.sb-changes.outputs.storybook == 'true'
env:
VPS_HOST: ${{ secrets.NEW_VPS_HOST }}
VPS_USER: ${{ secrets.NEW_VPS_USERNAME }}
run: |
set -euo pipefail
# `prepare` generates the icon set stories import (not committed).
task frontend:prepare
task frontend:storybook:build
PR=${{ needs.check-pr.outputs.pr_number }}
# Served at the ROOT of its own port so Storybook's global MSW worker
# (/mockServiceWorker.js) resolves. Port = PR + 20000 (bijective, offset
# from the app preview's bare-PR-number port).
SB_PORT=$((PR + 20000))
DIR=/stirling/SB-PR-$PR
tar czf storybook.tgz -C frontend/storybook-static .
scp -i ../private.key -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null \
storybook.tgz "$VPS_USER@$VPS_HOST:/tmp/storybook-$PR.tgz"
ssh -i ../private.key -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -T \
"$VPS_USER@$VPS_HOST" << ENDSSH
set -e
rm -rf "$DIR" && mkdir -p "$DIR"
tar xzf /tmp/storybook-$PR.tgz -C "$DIR"
rm -f /tmp/storybook-$PR.tgz
docker rm -f storybook-pr-$PR 2>/dev/null || true
docker run -d --name storybook-pr-$PR --restart unless-stopped \
-p $SB_PORT:80 -v "$DIR":/usr/share/nginx/html:ro nginx:alpine
ENDSSH
echo "url=http://$VPS_HOST:$SB_PORT/" >> "$GITHUB_OUTPUT"
- name: Post V2 deployment URL to PR
if: success()
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
env:
SB_URL: ${{ steps.storybook.outputs.url }}
SB_FILES: ${{ steps.sb-changes.outputs.storybook_files }}
with:
github-token: ${{ steps.setup-bot.outputs.token }}
script: |
@@ -429,40 +359,12 @@ jobs:
}
const deploymentUrl = `http://${{ secrets.NEW_VPS_HOST }}:${v2Port}`;
// Only mention the portal when this image actually embeds it.
// Use the direct IP URL - the SSL hostname isn't supported yet.
const withPortal = "${{ env.BUILD_PORTAL }}" === "true";
const portalNote = withPortal
? `🧩 **Admin portal** included - try it at [${deploymentUrl}/portal](${deploymentUrl}/portal).\n\n`
: ``;
// Storybook preview: only present when this PR changed stories/config.
const sbUrl = process.env.SB_URL;
let storybookNote = "";
if (sbUrl) {
const files = JSON.parse(process.env.SB_FILES || "[]");
const stories = files.filter((f) => /\.stories\.(ts|tsx|mdx)$/.test(f));
const config = files.filter((f) => f.startsWith("frontend/.storybook/"));
const shorten = (f) =>
f.replace(/^frontend\/editor\/src\//, "").replace(/^frontend\//, "");
const storyList = stories.map((f) => `- \`${shorten(f)}\``).join("\n");
const configList = config.map((f) => `- \`${shorten(f)}\``).join("\n");
const summary =
`${stories.length} stor${stories.length === 1 ? "y" : "ies"} changed` +
(config.length ? ` (+${config.length} config file${config.length === 1 ? "" : "s"})` : "");
storybookNote =
`📚 **Storybook:** [${sbUrl}](${sbUrl})\n\n` +
`<details>\n<summary>${summary}</summary>\n\n` +
(storyList ? `**Stories**\n${storyList}\n\n` : "") +
(configList ? `**Config**\n${configList}\n` : "") +
`</details>\n\n`;
}
const httpsUrl = `https://${v2Port}.ssl.stirlingpdf.cloud`;
const commentBody = `## 🚀 V2 Auto-Deployment Complete!\n\n` +
`Your V2 PR with embedded architecture has been deployed!\n\n` +
`🔗 **Direct Test URL (non-SSL)** [${deploymentUrl}](${deploymentUrl})\n\n` +
portalNote +
storybookNote +
`🔐 **Secure HTTPS URL**: [${httpsUrl}](${httpsUrl})\n\n` +
`_This deployment will be automatically cleaned up when the PR is closed._\n\n` +
`🔄 **Auto-deployed** for approved V2 contributors.`;
@@ -558,11 +460,7 @@ jobs:
else
echo "V2 PR directory not found, nothing to clean up"
fi
# Remove this PR's Storybook preview (container + files), if any.
docker rm -f storybook-pr-${{ github.event.pull_request.number }} 2>/dev/null || true
rm -rf /stirling/SB-PR-${{ github.event.pull_request.number }}
# Clean up old unused images (older than 2 weeks) but keep recent ones for reuse
docker image prune -af --filter "until=336h" --filter "label!=keep=true" || true
+99 -4
View File
@@ -1,9 +1,9 @@
name: AI Engine CI
# Runs the engine quality gate (lint, type-check, format-check, tests). Called
# from build.yml on PRs and merge_group; also runs directly on push to main as
# a post-merge safety net. Freshness of the generated tool_models.py is checked
# by the shared check-generated-models workflow.
# Validates the Python AI engine: regenerates tool models and runs the
# engine quality gate (lint, type-check, format-check, tests). Called from
# build.yml on PRs and merge_group; also runs directly on push to main as
# a post-merge safety net.
on:
workflow_call:
push:
@@ -34,9 +34,104 @@ jobs:
with:
enable-cache: true
- name: Set up JDK 25
uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0
with:
java-version: "25"
distribution: "temurin"
- name: Setup Gradle
uses: gradle/actions/setup-gradle@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e # v6.1.0
with:
gradle-version: 9.6.0
- name: Install Task
uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0
- name: Regenerate tool models
run: task engine:tool-models
- name: Verify tool models are up to date
id: tool-models-check
continue-on-error: true
run: git diff --exit-code engine/src/stirling/models/tool_models.py
- name: Comment on tool models check failure
# Only post a comment on PRs. github-script's PR helpers need an
# issue/PR number, which doesn't exist on merge_group runs.
if: steps.tool-models-check.outcome == 'failure' && github.event_name == 'pull_request'
continue-on-error: true
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
with:
script: |
const marker = '<!-- tool-models-check -->';
const body = [
marker,
'### Tool Models Check Failed',
'',
'The generated `engine/src/stirling/models/tool_models.py` is out of date with the Java OpenAPI spec and will need to be regenerated before it can be merged in.',
'',
'Run `task engine:tool-models` to regenerate, then commit the updated file.',
].join('\n');
const { data: comments } = await github.rest.issues.listComments({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
});
const existing = comments.find(c => c.body.includes(marker));
if (existing) {
await github.rest.issues.updateComment({
owner: context.repo.owner,
repo: context.repo.repo,
comment_id: existing.id,
body,
});
} else {
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
body,
});
}
- name: Fail if tool models check failed
if: steps.tool-models-check.outcome == 'failure'
run: |
echo "============================================"
echo " Tool Models Check Failed"
echo "============================================"
echo ""
echo "The generated engine/src/stirling/models/tool_models.py"
echo "is out of date with the Java OpenAPI spec and will"
echo "need to be regenerated before it can be merged in."
echo ""
echo "Run 'task engine:tool-models' to regenerate, then"
echo "commit the updated file."
echo "============================================"
exit 1
- name: Remove tool models check comment on success
if: steps.tool-models-check.outcome == 'success' && github.event_name == 'pull_request'
continue-on-error: true
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
with:
script: |
const marker = '<!-- tool-models-check -->';
const { data: comments } = await github.rest.issues.listComments({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
});
const existing = comments.find(c => c.body.includes(marker));
if (existing) {
await github.rest.issues.deleteComment({
owner: context.repo.owner,
repo: context.repo.repo,
comment_id: existing.id,
});
}
- name: Quality-check engine
id: engine-check
run: task engine:check
+4 -25
View File
@@ -2,7 +2,7 @@ name: Enterprise E2E (Playwright)
# Enterprise Playwright suite — exercises premium-key gated features (audit,
# teams, analytics) plus full OAuth + SAML logins via the Keycloak compose
# stacks under testing/compose. Slow and secret-gated, so it runs in four
# stacks under testing/compose. Slow and secret-gated, so it runs in three
# situations:
#
# - PRs that touch proprietary / premium / SSO compose / enterprise tests
@@ -12,6 +12,8 @@ name: Enterprise E2E (Playwright)
# - on a nightly cron schedule (catches Keycloak image drift, license
# expiry, upstream proprietary changes),
# - manual workflow_dispatch.
#
# Auto-skipped when secrets.PREMIUM_KEY_ENTERPRISE is missing (forks, dependabot).
on:
workflow_call:
@@ -50,10 +52,6 @@ jobs:
playwright-e2e-enterprise:
needs: pick
# Skip on fork PRs / untrusted authors: they have no PREMIUM_KEY_ENTERPRISE
# (nor DEPOT_TOKEN), so the suite can't boot premium and would fail. See the
# header comment. GitHub reports the skipped reusable workflow as success.
if: needs.pick.outputs.is_fork != 'true'
runs-on: ${{ needs.pick.outputs.is_fork == 'true' && 'ubuntu-latest' || format('depot-ubuntu-24.04-{0}', inputs.depot_cores || '8') }}
timeout-minutes: 45
env:
@@ -167,8 +165,6 @@ jobs:
wait_for_backend
- name: Run enterprise OAuth Playwright tests
id: oauth-tests
env:
PLAYWRIGHT_JSON_OUTPUT_FILE: ${{ github.workspace }}/frontend/playwright-report/results-oauth.json
run: task e2e:enterprise -- --grep "OAuth"
- name: Stop backend + tear down OAuth Keycloak
if: always()
@@ -242,8 +238,6 @@ jobs:
wait_for_backend
- name: Run enterprise SAML Playwright tests
id: saml-tests
env:
PLAYWRIGHT_JSON_OUTPUT_FILE: ${{ github.workspace }}/frontend/playwright-report/results-saml.json
run: task e2e:enterprise -- --grep "SAML"
- name: Stop backend + tear down SAML Keycloak
if: always()
@@ -274,8 +268,6 @@ jobs:
wait_for_backend
- name: Run enterprise feature Playwright tests
id: feature-tests
env:
PLAYWRIGHT_JSON_OUTPUT_FILE: ${{ github.workspace }}/frontend/playwright-report/results-feature.json
run: task e2e:enterprise -- --grep "Enterprise license"
- name: Print backend log on failure
if: failure()
@@ -288,23 +280,10 @@ jobs:
run: |
source /tmp/helpers.sh
stop_backend
- name: Flag flaky tests
# Runs regardless of the test outcomes: a flaky test (passed on retry)
# leaves its step green, so this is the only place it surfaces. Merges
# all three phase reports (some may be absent if an earlier phase hard-
# failed and skipped the rest). Emits ::warning:: annotations + a job
# summary; never fails the job.
if: always()
working-directory: frontend
run: >
npx tsx editor/scripts/report-flaky-tests.mts
"${{ github.workspace }}/frontend/playwright-report/results-oauth.json"
"${{ github.workspace }}/frontend/playwright-report/results-saml.json"
"${{ github.workspace }}/frontend/playwright-report/results-feature.json"
- name: Upload Playwright report
if: always()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: playwright-report-enterprise-${{ github.run_id }}
path: frontend/playwright-report/
path: frontend/editor/playwright-report/
retention-days: 7
-20
View File
@@ -43,7 +43,6 @@ jobs:
docker-base: ${{ steps.changes.outputs.docker-base }}
tauri: ${{ steps.changes.outputs.tauri }}
engine: ${{ steps.changes.outputs.engine }}
generated-models: ${{ steps.changes.outputs.generated-models }}
proprietary: ${{ steps.changes.outputs.proprietary }}
steps:
- name: Harden the runner (Audit all outbound calls)
@@ -172,20 +171,6 @@ jobs:
uses: ./.github/workflows/ai-engine.yml
secrets: inherit
# The generated frontend types and engine tool models are both derived from
# the Java OpenAPI spec. This job regenerates and diffs them; it boots the
# backend, so it is gated on the narrow generated-models filter (spec source,
# generators, generated files, generation tasks) rather than the broad
# frontend filter, so a CSS-only PR does not pay for a backend build.
generated-models:
if: needs.files-changed.outputs.generated-models == 'true'
needs: [files-changed]
permissions:
contents: read
pull-requests: write
uses: ./.github/workflows/check-generated-models.yml
secrets: inherit
pre-commit:
needs: [files-changed]
permissions:
@@ -217,9 +202,6 @@ jobs:
contents: read
uses: ./.github/workflows/coverage-aggregate.yml
secrets: inherit
with:
frontend-validation-result: ${{ needs.frontend-validation.result }}
playwright-e2e-live-result: ${{ needs.playwright-e2e-live.result }}
# Single status check that branch protection should mark as required.
# Succeeds when every upstream job is either `success` or `skipped` (path-
@@ -243,7 +225,6 @@ jobs:
- test-build-docker-images
- tauri-build
- ai-engine
- generated-models
- pre-commit
- dependency-review
runs-on: ubuntu-latest
@@ -269,7 +250,6 @@ jobs:
test-build-docker-images=${{ needs.test-build-docker-images.result }}
tauri-build=${{ needs.tauri-build.result }}
ai-engine=${{ needs.ai-engine.result }}
generated-models=${{ needs.generated-models.result }}
pre-commit=${{ needs.pre-commit.result }}
dependency-review=${{ needs.dependency-review.result }}
run: |
@@ -1,148 +0,0 @@
name: Check generated models
# Verifies the committed generated API models are still in sync with the Java
# OpenAPI spec: the frontend tool API types
# (frontend/editor/src/core/types/toolApiTypes.ts) and the engine tool
# models (engine/src/stirling/models/tool_models.py). Regenerates both with the
# single top-level `task tool-models` and fails if either committed file is
# out of date. Called from build.yml when the backend Java, frontend, or engine
# changes; also runs on push to main as a post-merge safety net.
on:
workflow_call:
push:
branches: [main]
permissions:
contents: read
jobs:
generated-models:
runs-on: ubuntu-latest
permissions:
contents: read
pull-requests: write
env:
DEPOT_TOKEN: ${{ secrets.DEPOT_TOKEN }}
steps:
- name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3
with:
egress-policy: audit
- name: Checkout code
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Install uv
uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0
with:
enable-cache: true
- name: Set up JDK 25
uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0
with:
java-version: "25"
distribution: "temurin"
- name: Setup Gradle
uses: gradle/actions/setup-gradle@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e # v6.1.0
with:
gradle-version: 9.6.0
- name: Set up Node
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: "22"
cache: "npm"
cache-dependency-path: frontend/package-lock.json
- name: Install Task
uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0
# Rebuilds the OpenAPI spec from the current Java and regenerates both the
# frontend types and the engine tool models from it.
- name: Regenerate generated models
run: task tool-models
- name: Verify generated models are up to date
id: models-check
continue-on-error: true
run: |
git diff --exit-code \
frontend/editor/src/core/types/toolApiTypes.ts \
engine/src/stirling/models/tool_models.py
- name: Comment on generated models check failure
# Only post a comment on PRs. github-script's PR helpers need an
# issue/PR number, which doesn't exist on merge_group runs.
if: steps.models-check.outcome == 'failure' && github.event_name == 'pull_request'
continue-on-error: true
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
with:
script: |
const marker = '<!-- generated-models-check -->';
const body = [
marker,
'### Generated Models Check Failed',
'',
'The generated `frontend/editor/src/core/types/toolApiTypes.ts` and/or `engine/src/stirling/models/tool_models.py` are out of date with the Java OpenAPI spec and will need to be regenerated before they can be merged in.',
'',
'Run `task tool-models` to regenerate both, then commit the updated files.',
].join('\n');
const { data: comments } = await github.rest.issues.listComments({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
});
const existing = comments.find(c => c.body.includes(marker));
if (existing) {
await github.rest.issues.updateComment({
owner: context.repo.owner,
repo: context.repo.repo,
comment_id: existing.id,
body,
});
} else {
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
body,
});
}
- name: Fail if generated models check failed
if: steps.models-check.outcome == 'failure'
run: |
echo "============================================"
echo " Generated Models Check Failed"
echo "============================================"
echo ""
echo "The generated frontend API types and/or engine tool"
echo "models are out of date with the Java OpenAPI spec and"
echo "will need to be regenerated before they can be merged in."
echo ""
echo "Run 'task tool-models' to regenerate both, then"
echo "commit the updated files."
echo "============================================"
exit 1
- name: Remove generated models check comment on success
if: steps.models-check.outcome == 'success' && github.event_name == 'pull_request'
continue-on-error: true
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
with:
script: |
const marker = '<!-- generated-models-check -->';
const { data: comments } = await github.rest.issues.listComments({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
});
const existing = comments.find(c => c.body.includes(marker));
if (existing) {
await github.rest.issues.deleteComment({
owner: context.repo.owner,
repo: context.repo.repo,
comment_id: existing.id,
});
}
+7 -18
View File
@@ -13,17 +13,6 @@ name: Aggregate backend coverage
# producers themselves
on:
workflow_call:
inputs:
frontend-validation-result:
description: Result of the frontend-validation producer job
required: false
type: string
default: skipped
playwright-e2e-live-result:
description: Result of the playwright-e2e-live producer job
required: false
type: string
default: skipped
permissions:
contents: read
@@ -207,9 +196,9 @@ jobs:
# --------------------------------------------------------------
- name: Download vitest coverage artifact
# frontend-validation uploads as `frontend-coverage`. Tolerate
# absence on backend-only runs by skipping the download entirely
# when the producer job was not part of this workflow run.
if: inputs.frontend-validation-result == 'success'
# absence so a backend-only PR still produces the matrix with
# just backend rows populated.
if: always()
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v6.0.0
with:
name: frontend-coverage
@@ -217,12 +206,12 @@ jobs:
continue-on-error: true
- name: Download Playwright frontend coverage artifact
# e2e-live uploads the artifact with a stable name. Skip the
# download entirely when the producer job did not run.
if: inputs.playwright-e2e-live-result == 'success'
# e2e-live uploads as `playwright-frontend-coverage-<run_id>`.
# Same tolerance as vitest - matrix script handles missing inputs.
if: always()
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v6.0.0
with:
name: playwright-frontend-coverage
name: playwright-frontend-coverage-${{ github.run_id }}
path: matrix-inputs/playwright/
continue-on-error: true
+1 -11
View File
@@ -62,17 +62,7 @@ jobs:
# .test-state/playwright/coverage-pw/ for the post-process step
# to aggregate. Chromium-only - other engines silently skip.
PW_COVERAGE: "1"
PLAYWRIGHT_JSON_OUTPUT_FILE: ${{ github.workspace }}/frontend/playwright-report/results.json
run: task e2e:live
- name: Flag flaky tests
# Runs regardless of the test outcome: a flaky test (passed on retry)
# leaves the step green, so this is the only place it surfaces. Emits
# ::warning:: annotations + a job summary; never fails the job.
if: always()
working-directory: frontend
run: npx tsx editor/scripts/report-flaky-tests.mts "$PLAYWRIGHT_JSON_OUTPUT_FILE"
env:
PLAYWRIGHT_JSON_OUTPUT_FILE: ${{ github.workspace }}/frontend/playwright-report/results.json
- name: Generate JaCoCo report from e2e:live .exec
if: always()
id: live-coverage
@@ -179,7 +169,7 @@ jobs:
if: always() && steps.pw-frontend-coverage.outputs.summary == 'true'
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: playwright-frontend-coverage
name: playwright-frontend-coverage-${{ github.run_id }}
path: |
.test-state/playwright/coverage-pw-summary/
.test-state/playwright/coverage-pw/
+1 -12
View File
@@ -44,22 +44,11 @@ jobs:
VITE_BUILD_FOR_PREVIEW: "1"
run: task frontend:build
- name: Run stubbed E2E tests (chromium)
env:
PLAYWRIGHT_JSON_OUTPUT_FILE: ${{ github.workspace }}/frontend/playwright-report/results.json
run: task e2e:stubbed -- --workers=3
- name: Flag flaky tests
# Runs regardless of the test outcome: a flaky test (passed on retry)
# leaves the step green, so this is the only place it surfaces. Emits
# ::warning:: annotations + a job summary; never fails the job.
if: always()
working-directory: frontend
run: npx tsx editor/scripts/report-flaky-tests.mts "$PLAYWRIGHT_JSON_OUTPUT_FILE"
env:
PLAYWRIGHT_JSON_OUTPUT_FILE: ${{ github.workspace }}/frontend/playwright-report/results.json
- name: Upload Playwright report
if: always()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: playwright-report-stubbed-${{ github.run_id }}
path: frontend/playwright-report/
path: frontend/editor/playwright-report/
retention-days: 7
@@ -98,13 +98,6 @@ jobs:
- name: Install Task
uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0
- name: Generate frontend license report (Push only)
if: github.event_name == 'push'
env:
PR_IS_FORK: "false"
run: task frontend:licenses:generate
- name: Generate frontend license report (internal PR)
if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork == false
env:
@@ -360,7 +353,6 @@ jobs:
- name: Install Task
uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0
- name: Check licenses and generate report
id: license-check
run: task backend:licenses:generate || echo "LICENSE_CHECK_FAILED=true" >> $GITHUB_ENV
+2 -2
View File
@@ -53,8 +53,8 @@ jobs:
if: always()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: playwright-report-nightly-${{ github.run_id }}
path: frontend/playwright-report/
name: playwright-nightly-${{ github.run_id }}
path: frontend/editor/playwright-report/
retention-days: 14
# Builds all desktop platforms on a schedule so the Rust dependency cache is
+2 -2
View File
@@ -15,10 +15,10 @@ testing/compose/validate-mcp-test.sh:curl-auth-header:92
testing/compose/validate-mcp-test.sh:curl-auth-header:116
# Storybook example showing curl with a fake Bearer token placeholder (sk_live_a3f8...).
frontend/editor/src/proprietary/ui/CodeBlock.stories.tsx:curl-auth-header:5
frontend/shared/components/CodeBlock.stories.tsx:curl-auth-header:4
# Truncated placeholder API key in portal docs example (sk_live_8f2c...e10) - not a real secret.
frontend/editor/src/portal/components/docs/GettingStartedSection.tsx:generic-api-key:30
frontend/portal/src/components/docs/GettingStartedSection.tsx:generic-api-key:31
# False positive: generic-api-key matches the Java type name "X509Certificate"
# in a method signature (CreateSignatureBase.resolveSignatureAlgorithm) - not a secret.
+98 -28
View File
@@ -55,6 +55,15 @@ tasks:
- editor/src/core/data/ogImageMap.json
- editor/public/og-metadata.json
prepare:classifier-categories:
internal: true
run: when_changed
desc: "Regenerate the engine classifier categories JSON from the TS source of truth"
cmds:
- npx tsx editor/scripts/generate-classification-taxonomy.mts
sources:
- editor/src/proprietary/data/classificationTaxonomy.ts
prepare:
desc: "Set up dev environment"
run: when_changed
@@ -65,6 +74,7 @@ tasks:
vars: { MODE: '{{.MODE}}' }
- prepare:icons
- prepare:og
- prepare:classifier-categories
# ============================================================
# Development
@@ -80,12 +90,6 @@ tasks:
OPEN: '{{.OPEN | default ""}}'
env:
BACKEND_URL: '{{.BACKEND_URL}}'
# Dev-only browser-tab label so concurrent worktrees are distinguishable.
# Only the worktree folder basename (e.g. "wt1") is exposed — never the
# full path, hostname, or user. Consumed at dev-serve time by vite.config
# and dropped from production builds.
STIRLING_DEV_LABEL:
sh: basename "$(git rev-parse --show-toplevel 2>/dev/null || pwd)"
cmds:
- npx vite editor --mode {{.MODE}} --port {{.PORT}}{{if .OPEN}} --open{{end}}
@@ -134,6 +138,37 @@ tasks:
- task: dev:_run
vars: { MODE: prototypes, PORT: '{{.PORT}}', BACKEND_URL: '{{.BACKEND_URL}}', OPEN: '{{.OPEN}}' }
dev:portal:
desc: "Start developer portal dev server"
ignore_error: true
deps: [install]
vars:
PORT: '{{.PORT | default "5173"}}'
BACKEND_URL: '{{.BACKEND_URL | default "http://localhost:8080"}}'
EDITOR_URL: '{{.EDITOR_URL | default ""}}'
OPEN: '{{.OPEN | default ""}}'
SUBPATH: '{{.SUBPATH | default ""}}'
MOCKS: '{{.MOCKS | default ""}}'
env:
BACKEND_URL: '{{.BACKEND_URL}}'
cmds:
- '{{if .SUBPATH}}RUN_SUBPATH={{.SUBPATH}} {{end}}{{if .MOCKS}}VITE_PORTAL_MOCKS={{.MOCKS}} {{end}}{{if .EDITOR_URL}}VITE_EDITOR_URL={{.EDITOR_URL}} {{end}}npx vite portal --port {{.PORT}}{{if .OPEN}} --open{{end}}'
dev:portal:proxy:serve:
internal: true
vars:
PORT: '{{.PORT | default "3000"}}'
BACKEND_URL: '{{.BACKEND_URL | default "http://localhost:8080"}}'
EDITOR_DEV_URL: '{{.EDITOR_DEV_URL | default ""}}'
PORTAL_DEV_URL: '{{.PORTAL_DEV_URL | default ""}}'
env:
PORT: '{{.PORT}}'
BACKEND_URL: '{{.BACKEND_URL}}'
EDITOR_DEV_URL: '{{.EDITOR_DEV_URL}}'
PORTAL_DEV_URL: '{{.PORTAL_DEV_URL}}'
cmds:
- npx tsx scripts/dev-origin-proxy.ts
# ============================================================
# Build
# ============================================================
@@ -180,6 +215,29 @@ tasks:
cmds:
- npx vite build editor --mode prototypes
build:portal:
desc: "Build developer portal"
deps: [install]
vars:
SUBPATH: '{{.SUBPATH | default ""}}'
cmds:
- '{{if .SUBPATH}}RUN_SUBPATH={{.SUBPATH}} {{end}}npx vite build portal'
preview:portal:proxy:
desc: "Build + serve editor + portal behind one origin (prod-like auth testing)"
deps: [prepare]
vars:
PORT: '{{.PORT | default "3000"}}'
BACKEND_URL: '{{.BACKEND_URL | default "http://localhost:8080"}}'
env:
PORT: '{{.PORT}}'
BACKEND_URL: '{{.BACKEND_URL}}'
cmds:
- task: build:proprietary
vars: { PREVIEW: '1' }
- task: build:portal
vars: { SUBPATH: portal }
- npx tsx scripts/dev-origin-proxy.ts
storybook:
desc: "Start Storybook dev server"
@@ -215,8 +273,8 @@ tasks:
deps: [install]
cmds:
# Globs so dpdm walks the whole tree. dpdm expands the braces itself, so this is
# shell-agnostic. Covers the whole editor tree, including the portal layer.
- npx dpdm "editor/src/**/*.{ts,tsx}" --circular --no-warning --no-tree --exit-code circular:1
# shell-agnostic. Covers editor, portal, and the shared design system.
- npx dpdm "editor/src/**/*.{ts,tsx}" "portal/src/**/*.{ts,tsx}" "shared/**/*.{ts,tsx}" --circular --no-warning --no-tree --exit-code circular:1
lint:fix:
desc: "Auto-fix lint issues"
@@ -297,6 +355,8 @@ tasks:
desc: "Typecheck scripts"
deps: [prepare]
cmds:
- task: typecheck:_run
vars: { PROJECT: scripts/tsconfig.json }
- task: typecheck:_run
vars: { PROJECT: editor/scripts/tsconfig.json }
@@ -312,7 +372,14 @@ tasks:
deps: [install]
cmds:
- task: typecheck:_run
vars: { PROJECT: editor/src/portal/tsconfig.json }
vars: { PROJECT: portal/tsconfig.json }
typecheck:shared:
desc: "Typecheck the shared design system"
deps: [install]
cmds:
- task: typecheck:_run
vars: { PROJECT: shared/tsconfig.json }
typecheck:all:
desc: "Typecheck all build variants"
@@ -325,6 +392,7 @@ tasks:
- task: typecheck:scripts
- task: typecheck:prototypes
- task: typecheck:portal
- task: typecheck:shared
# ============================================================
# Quality Gate
@@ -343,16 +411,28 @@ tasks:
cmds:
- node editor/scripts/generate-og-metadata.mjs --check
classifier-categories:
desc: "Regenerate the engine classifier categories JSON from the TS source"
cmds:
- npx tsx editor/scripts/generate-classification-taxonomy.mts
classifier-categories:check:
desc: "Fail if the committed classifier categories JSON is out of date"
cmds:
- npx tsx editor/scripts/generate-classification-taxonomy.mts --check
check:all:
desc: "Full CI quality gate"
cmds:
# Runs first, before prepare regenerates: guards the committed og-metadata.json /
# ogImageMap.json that the Cloudflare Pages (plain `vite build`) deploy relies on.
- task: og:check
- task: classifier-categories:check
- task: typecheck:all
- task: lint
- task: format:check
- task: build
- task: build:portal
- task: test
- task: storybook:build
@@ -364,6 +444,7 @@ tasks:
desc: "Run tests"
cmds:
- task: test:editor
- task: test:portal
test:editor:
desc: "Run editor tests"
@@ -371,6 +452,12 @@ tasks:
cmds:
- npx vitest run --root editor
test:portal:
desc: "Run portal tests"
deps: [prepare]
cmds:
- npx vitest run --root portal
test:watch:
desc: "Run tests in watch mode"
deps: [prepare]
@@ -402,23 +489,6 @@ tasks:
# Code Generation
# ============================================================
tool-models:
desc: "Generate tool API types from the Java OpenAPI spec"
deps: [install, ":backend:swagger"]
cmds:
- npx tsx editor/scripts/generate-tool-api-types.mts --spec ../SwaggerDoc.json --output editor/src/core/types/toolApiTypes.ts
sources:
- editor/scripts/generate-tool-api-types.mts
- ../SwaggerDoc.json
generates:
- editor/src/core/types/toolApiTypes.ts
tool-models:check:
desc: "Fail if committed tool API types are out of date"
deps: [install, ":backend:swagger"]
cmds:
- npx tsx editor/scripts/generate-tool-api-types.mts --spec ../SwaggerDoc.json --output editor/src/core/types/toolApiTypes.ts --check
licenses:generate:
desc: "Generate frontend license report"
deps: [install]
@@ -432,7 +502,7 @@ tasks:
clean:
desc: "Clean build artifacts and caches"
cmds:
- cmd: powershell rm -Recurse -Force -ErrorAction SilentlyContinue node_modules/.vite, editor/dist, dist
- cmd: powershell rm -Recurse -Force -ErrorAction SilentlyContinue node_modules/.vite, editor/dist, dist, dist-portal
platforms: [windows]
- cmd: rm -rf node_modules/.vite editor/dist dist
- cmd: rm -rf node_modules/.vite editor/dist dist dist-portal
platforms: [linux, darwin]
+1 -3
View File
@@ -139,8 +139,7 @@ The project structure is defined in `engine/pyproject.toml`. Any new dependencie
#### Environment Variables
- All `VITE_*` variables must be declared in the appropriate committed env file:
- `frontend/editor/.env` — core and shared vars (base, loaded in every mode)
- `frontend/editor/.env.proprietary` — proprietary-only vars, e.g. the admin portal's SaaS/account-link keys (layered on top of `.env` in proprietary mode)
- `frontend/editor/.env` — core, proprietary, and shared vars
- `frontend/editor/.env.saas` — SaaS-only vars (layered on top of `.env` in SaaS mode)
- `frontend/editor/.env.desktop` — desktop (Tauri)-only vars (layered on top of `.env` in desktop mode)
- These files are committed to Git and must not contain private keys
@@ -453,7 +452,6 @@ The frontend is organized with a clear separation of concerns:
- **CRITICAL**: Always update translations in `en-US` only - all other languages (including `en-GB`) are handled separately
- Translation files are located in `frontend/editor/public/locales/`
- After changing any translation file, run `task pre-commit:fix`
## Important Notes
+3 -3
View File
@@ -92,7 +92,7 @@ Visit the [Lombok website](https://projectlombok.org/setup/) for installation in
5. Add environment variable
For local testing, you should generally be testing the full 'Security' version of Stirling PDF. To do this, you must add the environment flag DISABLE_ADDITIONAL_FEATURES=false to your system and/or IDE build/run step.
6. **Frontend Setup (Required for Stirling 2.0)**
5. **Frontend Setup (Required for Stirling 2.0)**
Navigate to the frontend directory and install dependencies using npm.
### Verify Setup
@@ -275,7 +275,7 @@ Stirling-PDF uses different Docker images for various configurations. The build
1. Set the security environment variable:
```bash
export DISABLE_ADDITIONAL_FEATURES=true # or false to enable login and security features for builds
export DISABLE_ADDITIONAL_FEATURES=true # or false for to enable login and security features for builds
```
2. Build the project:
@@ -305,7 +305,7 @@ Stirling-PDF uses different Docker images for various configurations. The build
docker build --no-cache --pull --build-arg VERSION_TAG=alpha -t stirlingtools/stirling-pdf:latest-fat -f ./Dockerfile.fat .
```
Note: The `--no-cache` and `--pull` flags ensure that the build process uses the latest base images and doesn't use cached layers, which is useful for testing and ensuring reproducible builds. However, to improve build times these can often be removed depending on your use case
Note: The `--no-cache` and `--pull` flags ensure that the build process uses the latest base images and doesn't use cached layers, which is useful for testing and ensuring reproducible builds. however to improve build times these can often be removed depending on your usecase
## 7. Testing
+2 -2
View File
@@ -20,8 +20,8 @@ if that directory exists, is licensed under the license defined in "frontend/edi
if that directory exists, is licensed under the license defined in "frontend/editor/src/cloud/LICENSE".
* All content that resides under the "frontend/editor/src/prototypes/" directory of this repository,
if that directory exists, is licensed under the license defined in "frontend/editor/src/prototypes/LICENSE".
* All content that resides under the "frontend/editor/src/portal/" directory of this repository,
if that directory exists, is licensed under the license defined in "frontend/editor/src/portal/LICENSE".
* All content that resides under the "frontend/portal/" directory of this repository,
if that directory exists, is licensed under the license defined in "frontend/portal/LICENSE".
* Content outside of the above mentioned directories or restrictions above is
available under the MIT License as defined below.
+2 -2
View File
@@ -53,8 +53,8 @@ For full installation options (including desktop and Kubernetes), see our [Docum
## Support
- **Community**: [Discord](https://discord.gg/HYmhKj45pU)
- **Bug Reports**: [GitHub Issues](https://github.com/Stirling-Tools/Stirling-PDF/issues)
- **Community** [Discord](https://discord.gg/HYmhKj45pU)
- **Bug Reports**: [Github issues](https://github.com/Stirling-Tools/Stirling-PDF/issues)
## Contributing
+106 -13
View File
@@ -79,12 +79,86 @@ tasks:
OPEN: "true"
dev:portal:
desc: "Start backend + editor; the portal is an admin route at /portal"
desc: "Start backend + developer portal concurrently on free ports"
vars:
PORTS:
sh: '{{if eq OS "windows"}}{{.FIND_FREE_PORT_PS}} 8080 5173{{else}}{{.FIND_FREE_PORT_SH}} 8080 5173{{end}}'
BACKEND_PORT: '{{index (splitList "\n" .PORTS) 0}}'
EDITOR_PORT: '{{index (splitList "\n" .PORTS) 1}}'
PORTAL_PORT: '{{index (splitList "\n" .PORTS) 1}}'
deps:
- task: backend:dev
vars:
PORT: '{{.BACKEND_PORT}}'
SECURITY_ENABLELOGIN: "true"
POLICIES_ENABLED: "true"
- task: frontend:dev:portal
vars:
PORT: '{{.PORTAL_PORT}}'
BACKEND_URL: 'http://localhost:{{.BACKEND_PORT}}'
MOCKS: 'false'
OPEN: "true"
dev:portal:all:
desc: "Start backend + developer portal + editor concurrently on free ports"
vars:
PORTS:
sh: '{{if eq OS "windows"}}{{.FIND_FREE_PORT_PS}} 8080 5173 5174{{else}}{{.FIND_FREE_PORT_SH}} 8080 5173 5174{{end}}'
BACKEND_PORT: '{{index (splitList "\n" .PORTS) 0}}'
PORTAL_PORT: '{{index (splitList "\n" .PORTS) 1}}'
EDITOR_PORT: '{{index (splitList "\n" .PORTS) 2}}'
deps:
- task: backend:dev
vars:
PORT: '{{.BACKEND_PORT}}'
SECURITY_ENABLELOGIN: "true"
POLICIES_ENABLED: "true"
- task: frontend:dev:portal
vars:
PORT: '{{.PORTAL_PORT}}'
BACKEND_URL: 'http://localhost:{{.BACKEND_PORT}}'
# Point the portal's "Editor" app switcher at the editor we spawn here.
EDITOR_URL: 'http://localhost:{{.EDITOR_PORT}}/'
MOCKS: 'false'
OPEN: "true"
- task: frontend:dev
vars:
PORT: '{{.EDITOR_PORT}}'
BACKEND_URL: 'http://localhost:{{.BACKEND_PORT}}'
dev:portal:all:saas:
desc: "Start SaaS backend + developer portal + editor concurrently on free ports"
vars:
PORTS:
sh: '{{if eq OS "windows"}}{{.FIND_FREE_PORT_PS}} 8080 5173 5174{{else}}{{.FIND_FREE_PORT_SH}} 8080 5173 5174{{end}}'
BACKEND_PORT: '{{index (splitList "\n" .PORTS) 0}}'
PORTAL_PORT: '{{index (splitList "\n" .PORTS) 1}}'
EDITOR_PORT: '{{index (splitList "\n" .PORTS) 2}}'
deps:
- task: backend:dev:saas
vars:
PORT: '{{.BACKEND_PORT}}'
POLICIES_ENABLED: "true"
- task: frontend:dev:portal
vars:
PORT: '{{.PORTAL_PORT}}'
BACKEND_URL: 'http://localhost:{{.BACKEND_PORT}}'
EDITOR_URL: 'http://localhost:{{.EDITOR_PORT}}/'
MOCKS: 'false'
OPEN: "true"
- task: frontend:dev
vars:
PORT: '{{.EDITOR_PORT}}'
BACKEND_URL: 'http://localhost:{{.BACKEND_PORT}}'
dev:portal:proxy:
desc: "Editor + portal on ONE origin + backend via live dev servers (shared-token login)"
vars:
PORTS:
sh: '{{if eq OS "windows"}}{{.FIND_FREE_PORT_PS}} 8080 3000 5173 5174{{else}}{{.FIND_FREE_PORT_SH}} 8080 3000 5173 5174{{end}}'
BACKEND_PORT: '{{index (splitList "\n" .PORTS) 0}}'
PROXY_PORT: '{{index (splitList "\n" .PORTS) 1}}'
EDITOR_PORT: '{{index (splitList "\n" .PORTS) 2}}'
PORTAL_PORT: '{{index (splitList "\n" .PORTS) 3}}'
deps:
- task: backend:dev
vars:
@@ -95,7 +169,18 @@ tasks:
vars:
PORT: '{{.EDITOR_PORT}}'
BACKEND_URL: 'http://localhost:{{.BACKEND_PORT}}'
OPEN: "true"
- task: frontend:dev:portal
vars:
PORT: '{{.PORTAL_PORT}}'
BACKEND_URL: 'http://localhost:{{.BACKEND_PORT}}'
SUBPATH: portal
MOCKS: 'false'
- task: frontend:dev:portal:proxy:serve
vars:
PORT: '{{.PROXY_PORT}}'
BACKEND_URL: 'http://localhost:{{.BACKEND_PORT}}'
EDITOR_DEV_URL: 'http://localhost:{{.EDITOR_PORT}}'
PORTAL_DEV_URL: 'http://localhost:{{.PORTAL_PORT}}'
dev:saas:
desc: "Start SaaS backend + frontend concurrently on free ports"
@@ -143,6 +228,24 @@ tasks:
- task: backend:build
- task: frontend:build
preview:portal:proxy:
desc: "Build + serve editor + portal on ONE origin + backend (prod-like auth test)"
vars:
PORTS:
sh: '{{if eq OS "windows"}}{{.FIND_FREE_PORT_PS}} 8080 3000{{else}}{{.FIND_FREE_PORT_SH}} 8080 3000{{end}}'
BACKEND_PORT: '{{index (splitList "\n" .PORTS) 0}}'
PROXY_PORT: '{{index (splitList "\n" .PORTS) 1}}'
deps:
- task: backend:dev
vars:
PORT: '{{.BACKEND_PORT}}'
SECURITY_ENABLELOGIN: "true"
POLICIES_ENABLED: "true"
- task: frontend:preview:portal:proxy
vars:
PORT: '{{.PROXY_PORT}}'
BACKEND_URL: 'http://localhost:{{.BACKEND_PORT}}'
# ============================================================
# Test
# ============================================================
@@ -185,16 +288,6 @@ tasks:
- task: frontend:format:check
- task: engine:format:check
# ============================================================
# Code generation
# ============================================================
tool-models:
desc: "Generate all API models from the Java OpenAPI spec"
cmds:
- task: frontend:tool-models
- task: engine:tool-models
# ============================================================
# Quality Gate
# ============================================================
-20
View File
@@ -80,18 +80,10 @@
"moduleName": ".*",
"moduleLicense": "Apache License Version 2.0"
},
{
"moduleName": ".*",
"moduleLicense": "Apache License version 2.0"
},
{
"moduleName": ".*",
"moduleLicense": "Apache License, Version 2.0"
},
{
"moduleName": ".*",
"moduleLicense": "Apache License, version 2.0"
},
{
"moduleName": ".*",
"moduleLicense": "The Apache License, Version 2.0"
@@ -116,10 +108,6 @@
"moduleName": ".*",
"moduleLicense": "Mozilla Public License 2.0 (MPL-2.0)"
},
{
"moduleName": ".*",
"moduleLicense": "Mozilla Public License Version 2.0"
},
{
"moduleName": ".*",
"moduleLicense": "CDDL+GPL License"
@@ -184,14 +172,6 @@
"moduleName": ".*",
"moduleLicense": "Eclipse Public License, Version 2.0"
},
{
"moduleName": ".*",
"moduleLicense": "EPL-2.0"
},
{
"moduleName": ".*",
"moduleLicense": "LGPL-2.1-only"
},
{
"moduleName": ".*",
"moduleLicense": "Ubuntu Font Licence 1.0"
@@ -132,7 +132,7 @@ public class AppConfig {
return true;
}
Path mountInfo = Path.of("/proc/1/mountinfo");
// this should always exist, if not some unknown use case
// this should always exist, if not some unknown usecase
if (!Files.exists(mountInfo)) {
return true;
}
@@ -18,7 +18,9 @@ import stirling.software.common.model.PdfMetadata;
@Service
public class PdfMetadataService {
/** ({@code {labels}}). Written by the classify-and-label tool. */
/**
* ({@code {category, docType, typeConfidence, tags}}). Written by the classify-and-tag tool.
*/
public static final String CLASSIFICATION_KEY = "StirlingPDFClassification";
private final ApplicationProperties applicationProperties;
@@ -57,16 +57,6 @@ public class RequestUriUtils {
return true;
}
// Admin portal SPA shell (mounted at /processor — must match the frontend
// PORTAL_BASENAME). Served publicly like the editor root so a direct nav /
// refresh to /processor loads the app (the JWT lives in localStorage, not a
// cookie, so the server can't authenticate the navigation itself). The
// portal gates access via its own auth gate + RequirePortalAccess, and its
// data APIs stay protected, so serving the shell pre-auth is safe.
if (normalizedUri.equals("/processor") || normalizedUri.startsWith("/processor/")) {
return true;
}
// Treat common static file extensions as static resources
return normalizedUri.endsWith(".svg")
|| normalizedUri.endsWith(".png")
@@ -73,14 +73,6 @@ class RequestUriUtilsTest {
assertTrue(RequestUriUtils.isStaticResource("/mobile-scanner"));
}
@Test
void testIsStaticResource_portalShell() {
// The admin portal SPA shell (/processor) is served pre-auth so it's directly navigable.
assertTrue(RequestUriUtils.isStaticResource("/processor"));
assertTrue(RequestUriUtils.isStaticResource("/processor/users"));
assertTrue(RequestUriUtils.isStaticResource("/app", "/app/processor"));
}
// --- isFrontendRoute tests ---
@Test
+1 -11
View File
@@ -175,14 +175,6 @@ springBoot {
// Frontend build tasks - only enabled with -PbuildWithFrontend=true
def buildWithFrontend = project.hasProperty('buildWithFrontend') && project.property('buildWithFrontend') == 'true'
def buildPrototypes = project.hasProperty('prototypesMode') && project.property('prototypesMode') == 'true'
// The admin portal ships as a lazy route inside the editor bundle (see
// proprietary/routes/adminRouteExtensions). -PbuildWithPortal=true includes that
// chunk via VITE_INCLUDE_PORTAL on the editor build; the deploy GHA sets it when
// the portal or AI layers change. Building the portal implies building the editor.
def buildWithPortal = project.hasProperty('buildWithPortal') && project.property('buildWithPortal') == 'true'
if (buildWithPortal) {
buildWithFrontend = true
}
// Workspace root holds package.json and node_modules (shared across editor /
// future portal). Editor-specific paths (src, public, dist, tauri) live one
// level deeper under frontend/editor/.
@@ -305,11 +297,9 @@ tasks.register('npmBuild', Exec) {
// Override VITE_API_BASE_URL to use relative paths for production builds
// This ensures JARs work regardless of how they're deployed (direct, proxied, etc.)
environment 'VITE_API_BASE_URL', '/'
// Include the admin portal's lazy route/chunk in the editor build when requested.
environment 'VITE_INCLUDE_PORTAL', (buildWithPortal ? 'true' : 'false')
doFirst {
println "Building editor frontend application for production (mode=${frontendMode}, VITE_API_BASE_URL=/, portal=${buildWithPortal})"
println "Building editor frontend application for production (mode=${frontendMode}, VITE_API_BASE_URL=/)"
}
}
@@ -1,7 +1,5 @@
package stirling.software.SPDF.model.api.general;
import com.fasterxml.jackson.annotation.JsonProperty;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
@@ -19,8 +17,20 @@ public class PosterPdfRequest extends PDFFile {
allowableValues = {"A4", "Letter", "A3", "A5", "Legal", "Tabloid"})
private String pageSize = "A4";
@Schema(
description = "Horizontal decimation factor (how many columns to split into)",
requiredMode = Schema.RequiredMode.NOT_REQUIRED,
defaultValue = "2",
minimum = "1",
maximum = "10")
private int xFactor = 2;
@Schema(
description = "Vertical decimation factor (how many rows to split into)",
requiredMode = Schema.RequiredMode.NOT_REQUIRED,
defaultValue = "2",
minimum = "1",
maximum = "10")
private int yFactor = 2;
@Schema(
@@ -28,36 +38,4 @@ public class PosterPdfRequest extends PDFFile {
requiredMode = Schema.RequiredMode.NOT_REQUIRED,
defaultValue = "false")
private boolean rightToLeft = false;
@JsonProperty("xFactor")
@Schema(
description = "Horizontal decimation factor (how many columns to split into)",
requiredMode = Schema.RequiredMode.NOT_REQUIRED,
defaultValue = "2",
minimum = "1",
maximum = "10")
public int getXFactor() {
return xFactor;
}
@JsonProperty("xFactor")
public void setXFactor(int xFactor) {
this.xFactor = xFactor;
}
@JsonProperty("yFactor")
@Schema(
description = "Vertical decimation factor (how many rows to split into)",
requiredMode = Schema.RequiredMode.NOT_REQUIRED,
defaultValue = "2",
minimum = "1",
maximum = "10")
public int getYFactor() {
return yFactor;
}
@JsonProperty("yFactor")
public void setYFactor(int yFactor) {
this.yFactor = yFactor;
}
}
@@ -29,8 +29,7 @@ public class AddPasswordRequest extends PDFFile {
description = "The length of the encryption key",
type = "integer",
allowableValues = {"40", "128", "256"},
requiredMode = Schema.RequiredMode.NOT_REQUIRED,
defaultValue = "256")
requiredMode = Schema.RequiredMode.REQUIRED)
private int keyLength = 256;
@Schema(description = "Whether document assembly is prevented", defaultValue = "false")
@@ -4,9 +4,7 @@ import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import stirling.software.proprietary.access.service.DefaultPrincipalResolver;
import stirling.software.proprietary.access.service.DefaultTeamLeadLookup;
import stirling.software.proprietary.access.service.PrincipalResolver;
import stirling.software.proprietary.access.service.TeamLeadLookup;
/** Access-layer bean wiring. */
@@ -19,11 +17,4 @@ public class AccessConfig {
TeamLeadLookup defaultTeamLeadLookup() {
return new DefaultTeamLeadLookup();
}
/** USER/TEAM projection unless another bean is defined (e.g. the saas resolver). */
@Bean
@ConditionalOnMissingBean(PrincipalResolver.class)
PrincipalResolver defaultPrincipalResolver() {
return new DefaultPrincipalResolver();
}
}
@@ -25,9 +25,7 @@ import stirling.software.proprietary.access.model.PrincipalType;
import stirling.software.proprietary.access.model.ResourceGrant;
import stirling.software.proprietary.access.model.ResourceType;
import stirling.software.proprietary.access.service.ResourceAccessService;
import stirling.software.proprietary.security.database.repository.UserRepository;
import stirling.software.proprietary.security.model.User;
import stirling.software.proprietary.security.repository.TeamRepository;
/** Admin endpoints to grant/revoke access to gated resources (the portal, integration configs). */
@RestController
@@ -38,8 +36,6 @@ import stirling.software.proprietary.security.repository.TeamRepository;
public class ResourceGrantController {
private final ResourceAccessService accessService;
private final UserRepository userRepository;
private final TeamRepository teamRepository;
@GetMapping("/grants")
public ResponseEntity<?> list(
@@ -49,14 +45,6 @@ public class ResourceGrantController {
return ResponseEntity.ok(grants.stream().map(this::toDto).toList());
}
@GetMapping("/grants/by-principal")
public ResponseEntity<?> listByPrincipal(
@RequestParam PrincipalType principalType, @RequestParam Long principalId) {
List<ResourceGrant> grants =
accessService.listGrantsForPrincipal(principalType, principalId);
return ResponseEntity.ok(grants.stream().map(this::toDto).toList());
}
@PostMapping("/grants")
public ResponseEntity<?> create(
@RequestBody GrantRequest request, @AuthenticationPrincipal User admin) {
@@ -69,26 +57,17 @@ public class ResourceGrantController {
"error",
"resourceType, principalType and principalId are required"));
}
// PORTAL is a singleton (empty resourceId); every other type must name a resource.
boolean portal = request.resourceType() == ResourceType.PORTAL;
if (!portal && (request.resourceId() == null || request.resourceId().isBlank())) {
return ResponseEntity.badRequest()
.body(Map.of("error", "resourceId is required for " + request.resourceType()));
}
Long principalId = request.principalId();
String principalError = validatePrincipalExists(request.principalType(), principalId);
if (principalError != null) {
return ResponseEntity.badRequest().body(Map.of("error", principalError));
}
AccessPermission permission =
request.permission() == null ? AccessPermission.USE : request.permission();
String resourceId = portal ? "" : request.resourceId();
// PORTAL is a singleton resource; its grants always target the whole type.
String resourceId =
request.resourceType() == ResourceType.PORTAL ? "" : request.resourceId();
ResourceGrant grant =
accessService.grant(
request.resourceType(),
resourceId,
request.principalType(),
principalId,
request.principalId(),
permission,
admin);
return ResponseEntity.ok(toDto(grant));
@@ -100,14 +79,6 @@ public class ResourceGrantController {
return ResponseEntity.ok(Map.of("message", "Grant revoked"));
}
// Rejects grants to nonexistent principals (dead rows otherwise).
private String validatePrincipalExists(PrincipalType type, Long id) {
return switch (type) {
case USER -> userRepository.existsById(id) ? null : "User " + id + " does not exist";
case TEAM -> teamRepository.existsById(id) ? null : "Team " + id + " does not exist";
};
}
private Map<String, Object> toDto(ResourceGrant g) {
Map<String, Object> m = new HashMap<>();
m.put("id", g.getId());
@@ -54,15 +54,4 @@ public abstract class OwnedResource {
public Long getOwnerTeamId() {
return ownerTeam != null ? ownerTeam.getId() : null;
}
/** Owner as a principal ref; null when server-owned (admin-only ownership). */
public PrincipalRef getOwnerRef() {
if (getOwnerUserId() != null) {
return PrincipalRef.user(getOwnerUserId());
}
if (getOwnerTeamId() != null) {
return PrincipalRef.team(getOwnerTeamId());
}
return null;
}
}
@@ -1,20 +0,0 @@
package stirling.software.proprietary.access.model;
import java.util.Locale;
/** A (type, id) principal pair; the atom grants and ownership are expressed in. */
public record PrincipalRef(PrincipalType type, Long id) {
public static PrincipalRef user(Long id) {
return new PrincipalRef(PrincipalType.USER, id);
}
public static PrincipalRef team(Long id) {
return new PrincipalRef(PrincipalType.TEAM, id);
}
/** Canonical engine wire form, e.g. "user:12". */
public String token() {
return type.name().toLowerCase(Locale.ROOT) + ":" + id;
}
}
@@ -1,6 +1,6 @@
package stirling.software.proprietary.access.model;
/** Who a {@link ResourceGrant} is granted to. */
/** Who a {@link ResourceGrant} is granted to. Org-wide access is expressed via default policy. */
public enum PrincipalType {
USER,
TEAM
@@ -2,8 +2,7 @@ package stirling.software.proprietary.access.model;
/** Types of resources whose access can be gated by {@link ResourceGrant}. */
public enum ResourceType {
// The admin portal / processor (frontend/editor/src/portal). Singleton resource (empty
// resourceId).
// The admin portal / processor (frontend/portal). Singleton resource (empty resourceId).
PORTAL,
// A stored S3/MCP/API integration configuration.
INTEGRATION_CONFIG
@@ -3,15 +3,11 @@ package stirling.software.proprietary.access.repository;
import java.util.List;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Modifying;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import org.springframework.stereotype.Repository;
import stirling.software.proprietary.access.model.PrincipalType;
import stirling.software.proprietary.access.model.ResourceGrant;
import stirling.software.proprietary.access.model.ResourceType;
import stirling.software.proprietary.security.model.User;
@Repository
public interface ResourceGrantRepository extends JpaRepository<ResourceGrant, Long> {
@@ -22,20 +18,8 @@ public interface ResourceGrantRepository extends JpaRepository<ResourceGrant, Lo
List<ResourceGrant> findByResourceTypeAndPrincipalTypeAndPrincipalId(
ResourceType resourceType, PrincipalType principalType, Long principalId);
/** All grants held by a principal, across resource types (for the manage-access view). */
List<ResourceGrant> findByPrincipalTypeAndPrincipalId(
PrincipalType principalType, Long principalId);
void deleteByResourceTypeAndResourceId(ResourceType resourceType, String resourceId);
/** Removes every grant held by a principal; used when the user/team behind it is deleted. */
void deleteByPrincipalTypeAndPrincipalId(PrincipalType principalType, Long principalId);
// Detach issued grants so deleting the granting user does not hit the FK.
@Modifying
@Query("update ResourceGrant g set g.grantedBy = null where g.grantedBy = :user")
void clearGrantedBy(@Param("user") User user);
boolean existsByResourceTypeAndResourceIdAndPrincipalTypeAndPrincipalId(
ResourceType resourceType,
String resourceId,
@@ -11,12 +11,7 @@ import stirling.software.proprietary.access.service.ResourceAccessService;
import stirling.software.proprietary.security.model.User;
import stirling.software.proprietary.security.service.UserService;
/**
* {@code @PreAuthorize} bean for portal-access checks. Active in self-hosted and saas. Convention:
* every portal-exclusive endpoint is gated with
* {@code @PreAuthorize("@resourceAccess.canUsePortal()")}; endpoints shared with the editor (e.g.
* the policies API) must NOT be.
*/
/** {@code @PreAuthorize} bean for portal-access checks. Active in self-hosted and saas. */
@Component("resourceAccess")
@RequiredArgsConstructor
public class ResourceAccessSecurity {
@@ -1,32 +0,0 @@
package stirling.software.proprietary.access.service;
import java.util.HashSet;
import java.util.Set;
import stirling.software.proprietary.access.model.PrincipalRef;
import stirling.software.proprietary.security.model.User;
/**
* Self-hosted projection: the user and their team. One deployment = one org, so ORG_ALL is open.
*/
public class DefaultPrincipalResolver implements PrincipalResolver {
@Override
public Set<PrincipalRef> principalsOf(User user) {
if (user == null) {
return Set.of();
}
Set<PrincipalRef> principals = new HashSet<>();
principals.add(PrincipalRef.user(user.getId()));
if (user.getTeam() != null) {
principals.add(PrincipalRef.team(user.getTeam().getId()));
}
return principals;
}
// Self-hosted is a single deployment-wide org, so ORG_ALL admits every authenticated user.
@Override
public boolean allowsDeploymentWideAccess() {
return true;
}
}
@@ -1,33 +0,0 @@
package stirling.software.proprietary.access.service;
import org.springframework.stereotype.Component;
import lombok.RequiredArgsConstructor;
import stirling.software.common.model.enumeration.TeamRole;
import stirling.software.proprietary.security.model.User;
import stirling.software.proprietary.security.repository.TeamMembershipRepository;
/** Real lookup backed by team_memberships LEADER rows; wins over the no-op default bean. */
@Component
@RequiredArgsConstructor
public class MembershipTeamLeadLookup implements TeamLeadLookup {
private final TeamMembershipRepository memberships;
@Override
public boolean isAnyTeamLeader(User user) {
return user != null
&& user.getId() != null
&& memberships.existsByUserIdAndRole(user.getId(), TeamRole.LEADER);
}
@Override
public boolean isLeaderOfTeam(User user, Long teamId) {
return user != null
&& user.getId() != null
&& teamId != null
&& memberships.existsByTeamIdAndUserIdAndRole(
teamId, user.getId(), TeamRole.LEADER);
}
}
@@ -36,19 +36,15 @@ public class OwnershipService {
return accessService.canUseResource(
type,
String.valueOf(resource.getId()),
resource.getOwnerRef(),
resource.getOwnerUserId(),
resource.getDefaultAccess(),
user);
}
/** Whether the user may manage the resource. */
public boolean canManage(ResourceType type, OwnedResource resource, User user) {
// Disabled resources bypass grants for MANAGE too: admin/owner only.
if (!resource.isEnabled()) {
return isAdmin(user) || isOwner(resource, user);
}
return accessService.canManageResource(
type, String.valueOf(resource.getId()), resource.getOwnerRef(), user);
type, String.valueOf(resource.getId()), resource.getOwnerUserId(), user);
}
/**
@@ -1,28 +0,0 @@
package stirling.software.proprietary.access.service;
import java.util.Set;
import java.util.stream.Collectors;
import stirling.software.proprietary.access.model.PrincipalRef;
import stirling.software.proprietary.security.model.User;
/** Projects a user onto the set of principals they act as. */
public interface PrincipalResolver {
/** Every principal the user acts as; empty for a null user. */
Set<PrincipalRef> principalsOf(User user);
/**
* Whether this deployment treats every authenticated user as one org, so the {@code ORG_ALL}
* default policy admits anyone. Self-hosted: true. Multi-tenant saas: false, so an {@code
* ORG_ALL} resource can't leak across tenants. Defaults to false (deny) for safety.
*/
default boolean allowsDeploymentWideAccess() {
return false;
}
/** Canonical wire tokens for the engine, e.g. "user:12". */
default Set<String> principalTokens(User user) {
return principalsOf(user).stream().map(PrincipalRef::token).collect(Collectors.toSet());
}
}
@@ -14,7 +14,6 @@ import lombok.extern.slf4j.Slf4j;
import stirling.software.common.model.enumeration.Role;
import stirling.software.proprietary.access.model.AccessPermission;
import stirling.software.proprietary.access.model.DefaultAccessPolicy;
import stirling.software.proprietary.access.model.PrincipalRef;
import stirling.software.proprietary.access.model.PrincipalType;
import stirling.software.proprietary.access.model.ResourceGrant;
import stirling.software.proprietary.access.model.ResourceType;
@@ -30,7 +29,6 @@ public class ResourceAccessService {
private final ResourceGrantRepository grantRepository;
private final TeamLeadLookup teamLeadLookup;
private final PrincipalResolver principalResolver;
@Value("${security.portal.defaultAccess:ADMINS_AND_TEAM_LEADS}")
private DefaultAccessPolicy portalDefaultPolicy;
@@ -46,28 +44,28 @@ public class ResourceAccessService {
public boolean canUseResource(
ResourceType type,
String resourceId,
PrincipalRef owner,
Long ownerUserId,
DefaultAccessPolicy defaultPolicy,
User user) {
if (user == null) {
return false;
}
if (isOwner(owner, user) || isAdmin(user)) {
if (isOwner(ownerUserId, user) || isAdmin(user)) {
return true;
}
if (hasGrant(type, normalize(resourceId), user, AccessPermission.USE)) {
return true;
}
return matchesDefault(defaultPolicy, owner, user);
return matchesDefault(defaultPolicy, user);
}
/** Whether the user may manage (edit/delete/share) a resource. No default-policy fallback. */
public boolean canManageResource(
ResourceType type, String resourceId, PrincipalRef owner, User user) {
ResourceType type, String resourceId, Long ownerUserId, User user) {
if (user == null) {
return false;
}
if (isOwner(owner, user) || isAdmin(user)) {
if (isOwner(ownerUserId, user) || isAdmin(user)) {
return true;
}
return hasGrant(type, normalize(resourceId), user, AccessPermission.MANAGE);
@@ -112,22 +110,21 @@ public class ResourceAccessService {
return grantRepository.findByResourceTypeAndResourceId(type, normalize(resourceId));
}
/** Every grant a principal holds, for the per-user/per-team manage-access view. */
public List<ResourceGrant> listGrantsForPrincipal(
PrincipalType principalType, Long principalId) {
return grantRepository.findByPrincipalTypeAndPrincipalId(principalType, principalId);
}
/** Resource ids of the given type that any of the user's principals holds a grant on. */
/** Resource ids of the given type that this user (or their team) holds any grant on. */
public Set<String> grantedResourceIds(ResourceType type, User user) {
if (user == null) {
return Set.of();
}
Set<String> ids = new HashSet<>();
for (PrincipalRef principal : principalResolver.principalsOf(user)) {
for (ResourceGrant g :
grantRepository.findByResourceTypeAndPrincipalTypeAndPrincipalId(
type, PrincipalType.USER, user.getId())) {
ids.add(g.getResourceId());
}
if (user.getTeam() != null) {
for (ResourceGrant g :
grantRepository.findByResourceTypeAndPrincipalTypeAndPrincipalId(
type, principal.type(), principal.id())) {
type, PrincipalType.TEAM, user.getTeam().getId())) {
ids.add(g.getResourceId());
}
}
@@ -138,12 +135,18 @@ public class ResourceAccessService {
private boolean hasGrant(
ResourceType type, String resourceId, User user, AccessPermission required) {
Set<PrincipalRef> principals = principalResolver.principalsOf(user);
Long teamId = user.getTeam() != null ? user.getTeam().getId() : null;
for (ResourceGrant g : grantRepository.findByResourceTypeAndResourceId(type, resourceId)) {
if (!permissionSatisfies(g.getPermission(), required)) {
continue;
}
if (principals.contains(new PrincipalRef(g.getPrincipalType(), g.getPrincipalId()))) {
if (g.getPrincipalType() == PrincipalType.USER
&& g.getPrincipalId().equals(user.getId())) {
return true;
}
if (g.getPrincipalType() == PrincipalType.TEAM
&& teamId != null
&& g.getPrincipalId().equals(teamId)) {
return true;
}
}
@@ -158,40 +161,20 @@ public class ResourceAccessService {
return held == AccessPermission.MANAGE;
}
private boolean matchesDefault(DefaultAccessPolicy policy, PrincipalRef owner, User user) {
private boolean matchesDefault(DefaultAccessPolicy policy, User user) {
if (policy == null) {
return false;
}
return switch (policy) {
// Deployment-wide only where the resolver treats everyone as one org; saas resolvers
// return false, so ORG_ALL cannot leak a tenant's resource to another tenant's users.
case ORG_ALL -> principalResolver.allowsDeploymentWideAccess();
// Admins already pass above; only team leads here, scoped to the owning team.
case ADMINS_AND_TEAM_LEADS -> matchesTeamLeadDefault(owner, user);
case ORG_ALL -> true;
// Admins already pass above; only team leads here.
case ADMINS_AND_TEAM_LEADS -> teamLeadLookup.isAnyTeamLeader(user);
case EXPLICIT_ONLY -> false;
};
}
// Portal (no owner) admits any team lead; a team-owned resource admits only that team's
// leads; a user-owned resource admits no extra leads.
private boolean matchesTeamLeadDefault(PrincipalRef owner, User user) {
if (owner == null) {
return teamLeadLookup.isAnyTeamLeader(user);
}
return owner.type() == PrincipalType.TEAM
&& owner.id() != null
&& teamLeadLookup.isLeaderOfTeam(user, owner.id());
}
// Team owners are the owning team's leaders; plain members are not.
private boolean isOwner(PrincipalRef owner, User user) {
if (owner == null || owner.id() == null) {
return false;
}
return switch (owner.type()) {
case USER -> owner.id().equals(user.getId());
case TEAM -> teamLeadLookup.isLeaderOfTeam(user, owner.id());
};
private boolean isOwner(Long ownerUserId, User user) {
return ownerUserId != null && ownerUserId.equals(user.getId());
}
private boolean isAdmin(User user) {
@@ -18,26 +18,15 @@ public class SecretMasker {
// Cap recursion so a pathologically nested payload cannot overflow the stack.
private static final int MAX_DEPTH = 32;
// Key-name substrings that mark a value sensitive. Over-masking a non-secret is
// safe; leaking a secret is not, so this errs broad - but a per-type schema
// whitelist would be a stronger boundary for free-form config (follow-up).
private static final Set<String> SENSITIVE_HINTS =
Set.of(
"secret",
"password",
"passphrase",
"pwd",
"token",
"apikey",
"accesskey",
"credential",
"privatekey",
"authorization",
"cookie",
"session",
"connectionstring",
"bearer",
"signature");
"privatekey");
/** Replace sensitive values with the mask (recursively) for safe display. */
public Map<String, Object> mask(Map<String, Object> config) {
@@ -84,24 +73,18 @@ public class SecretMasker {
private Map<String, Object> merge(
Map<String, Object> stored, Map<String, Object> incoming, int depth) {
// Replace semantics (PUT): the result is the incoming document, except a redacted secret
// keeps its stored value. Keys absent from incoming are dropped, so edits can remove them.
Map<String, Object> out = new LinkedHashMap<>();
Map<String, Object> out = new LinkedHashMap<>(stored);
for (Map.Entry<String, Object> e : incoming.entrySet()) {
String key = e.getKey();
Object value = e.getValue();
if (isSensitive(key)) {
if (isRedacted(value, depth)) {
if (stored.containsKey(key)) {
out.put(key, stored.get(key)); // keep the stored secret
}
} else {
if (!isRedacted(value, depth)) {
out.put(key, value); // a real new secret replaces the stored one
}
continue;
continue; // redacted (blank / mask) -> keep stored
}
if (depth < MAX_DEPTH
&& stored.get(key) instanceof Map<?, ?> s
&& out.get(key) instanceof Map<?, ?> s
&& value instanceof Map<?, ?> i) {
out.put(key, merge(castMap(s), castMap(i), depth + 1));
} else {
@@ -6,7 +6,6 @@ import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.time.Duration;
import java.time.LocalDateTime;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
@@ -15,31 +14,25 @@ import org.springframework.stereotype.Service;
import lombok.extern.slf4j.Slf4j;
import stirling.software.proprietary.billing.UnitCalcPolicy;
import tools.jackson.databind.JsonNode;
import tools.jackson.databind.ObjectMapper;
import tools.jackson.databind.node.ObjectNode;
/**
* Outbound calls from a self-hosted instance to its linked SaaS backend (combined-billing "Mode
* A").
*
* <p>Calls:
* <p>Two calls:
*
* <ul>
* <li>{@link #register} — relays the admin's short-lived Supabase JWT to {@code POST
* /api/v1/account-link/register}; the SaaS side mints + returns a device credential.
* <li>{@link #fetchEntitlement} — authenticates with the stored device credential against {@code
* GET /api/v1/instance/entitlement}; what the local gate consults.
* <li>{@link #reportUsage} — daily usage sync ({@code POST /api/v1/instance/sync}); reports
* cumulative units and returns the refreshed entitlement.
* <li>{@link #revokeSelf} — self-revokes the credential on local unlink ({@code POST
* /api/v1/instance/revoke-self}).
* </ul>
*
* <p>Uses {@code java.net.http.HttpClient} (the established self-hosted outbound pattern; see
* {@code AiEngineClient}); base URL + client are injectable so tests can stub SaaS.
* <p>Uses {@code java.net.http.HttpClient} (the established self-hosted outbound pattern, see
* {@code AiEngineClient}). The base URL + client are injectable so tests can stub the SaaS
* endpoint.
*/
@Slf4j
@Service
@@ -93,9 +86,11 @@ public class AccountLinkClient {
}
/**
* Authoritative deny (401/403) — the device credential is revoked or invalid. Unlike a
* transport/server failure (which returns {@code null} and fails open), the cache must BLOCK on
* this. Unchecked so it propagates through {@link #fetchEntitlement}'s transport try/catch.
* Authoritative deny (401/403) from the entitlement endpoint — the device credential is revoked
* or invalid. Distinct from a transport/server failure (which returns {@code null} and fails
* open): the cache must BLOCK billable work on this rather than serve a stale entitled
* snapshot. Unchecked so it propagates cleanly through {@link #fetchEntitlement}'s transport
* try/catch.
*/
public static final class RevokedException extends RuntimeException {
private final int status;
@@ -147,9 +142,11 @@ public class AccountLinkClient {
}
/**
* Revokes this instance's own credential on the SaaS side, authenticated by that credential.
* Best-effort: returns {@code false} if SaaS is unreachable or rejects, so the caller (local
* unlink) can still clear locally and log the orphan for follow-up. Idempotent on SaaS.
* Revokes this instance's own credential on the SaaS side ({@code POST
* /api/v1/instance/revoke-self}), authenticated by the device credential — a credential is
* allowed to revoke its own identity. Best-effort: returns {@code false} if SaaS is unreachable
* or rejects the call, so the caller (local unlink) can still clear locally and log the orphan
* row for follow-up. Idempotent on SaaS (already-revoked → still 204).
*/
public boolean revokeSelf(String deviceId, String deviceSecret) {
try {
@@ -221,63 +218,6 @@ public class AccountLinkClient {
}
}
/**
* Reports the period's cumulative per-category units to {@code POST /api/v1/instance/sync} and
* returns the fresh entitlement in the same reply — one round-trip both reports and refreshes.
* SaaS bills the delta against its last-seen cumulative, so resending the same totals is
* idempotent. Same three outcomes as {@link #fetchEntitlement}; on {@code null} the caller must
* not advance its last-synced markers so the usage retries next sync.
*/
public InstanceEntitlement reportUsage(
String deviceId,
String deviceSecret,
long syncSeq,
LocalDateTime periodStart,
long apiUnits,
long aiUnits,
long automationUnits) {
HttpResponse<String> response;
try {
ObjectNode root = mapper.createObjectNode();
root.put("syncSeq", syncSeq);
// Explicit ISO-8601 string so it round-trips regardless of the mapper's time config.
root.put("periodStart", periodStart.toString());
ObjectNode units = root.putObject("cumulativeUnits");
units.put("api", apiUnits);
units.put("ai", aiUnits);
units.put("automation", automationUnits);
String body = mapper.writeValueAsString(root);
HttpRequest request =
HttpRequest.newBuilder()
.uri(uri("/api/v1/instance/sync"))
.header(HEADER_DEVICE_ID, deviceId)
.header(HEADER_DEVICE_SECRET, deviceSecret)
.header("Content-Type", "application/json")
.header("Accept", "application/json")
.timeout(timeout())
.POST(HttpRequest.BodyPublishers.ofString(body))
.build();
response = send(request);
} catch (Exception e) {
log.debug("Usage sync failed: {}", e.getMessage());
return null;
}
int status = response.statusCode();
if (status == 401 || status == 403) {
throw new RevokedException(status);
}
if (status / 100 != 2) {
log.debug("Usage sync returned HTTP {}", status);
return null;
}
try {
return parseEntitlement(response.body());
} catch (IOException e) {
log.debug("Usage sync parse failed: {}", e.getMessage());
return null;
}
}
private InstanceEntitlement parseEntitlement(String body) throws IOException {
JsonNode root = mapper.readTree(body);
boolean subscribed = root.path("subscribed").asBoolean(false);
@@ -286,45 +226,7 @@ public class AccountLinkClient {
Long periodCap =
root.hasNonNull("periodCapUnits") ? root.get("periodCapUnits").asLong() : null;
EntitlementState state = mapState(root.path("state").asText(null));
return new InstanceEntitlement(
subscribed,
freeRemaining,
periodSpend,
periodCap,
state,
parseUnitCalcPolicy(root),
parseDateTime(root, "periodStart"),
parseDateTime(root, "periodEnd"));
}
/** Parses the nested unit-calc policy; null if absent or any knob is invalid (e.g. zero). */
private static UnitCalcPolicy parseUnitCalcPolicy(JsonNode root) {
if (!root.hasNonNull("unitCalcPolicy")) {
return null;
}
JsonNode node = root.get("unitCalcPolicy");
try {
return new UnitCalcPolicy(
node.path("docPagesPerUnit").asInt(),
node.path("docBytesPerUnit").asLong(),
node.path("minChargeUnits").asInt(),
node.path("fileUnitCap").asInt());
} catch (RuntimeException e) {
// Malformed policy → degrade to "none" rather than fail the whole entitlement parse.
return null;
}
}
/** ISO date-time field → LocalDateTime; null if absent or unparseable. */
private static LocalDateTime parseDateTime(JsonNode root, String field) {
if (!root.hasNonNull(field)) {
return null;
}
try {
return LocalDateTime.parse(root.get(field).asText(null));
} catch (RuntimeException e) {
return null;
}
return new InstanceEntitlement(subscribed, freeRemaining, periodSpend, periodCap, state);
}
/** Maps the SaaS state string to our coarse enum; unrecognised → UNKNOWN. */
@@ -2,7 +2,6 @@ package stirling.software.proprietary.accountlink;
import java.io.IOException;
import org.springframework.beans.factory.ObjectProvider;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.context.annotation.Profile;
import org.springframework.http.HttpStatus;
@@ -24,9 +23,7 @@ import lombok.extern.slf4j.Slf4j;
* <p>The portal (served from this same origin, admin authenticated by the existing self-hosted
* security chain) calls these. {@code POST /link} relays the admin's Supabase JWT to the SaaS
* backend, which mints + returns a device credential we store locally. {@code GET /status} backs
* the portal's link card; {@code GET /usage} exposes locally-accrued unsynced usage the portal adds
* to SaaS-synced spend; {@code POST /sync-now} forces an immediate usage sync (ops "reconcile now"
* / test aid).
* the portal's link card.
*
* <p>Admin-only, {@code @Profile("!saas")}, gated behind {@code
* stirling.billing.account-link.enabled} — off → bean absent → 404.
@@ -41,17 +38,9 @@ import lombok.extern.slf4j.Slf4j;
public class AccountLinkController {
private final AccountLinkService service;
private final LocalUsageService localUsageService;
// Present only when metering is on (its own flag); absent → /sync-now reports 409.
private final ObjectProvider<UsageSyncService> syncServiceProvider;
public AccountLinkController(
AccountLinkService service,
LocalUsageService localUsageService,
ObjectProvider<UsageSyncService> syncServiceProvider) {
public AccountLinkController(AccountLinkService service) {
this.service = service;
this.localUsageService = localUsageService;
this.syncServiceProvider = syncServiceProvider;
}
/** {@code supabaseJwt} is the admin's short-lived token the portal already holds. */
@@ -96,29 +85,4 @@ public class AccountLinkController {
service.unlink();
return ResponseEntity.noContent().build();
}
/**
* Locally accrued usage not yet reported to SaaS — the portal adds it to the SaaS-synced spend
* so "current usage" includes work done since the last daily sync.
*/
@GetMapping("/usage")
public ResponseEntity<LocalUsageService.LocalUsage> usage() {
return ResponseEntity.ok(localUsageService.currentPeriodUnsynced());
}
/**
* Forces an immediate usage sync to SaaS — the same work the daily scheduler does. An admin
* "reconcile now" action (and a test aid so you don't wait on the scheduler). Idempotent:
* re-reports the current cumulative, so a repeat trigger bills nothing. {@code 204} once run;
* {@code 409} when metering is off (the sync bean is absent).
*/
@PostMapping("/sync-now")
public ResponseEntity<Void> syncNow() {
UsageSyncService sync = syncServiceProvider.getIfAvailable();
if (sync == null) {
return ResponseEntity.status(HttpStatus.CONFLICT).build();
}
sync.syncNow();
return ResponseEntity.noContent().build();
}
}
@@ -1,7 +1,5 @@
package stirling.software.proprietary.accountlink;
import java.time.Duration;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;
@@ -38,39 +36,4 @@ public class AccountLinkProperties {
/** Connect/read timeout for the outbound SaaS calls. */
private int requestTimeoutSeconds = 10;
/** Phase 2 usage metering + daily sync. Keyed under {@code …account-link.metering.*}. */
private final Metering metering = new Metering();
/**
* Dedicated billing switch, <b>separate</b> from {@link #enabled} so the link plumbing can be
* enabled (e.g. to test linking) without ever turning on real usage metering, reporting, or cap
* enforcement. Both default off; metering requires the master flag too. This is the production
* safety key — flipping it on is what actually bills linked instances.
*/
@Getter
@Setter
public static class Metering {
/** Turns on usage metering, the daily sync, and cap enforcement. Default off. */
private boolean enabled = false;
/**
* How often the instance syncs usage + refreshes entitlement (matches the licence sync).
*/
private int syncIntervalHours = 24;
/**
* Block billable work after this many days with no successful sync (fail-open → closed).
*/
private int graceDays = 3;
/**
* Dedup window for identical input sets. A re-run of the same inputs within this window is
* treated as workflow chaining and not re-charged; the same inputs run again after it are
* billed afresh. Mirrors the cloud's {@code payg.lineage.workflow-window} so the same op
* costs the same on the instance and in the cloud.
*/
private Duration workflowWindow = Duration.ofMinutes(5);
}
}
@@ -1,46 +0,0 @@
package stirling.software.proprietary.accountlink;
import java.time.LocalDateTime;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
/**
* Singleton row holding this instance's daily-sync bookkeeping (combined-billing "Mode A").
*
* <p>{@link #lastSyncSeq} is reserved (incremented + persisted) <em>before</em> each report so it
* is strictly monotonic across restarts and partial failures — SaaS dedups replays by comparing it,
* so a never-decreasing seq is the contract. {@link #lastSuccessAt} is the wall-clock of the last
* sync SaaS accepted and drives the fail-open→closed grace window.
*
* <p>Auto-created by Hibernate ({@code ddl-auto=update}); written only by the flag-gated sync.
*/
@Entity
@Table(name = "account_link_sync_state")
@Getter
@Setter
@NoArgsConstructor
public class AccountLinkSyncState {
/** One instance links to one team → one bookkeeping row. */
public static final long SINGLETON_ID = 1L;
@Id private Long id;
// columnDefinition default keeps the ddl-auto ADD COLUMN safe on a populated external Postgres.
@Column(
name = "last_sync_seq",
nullable = false,
columnDefinition = "bigint not null default 0")
private long lastSyncSeq;
/** Null until the first sync SaaS accepts. */
@Column(name = "last_success_at")
private LocalDateTime lastSuccessAt;
}
@@ -1,6 +0,0 @@
package stirling.software.proprietary.accountlink;
import org.springframework.data.jpa.repository.JpaRepository;
/** Persistence for the singleton {@link AccountLinkSyncState} (combined-billing "Mode A"). */
public interface AccountLinkSyncStateRepository extends JpaRepository<AccountLinkSyncState, Long> {}
@@ -3,26 +3,14 @@ package stirling.software.proprietary.accountlink;
import jakarta.servlet.http.HttpServletRequest;
import stirling.software.common.service.InternalApiClient;
import stirling.software.proprietary.billing.BillingCategory;
import stirling.software.proprietary.billing.BillingCategoryClassifier;
/**
* Buckets a request into a {@link BillingCategory} for the account-link gate + meter, using only
* HTTP-level signals (no dependency on the saas module):
* Classifies a request as <b>billable</b> (AI / automation) or free (a manual tool).
*
* <ul>
* <li><b>AUTOMATION</b> — the automation marker header ({@link
* InternalApiClient#AUTOMATION_HEADER}, set on pipeline / workflow / policy sub-steps);
* <li><b>AI</b> — the AI surface ({@code /api/v1/ai/**});
* <li><b>API</b> — an API-key authenticated tool call;
* <li><b>BYPASSED</b> — a manual interactive tool call, never billed.
* </ul>
*
* <p>Same precedence as the SaaS classifier (AUTOMATION → AI → API → BYPASSED) via the shared
* {@link BillingCategoryClassifier}; the AI signal is resolved by path prefix rather than the
* saas-only {@code @RequiresFeature} annotation. The {@code apiKey} signal is supplied by the
* caller (resolved from the security context), so this class stays free of any security-type
* dependency.
* <p>Mirrors the saas billing categorisation at a coarse level, without depending on the saas
* module: billable = the AI surface ({@code /api/v1/ai/**}) or any request carrying the automation
* marker header ({@link InternalApiClient#AUTOMATION_HEADER}, set on pipeline / workflow / policy
* sub-steps). Everything else — interactive manual PDF tools — is always free.
*/
public final class BillableOperationClassifier {
@@ -30,22 +18,16 @@ public final class BillableOperationClassifier {
private BillableOperationClassifier() {}
/**
* @param apiKey whether the request authenticated via an API key (an {@code
* ApiKeyAuthenticationToken} principal), resolved by the caller from the security context.
*/
public static BillingCategory categorize(HttpServletRequest request, boolean apiKey) {
boolean automation = request.getHeader(InternalApiClient.AUTOMATION_HEADER) != null;
return BillingCategoryClassifier.classify(automation, isAiSurface(request), apiKey);
}
private static boolean isAiSurface(HttpServletRequest request) {
public static boolean isBillable(HttpServletRequest request) {
if (request.getHeader(InternalApiClient.AUTOMATION_HEADER) != null) {
return true;
}
String uri = request.getRequestURI();
if (uri == null) {
return false;
}
// Prefix-match the AI surface (not a loose substring contains), stripping a deployment
// context path so /<ctx>/api/v1/ai/** still classifies as AI.
// context path so /<ctx>/api/v1/ai/** still classifies as billable.
String ctx = request.getContextPath();
String path =
ctx != null && !ctx.isEmpty() && uri.startsWith(ctx)
@@ -12,13 +12,18 @@ import org.springframework.stereotype.Service;
import lombok.extern.slf4j.Slf4j;
/**
* Caches the linked team's entitlement so the request-time gate needn't call SaaS on every billable
* request. Single-slot (one instance = one linked team), TTL-based.
* Caches the linked team's entitlement so the request-time gate does not call the SaaS backend on
* every billable request. Single-slot (one instance = one linked team), TTL-based.
*
* <p>A transport failure fails open — {@link #current()} keeps serving the freshest snapshot it has
* and returns {@link Optional#empty()} ("unknown → allow") only when nothing was ever fetched. An
* authoritative deny ({@link AccountLinkClient.RevokedException}) does not: the snapshot is
* replaced with a {@link EntitlementState#REVOKED} entitlement so the gate blocks immediately.
* <p>Fail-open friendly for TRANSPORT failures: {@link #current()} returns the freshest snapshot it
* has, even if a refresh just failed; it returns {@link Optional#empty()} only when nothing has
* ever been fetched <i>and</i> the latest refresh failed (the gate treats empty as "unknown →
* allow").
*
* <p>But an AUTHORITATIVE deny (revoked/invalid credential → {@link
* AccountLinkClient.RevokedException}) is NOT a transport failure: the snapshot is replaced with a
* {@link EntitlementState#REVOKED} blocked entitlement so the gate stops billable work immediately
* rather than serving a stale entitled snapshot.
*/
@Slf4j
@Service
@@ -58,8 +63,9 @@ public class EntitlementCache {
* not linked or the SaaS side is unreachable and we have no prior snapshot.
*/
public Optional<InstanceEntitlement> current() {
// Single-flight: when stale, exactly one thread refreshes while concurrent callers serve
// the last snapshot — no thundering herd of round-trips on the billable hot path.
// Single-flight: when stale, exactly one thread refreshes (blocking on the SaaS
// call) while concurrent callers serve the last snapshot — no thundering herd of
// synchronous round-trips on the billable hot path. Safe because the gate fails open.
if (isStale(snapshot) && refreshing.compareAndSet(false, true)) {
try {
refresh();
@@ -71,15 +77,16 @@ public class EntitlementCache {
}
private boolean isStale(Snapshot snap) {
// fetchedAt is the last *attempt* time (stamped on success and failure), so a failed fetch
// backs off a full TTL instead of every request re-triggering a round-trip to a dead SaaS.
// fetchedAt is the last *attempt* time (stamped on success AND failure), so a failed
// fetch backs off for a full TTL instead of every billable request re-triggering a
// blocking round-trip against a dead/slow SaaS endpoint.
return Duration.between(snap.fetchedAt(), Instant.now()).compareTo(ttl) >= 0;
}
/**
* Pulls a fresh snapshot. On a transport failure keeps the previous entitlement but stamps the
* attempt time so re-fetches throttle to the TTL; on an authoritative deny replaces it with a
* blocked snapshot.
* Pulls a fresh snapshot. Keeps the previous entitlement on a TRANSPORT failure (fail-open) but
* still stamps the attempt time so re-fetches throttle to the TTL; on an AUTHORITATIVE deny
* (revoked credential) replaces it with a blocked snapshot so the gate stops billable work.
*/
void refresh() {
Optional<DeviceCredential> cred = credentialStore.get();
@@ -94,15 +101,15 @@ public class EntitlementCache {
if (fresh != null) {
snapshot = new Snapshot(fresh, Instant.now());
} else {
// Unreachable / server error: keep the last known entitlement but stamp the attempt
// so we don't hammer SaaS; the gate fails open meanwhile.
// Unreachable / server error: keep the last known entitlement (may be null) but
// stamp the attempt so we don't hammer SaaS; the gate fails open in the meantime.
log.debug(
"Entitlement refresh failed; reusing last known snapshot, backing off a TTL");
snapshot = new Snapshot(snapshot.entitlement(), Instant.now());
}
} catch (AccountLinkClient.RevokedException e) {
// Authoritative deny — block immediately rather than serving the stale entitled
// snapshot.
// Authoritative deny — credential revoked/invalid. Do NOT fail open: block immediately
// rather than serving the stale entitled snapshot until the next unlink.
log.info(
"Entitlement denied (HTTP {}); blocking billable work for the revoked credential",
e.status());
@@ -114,14 +121,4 @@ public class EntitlementCache {
public void invalidate() {
snapshot = new Snapshot(snapshot.entitlement(), Instant.EPOCH);
}
/**
* Seeds the cache with an entitlement obtained out-of-band (the sync reply carries a fresh
* one), saving a redundant fetch. No-op on null.
*/
public void accept(InstanceEntitlement fresh) {
if (fresh != null) {
snapshot = new Snapshot(fresh, Instant.now());
}
}
}
@@ -16,11 +16,6 @@ public record GateDecision(boolean allowed, Reason reason) {
ENTITLED,
/** Entitlement source unreachable — fail open, allow. */
FAIL_OPEN,
/**
* Linked + metering, but SaaS has been unreachable past the grace window — block (the
* fail-open backstop expired) so unbounded free/unbilled billable work can't continue.
*/
GRACE_EXPIRED,
/** Not linked — block billable work; FE should prompt to link. */
NOT_LINKED,
/** Linked but over the limit / no subscription — block billable work. */
@@ -1,53 +1,19 @@
package stirling.software.proprietary.accountlink;
import java.time.LocalDateTime;
import stirling.software.proprietary.billing.UnitCalcPolicy;
/**
* Cached, proprietary-local view of the SaaS {@code GET /api/v1/instance/entitlement} response.
* Mirrors the saas {@code EntitlementResponse} shape but carries no saas types.
*
* <p>The first five fields are what the <b>gate</b> enforces against; the trailing three are the
* metering inputs (Phase 2) the instance uses to cost + bucket its own usage and reset its
* per-period counters. The 5-arg constructor builds a gate-only view (metering fields null) for the
* revoked sentinel and unit tests that don't exercise metering.
* Cached, proprietary-local view of the SaaS {@code GET /api/v1/instance/entitlement} response
* just the fields the gate needs. Mirrors the saas {@code EntitlementResponse} shape but carries no
* saas types.
*
* @param subscribed team has an active subscription
* @param freeRemainingUnits remaining free-pool units (>0 means free work is available)
* @param periodSpendUnits paid units spent this period
* @param periodCapUnits paid cap for the period; {@code null} = uncapped
* @param state coarse state classification (see {@link EntitlementState})
* @param unitCalcPolicy doc-unit pricing knobs for local unit computation; {@code null} if not
* supplied (older SaaS / gate-only sentinel)
* @param periodStart inclusive start of the current billing period; {@code null} if not supplied
* @param periodEnd exclusive end of the current billing period; {@code null} if not supplied
*/
public record InstanceEntitlement(
boolean subscribed,
long freeRemainingUnits,
long periodSpendUnits,
Long periodCapUnits,
EntitlementState state,
UnitCalcPolicy unitCalcPolicy,
LocalDateTime periodStart,
LocalDateTime periodEnd) {
/** Gate-only view with no metering config — used by the revoked sentinel and gate tests. */
public InstanceEntitlement(
boolean subscribed,
long freeRemainingUnits,
long periodSpendUnits,
Long periodCapUnits,
EntitlementState state) {
this(
subscribed,
freeRemainingUnits,
periodSpendUnits,
periodCapUnits,
state,
null,
null,
null);
}
}
EntitlementState state) {}
@@ -1,6 +1,5 @@
package stirling.software.proprietary.accountlink;
import java.time.LocalDateTime;
import java.util.Optional;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
@@ -16,17 +15,14 @@ import org.springframework.stereotype.Service;
* <li>Flag off → always allow (feature inert).
* <li>Manual tool → always allow (manual tools are free, never metered).
* <li>Billable + not linked → block with {@code NOT_LINKED} ("link to activate").
* <li>Billable + linked + entitlement unknown (unreachable) → <b>fail open</b>, allow — unless
* metering is on and SaaS has been unreachable past the grace window, then block with {@code
* GRACE_EXPIRED} so the fail-open can't grant unbounded free/unbilled work forever.
* <li>Billable + linked + entitlement unknown (unreachable) → <b>fail open</b>, allow.
* <li>Billable + linked + entitled → allow.
* <li>Billable + linked + credential revoked → block with {@code REVOKED}.
* <li>Billable + linked + over limit → block with {@code OVER_LIMIT}.
* </ol>
*
* <p>The decision logic is the pure static {@link #decide}; the Spring wrapper supplies the live
* flag / linked-state / entitlement and computes whether the grace window has expired. This is the
* unit-tested core.
* <p>The decision logic is the pure static {@link #decide}; the Spring wrapper just supplies the
* live flag / linked-state / entitlement. This is the unit-tested core.
*/
@Service
@Profile("!saas")
@@ -36,20 +32,14 @@ public class InstanceEntitlementGate {
private final AccountLinkProperties properties;
private final DeviceCredentialStore credentialStore;
private final EntitlementCache entitlementCache;
private final AccountLinkSyncStateRepository syncStateRepository;
private final LocalUsageService localUsageService;
public InstanceEntitlementGate(
AccountLinkProperties properties,
DeviceCredentialStore credentialStore,
EntitlementCache entitlementCache,
AccountLinkSyncStateRepository syncStateRepository,
LocalUsageService localUsageService) {
EntitlementCache entitlementCache) {
this.properties = properties;
this.credentialStore = credentialStore;
this.entitlementCache = entitlementCache;
this.syncStateRepository = syncStateRepository;
this.localUsageService = localUsageService;
}
/** Evaluates the gate for a request, resolving live state from the store + cache. */
@@ -63,39 +53,18 @@ public class InstanceEntitlementGate {
boolean linked = credentialStore.isLinked();
Optional<InstanceEntitlement> entitlement =
linked ? entitlementCache.current() : Optional.empty();
boolean graceExpired = linked && entitlement.isEmpty() && isGraceExpired();
// Deplete the applicable ceiling — free grant (unsubscribed) or spend cap (capped
// subscription) — by local usage not yet synced, so the gate stops in real time instead of
// overshooting until the next sync. An uncapped subscription has no ceiling to deplete → 0.
long pendingUnsynced =
entitlement.map(InstanceEntitlementGate::depletesCeiling).orElse(false)
? localUsageService.currentPeriodUnsynced().totalUnsyncedUnits()
: 0L;
return decide(true, true, linked, entitlement, graceExpired, pendingUnsynced);
}
/** Whether local unsynced usage pushes against a real ceiling (free grant or a spend cap). */
private static boolean depletesCeiling(InstanceEntitlement e) {
return !e.subscribed() || e.periodCapUnits() != null;
return decide(true, true, linked, entitlement);
}
/**
* Pure decision function — no Spring, no I/O. {@code entitlement} empty means "unknown"
* (unreachable): when linked, that fails open unless {@code graceExpired} (the metering grace
* window elapsed with no authoritative contact), in which case it blocks.
*
* @param pendingUnsyncedUnits billable units accrued locally since the last sync — depletes the
* free grant (unsubscribed) or the spend cap (capped subscription) in real time so the gate
* stops without waiting for the next sync (0 for uncapped-subscribed / unknown-entitlement
* cases, where it has no effect).
* (unreachable): when linked, that fails open.
*/
public static GateDecision decide(
boolean flagEnabled,
boolean billable,
boolean linked,
Optional<InstanceEntitlement> entitlement,
boolean graceExpired,
long pendingUnsyncedUnits) {
Optional<InstanceEntitlement> entitlement) {
if (!flagEnabled) {
return GateDecision.allow(GateDecision.Reason.FLAG_OFF);
}
@@ -106,69 +75,30 @@ public class InstanceEntitlementGate {
return GateDecision.block(GateDecision.Reason.NOT_LINKED);
}
if (entitlement.isEmpty()) {
// Linked but entitlement unreachable: fail open, unless the grace window has expired
// (so
// the fail-open can't grant unbounded unbilled work forever).
return graceExpired
? GateDecision.block(GateDecision.Reason.GRACE_EXPIRED)
: GateDecision.allow(GateDecision.Reason.FAIL_OPEN);
// Linked but entitlement source unreachable — never hard-block billable work on our
// inability to reach billing.
return GateDecision.allow(GateDecision.Reason.FAIL_OPEN);
}
InstanceEntitlement e = entitlement.get();
if (e.state() == EntitlementState.REVOKED) {
// Credential revoked/invalid (authoritative deny) — block, distinct from over-limit.
return GateDecision.block(GateDecision.Reason.REVOKED);
}
return entitled(e, pendingUnsyncedUnits)
return entitled(e)
? GateDecision.allow(GateDecision.Reason.ENTITLED)
: GateDecision.block(GateDecision.Reason.OVER_LIMIT);
}
/**
* True when metering is on and it's been {@code graceDays} since the last authoritative contact
* (last successful sync, or link time if never synced). {@code graceDays <= 0} or metering off
* disables the backstop.
*/
private boolean isGraceExpired() {
AccountLinkProperties.Metering metering = properties.getMetering();
if (!metering.isEnabled() || metering.getGraceDays() <= 0) {
return false;
}
LocalDateTime reference = lastAuthoritativeContact();
if (reference == null) {
return false; // can't determine elapsed time → fail open
}
return reference.plusDays(metering.getGraceDays()).isBefore(LocalDateTime.now());
}
private LocalDateTime lastAuthoritativeContact() {
LocalDateTime lastSuccess =
syncStateRepository
.findById(AccountLinkSyncState.SINGLETON_ID)
.map(AccountLinkSyncState::getLastSuccessAt)
.orElse(null);
if (lastSuccess != null) {
return lastSuccess;
}
return credentialStore.get().map(DeviceCredential::getLinkedAt).orElse(null);
}
/** True when the snapshot permits billable work (subscribed, free pool left, or within cap). */
private static boolean entitled(InstanceEntitlement e, long pendingUnsyncedUnits) {
private static boolean entitled(InstanceEntitlement e) {
if (e.state() == EntitlementState.OVER_LIMIT || e.state() == EntitlementState.REVOKED) {
return false;
}
if (e.subscribed()) {
if (e.periodCapUnits() == null) {
return true; // uncapped subscription
}
// Project the cap the way the grant is projected: synced paid spend plus the paid part
// of local usage not yet synced (free grant is consumed first, so only the excess
// bills) — stops at the cap in real time instead of overshooting until the next sync.
long pendingPaid = Math.max(0, pendingUnsyncedUnits - e.freeRemainingUnits());
return e.periodSpendUnits() + pendingPaid < e.periodCapUnits();
// Subscribed: allowed unless a period cap is set and exceeded.
return e.periodCapUnits() == null || e.periodSpendUnits() < e.periodCapUnits();
}
// Unsubscribed: free pool must cover SaaS-charged usage (in freeRemainingUnits) plus local
// usage not yet synced — deplete by the pending delta so we stop at the grant in real time.
return e.freeRemainingUnits() - pendingUnsyncedUnits > 0;
// Unsubscribed: only the free pool covers billable work.
return e.freeRemainingUnits() > 0;
}
}
@@ -1,51 +1,27 @@
package stirling.software.proprietary.accountlink;
import java.io.IOException;
import java.io.InputStream;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.security.DigestOutputStream;
import java.security.MessageDigest;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import org.springframework.beans.factory.ObjectProvider;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.context.annotation.Profile;
import org.springframework.http.HttpStatus;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.stereotype.Component;
import org.springframework.web.multipart.MultipartFile;
import org.springframework.web.multipart.MultipartHttpServletRequest;
import org.springframework.web.servlet.HandlerInterceptor;
import org.springframework.web.util.WebUtils;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.extern.slf4j.Slf4j;
import stirling.software.common.util.TempFile;
import stirling.software.common.util.TempFileManager;
import stirling.software.jpdfium.PdfDocument;
import stirling.software.proprietary.billing.BillingCategory;
import stirling.software.proprietary.billing.ContentHasher;
import stirling.software.proprietary.billing.DocumentUnitCalculator;
import stirling.software.proprietary.billing.DocumentUnitCalculator.FileSize;
import stirling.software.proprietary.billing.UnitCalcPolicy;
import stirling.software.proprietary.security.model.ApiKeyAuthenticationToken;
/**
* Request-time gate + meter for combined-billing "Mode A". {@code preHandle} blocks billable (API /
* AI / automation) work when the instance is unlinked or over its limit; manual tools pass through.
* {@code afterCompletion} meters a successful billable op into the per-period cumulative counter.
* Request-time gate for combined-billing "Mode A". Runs before billable (AI / automation) work and
* blocks it when the instance is unlinked or over its limit; manual tools pass straight through.
*
* <p>Blocking responds {@code 402} with a machine-readable body the FE maps to a "link to activate"
* prompt; fail-open and flag-off both let the request continue. Metering is separately gated behind
* {@code …metering.enabled} via {@link ObjectProvider} — switch off means the {@link
* UsageMeterService} bean is absent and nothing accrues, while the gate still works.
* <p>Blocking responds {@code 402 Payment Required} with a small machine-readable body — {@code
* {"error":"ACCOUNT_LINK_REQUIRED","reason":"NOT_LINKED"}} — that the FE maps to a "link to
* activate" prompt (the same DownstreamEntitlementError-style envelope already used for saas limit
* responses). Fail-open and flag-off both let the request continue.
*
* <p>Gated + {@code @Profile("!saas")}; when the flag is off the bean is absent and the {@link
* AccountLinkWebMvcConfig} never registers it, so there is no per-request cost.
*/
@Slf4j
@Component
@@ -53,23 +29,10 @@ import stirling.software.proprietary.security.model.ApiKeyAuthenticationToken;
@ConditionalOnProperty(name = "stirling.billing.account-link.enabled", havingValue = "true")
public class InstanceEntitlementInterceptor implements HandlerInterceptor {
private static final String ATTR_CATEGORY =
InstanceEntitlementInterceptor.class.getName() + ".category";
private final InstanceEntitlementGate gate;
private final EntitlementCache entitlementCache;
private final ObjectProvider<UsageMeterService> meterProvider;
private final TempFileManager tempFileManager;
public InstanceEntitlementInterceptor(
InstanceEntitlementGate gate,
EntitlementCache entitlementCache,
ObjectProvider<UsageMeterService> meterProvider,
TempFileManager tempFileManager) {
public InstanceEntitlementInterceptor(InstanceEntitlementGate gate) {
this.gate = gate;
this.entitlementCache = entitlementCache;
this.meterProvider = meterProvider;
this.tempFileManager = tempFileManager;
}
@Override
@@ -78,13 +41,7 @@ public class InstanceEntitlementInterceptor implements HandlerInterceptor {
throws Exception {
GateDecision decision;
try {
// API-key tool calls are billable (category API); stash the category for the meter.
boolean apiKey =
SecurityContextHolder.getContext().getAuthentication()
instanceof ApiKeyAuthenticationToken;
BillingCategory category = BillableOperationClassifier.categorize(request, apiKey);
request.setAttribute(ATTR_CATEGORY, category);
decision = gate.evaluate(category != BillingCategory.BYPASSED);
decision = gate.evaluate(BillableOperationClassifier.isBillable(request));
} catch (RuntimeException e) {
// Fail open: an inability to resolve entitlement (e.g. a DB or SaaS blip) must never
// turn into a hard block on billable work.
@@ -105,139 +62,4 @@ public class InstanceEntitlementInterceptor implements HandlerInterceptor {
+ "\"}");
return false;
}
@Override
public void afterCompletion(
HttpServletRequest request,
HttpServletResponse response,
Object handler,
Exception ex) {
// Meter successful billable ops only.
if (ex != null || response.getStatus() >= 400) {
return;
}
UsageMeterService meter = meterProvider.getIfAvailable();
if (meter == null) {
return; // metering switch off
}
if (!(request.getAttribute(ATTR_CATEGORY) instanceof BillingCategory category)
|| category == BillingCategory.BYPASSED) {
return;
}
try {
InstanceEntitlement ent = entitlementCache.current().orElse(null);
if (ent == null || ent.unitCalcPolicy() == null || ent.periodStart() == null) {
// Not yet synced (no policy/period) — can't compute units; skip until next sync.
return;
}
meterRequest(request, category, ent, meter);
} catch (RuntimeException e) {
// Metering must never affect the response that already completed.
log.debug("Usage metering failed for {}", request.getRequestURI(), e);
}
}
/**
* Computes doc-units (page + byte axes) and the input-set signature, then accrues. The instance
* is authoritative for units (SaaS bills the delta and never sees the file), so a page-heavy
* but small PDF must be page-counted or it under-bills. A fileless op has no input identity —
* null signature (no dedup), billed the 1-unit floor each time.
*/
private void meterRequest(
HttpServletRequest request,
BillingCategory category,
InstanceEntitlement ent,
UsageMeterService meter) {
UnitCalcPolicy policy = ent.unitCalcPolicy();
MultipartHttpServletRequest mreq =
WebUtils.getNativeRequest(request, MultipartHttpServletRequest.class);
if (mreq == null) {
long fileless = DocumentUnitCalculator.unitsForFile(0, 0, policy);
meter.accrue(ent.periodStart(), category, fileless, null);
return;
}
List<TempFile> temps = new ArrayList<>();
try {
List<FileSize> sizes = new ArrayList<>();
List<String> hashes = new ArrayList<>();
int fileCount = 0;
for (List<MultipartFile> files : mreq.getMultiFileMap().values()) {
for (MultipartFile f : files) {
fileCount++;
try {
TempFile temp = tempFileManager.createManagedTempFile(".bin");
temps.add(temp);
// Hash in the same pass that writes the temp file — one read of the upload,
// not a second full read just to fingerprint it.
MessageDigest digest = ContentHasher.newSha256();
try (InputStream in = f.getInputStream();
DigestOutputStream out =
new DigestOutputStream(
Files.newOutputStream(temp.getPath()), digest)) {
in.transferTo(out);
}
sizes.add(new FileSize(pageCount(temp.getPath(), f), f.getSize()));
hashes.add(ContentHasher.toHex(digest.digest()));
} catch (IOException | RuntimeException perFile) {
// Couldn't materialise/hash this input — bill on bytes only and, by leaving
// it out of `hashes`, drop dedup for the whole op rather than risk a
// mismatch.
log.debug(
"Metering materialise/hash failed for {}; bytes-only",
f.getOriginalFilename());
sizes.add(new FileSize(0, f.getSize()));
}
}
}
long units =
sizes.isEmpty()
? DocumentUnitCalculator.unitsForFile(0, 0, policy)
: DocumentUnitCalculator.unitsForGroup(sizes, policy);
// Only dedup when every input hashed; a partial signature could collide with a
// different input set, so fall back to no-dedup (bill it) if any file failed.
String opSignature =
fileCount > 0 && hashes.size() == fileCount ? opSignature(hashes) : null;
meter.accrue(ent.periodStart(), category, units, opSignature);
} finally {
for (TempFile temp : temps) {
try {
temp.close();
} catch (RuntimeException cleanup) {
log.debug("Temp file cleanup failed: {}", cleanup.getMessage());
}
}
}
}
/** Page count via jpdfium (parser-identical to SaaS); 0 for non-PDF / unreadable inputs. */
private static int pageCount(Path path, MultipartFile file) {
if (!isPdf(file)) {
return 0;
}
try (PdfDocument doc = PdfDocument.open(path)) {
return doc.pageCount();
} catch (RuntimeException e) {
// Malformed / encrypted → byte axis only, matching the SaaS classifier.
log.debug(
"Page count unavailable for {}; metering on bytes only",
file.getOriginalFilename());
return 0;
}
}
/** Order-independent signature of the input set: sorted per-file hashes, hashed together. */
private static String opSignature(List<String> hashes) {
List<String> sorted = new ArrayList<>(hashes);
Collections.sort(sorted);
return ContentHasher.sha256(String.join("\n", sorted).getBytes(StandardCharsets.UTF_8));
}
private static boolean isPdf(MultipartFile file) {
String contentType = file.getContentType();
if (contentType != null && contentType.toLowerCase().contains("pdf")) {
return true;
}
String name = file.getOriginalFilename();
return name != null && name.toLowerCase().endsWith(".pdf");
}
}
@@ -1,59 +0,0 @@
package stirling.software.proprietary.accountlink;
import java.time.LocalDateTime;
import java.util.EnumMap;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.context.annotation.Profile;
import org.springframework.stereotype.Service;
import stirling.software.proprietary.billing.BillingCategory;
/**
* Reads this instance's locally accrued but not-yet-synced usage for the current period. The portal
* adds this on top of SaaS-synced spend so "current usage" reflects work done since the last sync.
*
* <p>Unsynced per category = {@code cumulativeUnits lastSyncedUnits} (floored at 0), scoped to
* the current period so prior-period leftovers don't inflate it. Zeros when the period is unknown
* or metering is off.
*/
@Service
@Profile("!saas")
@ConditionalOnProperty(name = "stirling.billing.account-link.enabled", havingValue = "true")
public class LocalUsageService {
private final UsageCounterRepository counters;
private final EntitlementCache entitlementCache;
public LocalUsageService(UsageCounterRepository counters, EntitlementCache entitlementCache) {
this.counters = counters;
this.entitlementCache = entitlementCache;
}
/** Per-category unsynced units for the current period; {@code periodStart} null = unknown. */
public record LocalUsage(
LocalDateTime periodStart,
long apiUnsyncedUnits,
long aiUnsyncedUnits,
long automationUnsyncedUnits,
long totalUnsyncedUnits) {}
public LocalUsage currentPeriodUnsynced() {
LocalDateTime period =
entitlementCache.current().map(InstanceEntitlement::periodStart).orElse(null);
if (period == null) {
return new LocalUsage(null, 0, 0, 0, 0);
}
EnumMap<BillingCategory, Long> unsynced = new EnumMap<>(BillingCategory.class);
for (UsageCounter c : counters.findByPeriodStart(period)) {
BillingCategory cat = c.billingCategory();
if (cat != null && cat != BillingCategory.BYPASSED) {
unsynced.merge(cat, c.unsyncedUnits(), Long::sum);
}
}
long api = unsynced.getOrDefault(BillingCategory.API, 0L);
long ai = unsynced.getOrDefault(BillingCategory.AI, 0L);
long automation = unsynced.getOrDefault(BillingCategory.AUTOMATION, 0L);
return new LocalUsage(period, api, ai, automation, api + ai + automation);
}
}
@@ -1,73 +0,0 @@
package stirling.software.proprietary.accountlink;
import java.time.LocalDateTime;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import jakarta.persistence.UniqueConstraint;
import lombok.AccessLevel;
import lombok.Getter;
import lombok.NoArgsConstructor;
/**
* The last time the instance metered a given input set this period — the local equivalent of the
* cloud's lineage join (combined-billing "Mode A"). The meter dedups on a rolling <b>workflow
* window</b>: an identical input set re-submitted within the window (see {@link
* AccountLinkProperties.Metering}) is treated as workflow chaining and not re-charged, while the
* same inputs run again after the window are billed afresh — matching the cloud's 5-minute open-job
* window so the same operation costs the same on the instance and in the cloud.
*
* <p>{@code lastMeteredAt} is refreshed on every sighting (the window slides, as recording a cloud
* artifact touches its job). One row per {@code (period, signature)}; the unique constraint also
* makes the first-sighting insert an atomic claim under concurrency.
*
* <p>Auto-created by Hibernate ({@code ddl-auto=update}); written only by the flag-gated meter.
*/
@Entity
@Table(
name = "account_link_metered_signature",
uniqueConstraints =
@UniqueConstraint(
name = "uk_account_link_metered_signature",
columnNames = {"period_start", "signature"}))
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class MeteredInputSignature {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "period_start", nullable = false)
private LocalDateTime periodStart;
/** SHA-256 hex of the op's input set (64 chars); the dedup key within a period. */
@Column(name = "signature", nullable = false, length = 64)
private String signature;
@Column(name = "created_at", nullable = false)
private LocalDateTime createdAt;
/**
* When this input set was last metered — the anchor the workflow-window dedup compares against.
*/
@Column(name = "last_metered_at")
private LocalDateTime lastMeteredAt;
public MeteredInputSignature(LocalDateTime periodStart, String signature, LocalDateTime at) {
this.periodStart = periodStart;
this.signature = signature;
this.createdAt = at;
this.lastMeteredAt = at;
}
/** Slides the window forward — the input set was seen again. */
public void touch(LocalDateTime at) {
this.lastMeteredAt = at;
}
}
@@ -1,15 +0,0 @@
package stirling.software.proprietary.accountlink;
import java.time.LocalDateTime;
import java.util.Optional;
import org.springframework.data.jpa.repository.JpaRepository;
/** Persistence for the per-period metered input-set signatures (combined-billing "Mode A"). */
public interface MeteredInputSignatureRepository
extends JpaRepository<MeteredInputSignature, Long> {
/** The existing row for a seen input set, so the meter can apply the workflow-window check. */
Optional<MeteredInputSignature> findByPeriodStartAndSignature(
LocalDateTime periodStart, String signature);
}
@@ -1,105 +0,0 @@
package stirling.software.proprietary.accountlink;
import java.time.LocalDateTime;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import jakarta.persistence.UniqueConstraint;
import lombok.AccessLevel;
import lombok.Getter;
import lombok.NoArgsConstructor;
import stirling.software.proprietary.billing.BillingCategory;
/**
* Durable per-(billing period, category) cumulative usage counter for combined-billing "Mode A".
* Each successful billable op increments its row; the daily sync reports the cumulative totals and
* SaaS bills the delta since the last sync. The cumulative model is idempotent (a resend bills
* nothing) and tamper-evident (a counter that drops is a signal). One row per {@code (period_start,
* category)}, auto-created by Hibernate; only the flag-gated {@link UsageMeterService} writes it.
*/
@Entity
@Table(
name = "account_link_usage_counter",
uniqueConstraints =
@UniqueConstraint(
name = "uk_usage_counter_period_category",
columnNames = {"period_start", "category"}))
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class UsageCounter {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
/**
* Inclusive start of the billing period this counter belongs to (from the entitlement sync).
*/
@Column(name = "period_start", nullable = false)
private LocalDateTime periodStart;
/** {@code BillingCategory} name — API / AI / AUTOMATION (never BYPASSED). */
@Column(name = "category", nullable = false, length = 32)
private String category;
/** Running total of metered units in this period+category. */
@Column(name = "cumulative_units", nullable = false)
private long cumulativeUnits;
/**
* {@link #cumulativeUnits} as of the last sync SaaS accepted; the difference is the unreported
* usage the portal shows on top of SaaS-synced spend. The {@code columnDefinition} default
* keeps the {@code ddl-auto=update} ADD COLUMN safe against a table an earlier build already
* populated (NOT NULL with no default would fail the ALTER).
*/
@Column(
name = "last_synced_units",
nullable = false,
columnDefinition = "bigint not null default 0")
private long lastSyncedUnits;
@Column(name = "updated_at", nullable = false)
private LocalDateTime updatedAt;
/** Fresh-accrual row: nothing synced yet. */
public UsageCounter(
LocalDateTime periodStart,
String category,
long cumulativeUnits,
LocalDateTime updatedAt) {
this(periodStart, category, cumulativeUnits, 0L, updatedAt);
}
public UsageCounter(
LocalDateTime periodStart,
String category,
long cumulativeUnits,
long lastSyncedUnits,
LocalDateTime updatedAt) {
this.periodStart = periodStart;
this.category = category;
this.cumulativeUnits = cumulativeUnits;
this.lastSyncedUnits = lastSyncedUnits;
this.updatedAt = updatedAt;
}
/** This row's category as the enum, or {@code null} for an unrecognised stored value. */
public BillingCategory billingCategory() {
try {
return BillingCategory.valueOf(category);
} catch (IllegalArgumentException unknown) {
return null;
}
}
/** Units accrued but not yet accepted by SaaS (floored at 0). */
public long unsyncedUnits() {
return Math.max(0, cumulativeUnits - lastSyncedUnits);
}
}
@@ -1,57 +0,0 @@
package stirling.software.proprietary.accountlink;
import java.time.LocalDateTime;
import java.util.List;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Modifying;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import org.springframework.transaction.annotation.Transactional;
/** Persistence for the per-period/per-category usage counters (combined-billing "Mode A"). */
public interface UsageCounterRepository extends JpaRepository<UsageCounter, Long> {
/**
* Atomically adds {@code delta} to an existing counter row. Returns the number of rows updated
* (0 when the row doesn't exist yet — the caller then inserts). Doing the add in SQL avoids a
* read-modify-write race between concurrent billable requests.
*/
@Modifying
@Transactional
@Query(
"UPDATE UsageCounter c SET c.cumulativeUnits = c.cumulativeUnits + :delta,"
+ " c.updatedAt = :now"
+ " WHERE c.periodStart = :periodStart AND c.category = :category")
int increment(
@Param("periodStart") LocalDateTime periodStart,
@Param("category") String category,
@Param("delta") long delta,
@Param("now") LocalDateTime now);
/** All counters for a period — the daily sync reads these to report cumulative totals. */
List<UsageCounter> findByPeriodStart(LocalDateTime periodStart);
/**
* Periods (oldest first) that still hold usage not yet accepted by SaaS. The sync reports each
* so end-of-period usage isn't stranded when the billing period rolls over between syncs.
*/
@Query(
"SELECT DISTINCT c.periodStart FROM UsageCounter c"
+ " WHERE c.cumulativeUnits > c.lastSyncedUnits ORDER BY c.periodStart")
List<LocalDateTime> findPeriodsWithUnsyncedUsage();
/**
* Marks a counter synced up to {@code syncedUnits} (the cumulative value just accepted by
* SaaS), not the live cumulative — concurrent accruals during the sync stay correctly unsynced.
*/
@Modifying
@Transactional
@Query(
"UPDATE UsageCounter c SET c.lastSyncedUnits = :syncedUnits"
+ " WHERE c.periodStart = :periodStart AND c.category = :category")
int markSynced(
@Param("periodStart") LocalDateTime periodStart,
@Param("category") String category,
@Param("syncedUnits") long syncedUnits);
}
@@ -1,115 +0,0 @@
package stirling.software.proprietary.accountlink;
import java.time.Duration;
import java.time.LocalDateTime;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.context.annotation.Profile;
import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.stereotype.Service;
import lombok.extern.slf4j.Slf4j;
import stirling.software.proprietary.billing.BillingCategory;
/**
* Accrues metered usage into the durable per-(period, category) {@link UsageCounter}; the daily
* sync later reports the cumulative totals to SaaS.
*
* <p>Workflow-window dedup: an identical input set re-submitted within {@code metering.workflow-
* window} is treated as chaining and not re-charged; the same inputs run again after the window are
* billed afresh — matching the cloud's open-job lineage window so the same op costs the same on the
* instance and in the cloud. Fileless ops pass a null signature and always accrue. {@link #accrue}
* is best-effort: callers need not handle persistence errors.
*/
@Slf4j
@Service
@Profile("!saas")
@ConditionalOnProperty(
name = "stirling.billing.account-link.metering.enabled",
havingValue = "true")
public class UsageMeterService {
private final UsageCounterRepository repo;
private final MeteredInputSignatureRepository signatureRepo;
private final Duration workflowWindow;
public UsageMeterService(
UsageCounterRepository repo,
MeteredInputSignatureRepository signatureRepo,
AccountLinkProperties properties) {
this.repo = repo;
this.signatureRepo = signatureRepo;
this.workflowWindow = properties.getMetering().getWorkflowWindow();
}
/**
* Adds {@code units} to the {@code (periodStart, category)} counter (creating the row on first
* use), unless {@code opSignature} was already metered this period. No-ops for non-billable
* categories, non-positive units, or a missing period.
*/
public void accrue(
LocalDateTime periodStart, BillingCategory category, long units, String opSignature) {
if (periodStart == null
|| category == null
|| category == BillingCategory.BYPASSED
|| units <= 0) {
return;
}
if (opSignature != null && !shouldCharge(periodStart, opSignature)) {
return; // identical inputs seen within the workflow window — chaining, already billed
}
incrementOrInsert(periodStart, category.name(), units);
}
/**
* True when this input set should be charged: unseen this period, or last seen outside the
* workflow window. Records a first sighting (an atomic insert-as-claim under concurrency) and
* slides the window on a repeat. Fails toward charging so a store hiccup never drops a charge.
*/
private boolean shouldCharge(LocalDateTime periodStart, String opSignature) {
LocalDateTime now = LocalDateTime.now();
MeteredInputSignature seen =
signatureRepo.findByPeriodStartAndSignature(periodStart, opSignature).orElse(null);
if (seen == null) {
try {
signatureRepo.saveAndFlush(
new MeteredInputSignature(periodStart, opSignature, now));
return true; // first sighting this period
} catch (DataIntegrityViolationException raced) {
return false; // a concurrent op just claimed it — within window → chaining
} catch (RuntimeException e) {
log.debug("Signature claim failed for {}: {}", periodStart, e.getMessage());
return true;
}
}
LocalDateTime last = seen.getLastMeteredAt() != null ? seen.getLastMeteredAt() : now;
boolean withinWindow = last.isAfter(now.minus(workflowWindow));
try {
seen.touch(now);
signatureRepo.save(seen);
} catch (RuntimeException e) {
log.debug("Signature touch failed for {}: {}", periodStart, e.getMessage());
}
return !withinWindow;
}
private void incrementOrInsert(LocalDateTime periodStart, String category, long units) {
LocalDateTime now = LocalDateTime.now();
try {
if (repo.increment(periodStart, category, units, now) > 0) {
return;
}
try {
repo.saveAndFlush(new UsageCounter(periodStart, category, units, now));
} catch (DataIntegrityViolationException raceLostInsert) {
// A concurrent request inserted the row first — increment the now-existing row.
repo.increment(periodStart, category, units, now);
}
} catch (RuntimeException e) {
// Metering must never break the request it rode in on; a lost accrual self-heals on the
// next increment and the daily sync reports the cumulative total either way.
log.debug("Usage accrual failed for {}/{}: {}", periodStart, category, e.getMessage());
}
}
}
@@ -1,189 +0,0 @@
package stirling.software.proprietary.accountlink;
import java.time.Duration;
import java.time.LocalDateTime;
import java.util.EnumMap;
import java.util.List;
import java.util.Optional;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.context.annotation.Profile;
import org.springframework.scheduling.annotation.SchedulingConfigurer;
import org.springframework.scheduling.config.FixedDelayTask;
import org.springframework.scheduling.config.ScheduledTaskRegistrar;
import org.springframework.stereotype.Service;
import lombok.extern.slf4j.Slf4j;
import stirling.software.proprietary.billing.BillingCategory;
/**
* Daily usage sender for combined-billing "Mode A". Reports each period's cumulative per-category
* usage to SaaS, which bills the delta against its own last-seen totals.
*
* <p>Resilience: the sync seq is persisted before the report so it never regresses across
* restarts/failures; a transport failure leaves the {@code lastSyncedUnits} markers untouched so
* usage rolls into the next sync; and reporting the same cumulative twice bills nothing. All
* periods with unsynced usage are reported so nothing is stranded when the period rolls over
* between syncs.
*/
@Slf4j
@Service
@Profile("!saas")
@ConditionalOnProperty(
name = "stirling.billing.account-link.metering.enabled",
havingValue = "true")
public class UsageSyncService implements SchedulingConfigurer {
// First run waits out startup churn; then every interval.
private static final Duration INITIAL_DELAY = Duration.ofMinutes(5);
private final UsageCounterRepository counters;
private final AccountLinkSyncStateRepository syncState;
private final DeviceCredentialStore credentialStore;
private final AccountLinkClient client;
private final EntitlementCache entitlementCache;
private final AccountLinkProperties properties;
public UsageSyncService(
UsageCounterRepository counters,
AccountLinkSyncStateRepository syncState,
DeviceCredentialStore credentialStore,
AccountLinkClient client,
EntitlementCache entitlementCache,
AccountLinkProperties properties) {
this.counters = counters;
this.syncState = syncState;
this.credentialStore = credentialStore;
this.client = client;
this.entitlementCache = entitlementCache;
this.properties = properties;
}
/**
* Registers the daily sync, binding the interval from {@code metering.sync-interval-hours} in
* code rather than a {@code @Scheduled} SpEL string so a bad interval fails at boot/test rather
* than only on a flags-on run.
*/
@Override
public void configureTasks(ScheduledTaskRegistrar registrar) {
Duration interval = Duration.ofHours(properties.getMetering().getSyncIntervalHours());
registrar.addFixedDelayTask(
new FixedDelayTask(this::scheduledSync, interval, INITIAL_DELAY));
}
public void scheduledSync() {
try {
syncNow();
} catch (RuntimeException e) {
log.debug("Scheduled usage sync failed", e);
}
}
/**
* Reports every period with unsynced usage and refreshes the cached entitlement from the reply.
* Single daily caller (non-reentrant {@code fixedDelay}), so no internal locking. No-op when
* unlinked or when nothing is pending.
*/
public void syncNow() {
Optional<DeviceCredential> cred = credentialStore.get();
if (cred.isEmpty()) {
return; // not linked
}
List<LocalDateTime> periods = counters.findPeriodsWithUnsyncedUsage();
if (periods.isEmpty()) {
// Nothing to report, but a sync is also our cue to pick up an out-of-band entitlement
// change (e.g. the admin just subscribed) that otherwise wouldn't surface until the
// cache TTL lapses. Force an immediate refresh so the gate reflects the new plan now.
entitlementCache.invalidate();
entitlementCache.current();
return;
}
InstanceEntitlement latest = null;
try {
for (LocalDateTime period : periods) {
InstanceEntitlement fresh = syncPeriod(cred.get(), period);
if (fresh != null) {
latest = fresh;
}
}
} catch (AccountLinkClient.RevokedException e) {
// Authoritative deny — stop reporting; the entitlement cache blocks billable work on
// its
// own next refresh, so we don't synthesise the blocked state here.
log.info(
"Usage sync denied (HTTP {}); credential revoked/invalid — gate blocks on next"
+ " refresh",
e.status());
return;
}
// Adopt the freshest entitlement the sync returned, saving the cache a redundant fetch.
entitlementCache.accept(latest);
}
/** Reports one period; returns the fresh entitlement, or null on a transport/server failure. */
private InstanceEntitlement syncPeriod(DeviceCredential cred, LocalDateTime period) {
EnumMap<BillingCategory, Long> cumulative = new EnumMap<>(BillingCategory.class);
for (UsageCounter c : counters.findByPeriodStart(period)) {
BillingCategory cat = c.billingCategory();
if (cat != null && cat != BillingCategory.BYPASSED) {
cumulative.merge(cat, c.getCumulativeUnits(), Long::sum);
}
}
AccountLinkSyncState state = loadState();
long seq = reserveNextSeq(state);
InstanceEntitlement fresh =
client.reportUsage(
cred.getDeviceId(),
cred.getDeviceSecret(),
seq,
period,
cumulative.getOrDefault(BillingCategory.API, 0L),
cumulative.getOrDefault(BillingCategory.AI, 0L),
cumulative.getOrDefault(BillingCategory.AUTOMATION, 0L));
if (fresh == null) {
// Transport/server failure: leave the synced markers untouched. The burned seq is
// harmless (seqs need only be monotonic) and the delta bills on the next successful
// sync.
return null;
}
recordSuccess(period, cumulative, state);
return fresh;
}
/** Reserves and persists the next strictly-increasing sequence before the report goes out. */
private long reserveNextSeq(AccountLinkSyncState state) {
long next = state.getLastSyncSeq() + 1;
state.setLastSyncSeq(next);
syncState.save(state);
return next;
}
/**
* Advances the per-category synced markers to the reported totals + stamps the success time.
*/
private void recordSuccess(
LocalDateTime period,
EnumMap<BillingCategory, Long> cumulative,
AccountLinkSyncState state) {
cumulative.forEach(
(category, units) -> {
if (units > 0) {
counters.markSynced(period, category.name(), units);
}
});
state.setLastSuccessAt(LocalDateTime.now());
syncState.save(state);
}
private AccountLinkSyncState loadState() {
return syncState
.findById(AccountLinkSyncState.SINGLETON_ID)
.orElseGet(
() -> {
AccountLinkSyncState s = new AccountLinkSyncState();
s.setId(AccountLinkSyncState.SINGLETON_ID);
return s;
});
}
}
@@ -130,7 +130,6 @@ public class ControllerAuditAspect {
String previousPrincipal = MDC.get("auditPrincipal");
String previousOrigin = MDC.get("auditOrigin");
String previousSource = MDC.get("auditSource");
String previousIp = MDC.get("auditIp");
// EARLY CAPTURE: Capture from SecurityContext on request thread, store in MDC for async
@@ -162,14 +161,6 @@ public class ControllerAuditAspect {
return joinPoint.proceed();
}
// Stamp the free-UI source only for non-@Audited controller traffic — an actual
// tool / UI action. @Audited events (login, settings) return above without a source,
// so they never count as an "active editor" or a free UI run. The finally block
// restores auditSource, so a pooled thread can't leak a stale "WEB" into them.
if (previousSource == null) {
MDC.put("auditSource", auditService.captureCurrentSource());
}
long start = System.currentTimeMillis();
// Use auditService to create the base audit data
@@ -256,7 +247,6 @@ public class ControllerAuditAspect {
} finally {
restoreMdcValue("auditPrincipal", previousPrincipal);
restoreMdcValue("auditOrigin", previousOrigin);
restoreMdcValue("auditSource", previousSource);
restoreMdcValue("auditIp", previousIp);
}
}
@@ -1,15 +0,0 @@
package stirling.software.proprietary.audit;
import org.springframework.stereotype.Component;
/** Self-hosted default: admins see the whole-server audit log, everyone else is denied. */
@Component
public class DefaultPortalAuditScopeResolver implements PortalAuditScopeResolver {
@Override
public PortalAuditScope resolve() {
return PortalAuditScopeResolver.hasAdminAuthority()
? PortalAuditScope.server()
: PortalAuditScope.denied();
}
}
@@ -1,7 +0,0 @@
package stirling.software.proprietary.audit;
import java.time.Instant;
/** Immutable, cacheable projection of an {@code audit_events} row, shared across portal views. */
public record PortalAuditEventRow(
long id, String principal, String type, String data, Instant timestamp) {}
@@ -1,21 +0,0 @@
package stirling.software.proprietary.audit;
import java.util.List;
/** Resolved audit visibility: fullServer (admin), principals-scoped (team lead), or !allowed. */
public record PortalAuditScope(
boolean allowed, boolean fullServer, List<String> principals, String cacheKey) {
public static PortalAuditScope denied() {
return new PortalAuditScope(false, false, List.of(), "denied");
}
// Named server()/team() to avoid colliding with the record's fullServer() accessor.
public static PortalAuditScope server() {
return new PortalAuditScope(true, true, List.of(), "server");
}
public static PortalAuditScope team(String cacheKey, List<String> principals) {
return new PortalAuditScope(true, false, List.copyOf(principals), cacheKey);
}
}
@@ -1,18 +0,0 @@
package stirling.software.proprietary.audit;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
/** Resolves which slice of the audit log the caller may see. */
public interface PortalAuditScopeResolver {
PortalAuditScope resolve();
/** True when the current authentication carries {@code ROLE_ADMIN}. */
static boolean hasAdminAuthority() {
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
return auth != null
&& auth.getAuthorities().stream()
.anyMatch(a -> "ROLE_ADMIN".equals(a.getAuthority()));
}
}
@@ -1,22 +0,0 @@
package stirling.software.proprietary.billing;
/**
* The billing / analytics axis for a metered operation. PAYG runs on a single flat-priced meter, so
* category is metadata only and never affects price.
*
* <p>Classification precedence is {@code AUTOMATION → AI → API → BYPASSED} (see {@link
* BillingCategoryClassifier}); {@link #BYPASSED} is a manual interactive tool call that is never
* billed.
*
* <p>Mirrors the value set of the SaaS {@code payg.model.BillingCategory}. A linked self-hosted
* instance reports usage per category to SaaS as the lower-case names ({@code api} / {@code ai} /
* {@code automation}) in the daily sync, and SaaS maps them back — so the two enums must keep the
* same names. (We deliberately do not share one enum across the modules: that would drag the SaaS
* billing enum through ~20 hot-path files for what is JSON-string metadata on the wire.)
*/
public enum BillingCategory {
BYPASSED,
API,
AI,
AUTOMATION
}
@@ -1,29 +0,0 @@
package stirling.software.proprietary.billing;
/**
* Pure precedence for bucketing a request into a {@link BillingCategory}, so the SaaS engine and a
* linked self-hosted instance classify identically. Each backend resolves the three signals from
* its own types — the automation marker header; an AI-surface signal (a {@code @RequiresFeature}
* annotation / route on SaaS, a path prefix on the instance); API-key authentication — and this
* applies the order {@code AUTOMATION → AI → API → BYPASSED}.
*
* <p>An AI tool dispatched inside a pipeline / workflow therefore bills as {@code AUTOMATION} (the
* automation header dominates), while a direct call to it bills as {@code AI}.
*/
public final class BillingCategoryClassifier {
private BillingCategoryClassifier() {}
public static BillingCategory classify(boolean automation, boolean ai, boolean apiKey) {
if (automation) {
return BillingCategory.AUTOMATION;
}
if (ai) {
return BillingCategory.AI;
}
if (apiKey) {
return BillingCategory.API;
}
return BillingCategory.BYPASSED;
}
}
@@ -1,68 +0,0 @@
package stirling.software.proprietary.billing;
import java.io.IOException;
import java.io.InputStream;
import java.nio.file.Files;
import java.nio.file.Path;
import java.security.DigestInputStream;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.HexFormat;
/**
* SHA-256 content fingerprint shared by the SaaS charge path and the linked self-hosted instance's
* meter (combined-billing "Mode A"), so both derive an <em>identical</em> signature for the same
* bytes — the basis for lineage dedup. Pure, no Spring: fixed 64 KiB buffer (allocation independent
* of file size), hardware-accelerated by the JVM where available.
*
* <p>Lives in {@code :proprietary} (not {@code :common}) so it stays out of the community core
* build yet is reachable from {@code :saas} (which depends on {@code :proprietary}).
*/
public final class ContentHasher {
private static final String ALGORITHM = "SHA-256";
private static final int BUFFER_SIZE = 64 * 1024;
private ContentHasher() {}
/** Lower-case hex SHA-256 of the file's bytes. */
public static String sha256(Path file) throws IOException {
MessageDigest digest = newDigest();
try (InputStream raw = Files.newInputStream(file);
DigestInputStream in = new DigestInputStream(raw, digest)) {
byte[] buf = new byte[BUFFER_SIZE];
while (in.read(buf) != -1) {
// drain through the digest; we only want the side effect
}
}
return HexFormat.of().formatHex(digest.digest());
}
/** Lower-case hex SHA-256 of the given bytes (e.g. to combine per-file hashes into one key). */
public static String sha256(byte[] bytes) {
return HexFormat.of().formatHex(newDigest().digest(bytes));
}
/**
* A fresh SHA-256 digest, for callers that stream bytes through a {@link
* java.security.DigestOutputStream} to hash in the same pass that writes the file — avoiding a
* second full read just to fingerprint it. Pair with {@link #toHex(byte[])}.
*/
public static MessageDigest newSha256() {
return newDigest();
}
/** Lower-case hex of a completed digest — the same format {@link #sha256(Path)} produces. */
public static String toHex(byte[] digest) {
return HexFormat.of().formatHex(digest);
}
private static MessageDigest newDigest() {
try {
return MessageDigest.getInstance(ALGORITHM);
} catch (NoSuchAlgorithmException e) {
// SHA-256 is mandated by every JDK; unreachable in practice.
throw new IllegalStateException(ALGORITHM + " unavailable — JDK is misconfigured", e);
}
}
}
@@ -1,76 +0,0 @@
package stirling.software.proprietary.billing;
import java.util.List;
/**
* Pure doc-unit math shared by the SaaS billing engine and a linked self-hosted instance, so both
* cost an operation identically. No Spring, no IO: callers supply page/byte facts (read however
* their backend reads them — e.g. jpdfium for PDFs) plus a {@link UnitCalcPolicy}.
*
* <p>Raw units for one file = the larger of {@code ceil(pages / docPagesPerUnit)} and {@code
* ceil(bytes / docBytesPerUnit)} (non-PDF inputs pass {@code pages = 0}, so only the bytes axis
* contributes). A single file is clamped to {@code [1, fileUnitCap]}; a multi-file group is the
* <em>raw</em> per-file sum clamped to {@code [1, fileUnitCap * file_count]} (summing raw, not
* per-file-clamped, units so the group cap can actually bind).
*
* <p>{@link UnitCalcPolicy#minChargeUnits()} is applied by the charge layer, not here; this
* enforces only an absolute floor of {@link #MIN_UNITS_PER_NONEMPTY_FILE} so callers can rely on
* "non-empty input → at least 1 unit". Extracted verbatim from the SaaS {@code
* DefaultDocumentClassifier} to preserve behaviour.
*/
public final class DocumentUnitCalculator {
/** Floor for non-empty input. Distinct from {@link UnitCalcPolicy#minChargeUnits()}. */
public static final int MIN_UNITS_PER_NONEMPTY_FILE = 1;
private DocumentUnitCalculator() {}
/** One file's page count (0 for non-PDF / unreadable) and byte size. */
public record FileSize(int pages, long bytes) {}
/** Raw (unclamped) units for one file. */
public static long rawUnits(int pages, long bytes, UnitCalcPolicy policy) {
long pageUnits = pages > 0 ? ceilDiv(pages, policy.docPagesPerUnit()) : 0L;
long byteUnits = ceilDiv(bytes, policy.docBytesPerUnit());
return Math.max(pageUnits, byteUnits);
}
/** Units for a single file, clamped to {@code [1, fileUnitCap]}. */
public static int unitsForFile(int pages, long bytes, UnitCalcPolicy policy) {
long raw = rawUnits(pages, bytes, policy);
// toIntExact: fail loud on overflow rather than silently wrapping a billing number.
return Math.toIntExact(
Math.max(MIN_UNITS_PER_NONEMPTY_FILE, Math.min(policy.fileUnitCap(), raw)));
}
/**
* Units for a multi-file group: raw per-file sum clamped to {@code [1, fileUnitCap * count]}.
*/
public static int unitsForGroup(List<FileSize> files, UnitCalcPolicy policy) {
if (files.isEmpty()) {
throw new IllegalArgumentException("files must not be empty");
}
long rawSum = 0;
for (FileSize f : files) {
rawSum = saturatedAdd(rawSum, rawUnits(f.pages(), f.bytes(), policy));
}
long groupCap = (long) policy.fileUnitCap() * files.size();
return Math.toIntExact(
Math.max((long) MIN_UNITS_PER_NONEMPTY_FILE, Math.min(groupCap, rawSum)));
}
private static long ceilDiv(long numerator, long divisor) {
if (numerator <= 0) {
return 0;
}
return (numerator + divisor - 1) / divisor;
}
private static long saturatedAdd(long a, long b) {
try {
return Math.addExact(a, b);
} catch (ArithmeticException e) {
return Long.MAX_VALUE;
}
}
}
@@ -1,30 +0,0 @@
package stirling.software.proprietary.billing;
/**
* The four billing knobs the doc-unit math needs, split out of the SaaS {@code PricingPolicy} JPA
* entity so the calculation ({@link DocumentUnitCalculator}) can live in {@code :proprietary} and
* be shared by the SaaS billing engine and a linked self-hosted instance — both then cost an
* operation identically.
*
* <p>The SaaS engine builds one from its persisted {@code PricingPolicy}; a linked instance
* receives these values in the daily entitlement sync. {@code minChargeUnits} is carried here for
* the charge layer; {@link DocumentUnitCalculator} itself does not apply it (see its docs).
*/
public record UnitCalcPolicy(
int docPagesPerUnit, long docBytesPerUnit, int minChargeUnits, int fileUnitCap) {
public UnitCalcPolicy {
if (docPagesPerUnit <= 0) {
throw new IllegalArgumentException("docPagesPerUnit must be > 0");
}
if (docBytesPerUnit <= 0) {
throw new IllegalArgumentException("docBytesPerUnit must be > 0");
}
if (minChargeUnits < 1) {
throw new IllegalArgumentException("minChargeUnits must be >= 1");
}
if (fileUnitCap < 1) {
throw new IllegalArgumentException("fileUnitCap must be >= 1");
}
}
}
@@ -1,136 +0,0 @@
package stirling.software.proprietary.classification;
import org.springframework.boot.autoconfigure.condition.ConditionalOnBooleanProperty;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.server.ResponseStatusException;
import io.swagger.v3.oas.annotations.Hidden;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import stirling.software.common.model.ApplicationProperties;
import stirling.software.common.service.UserServiceInterface;
import stirling.software.proprietary.classification.model.ClassificationLabels;
import stirling.software.proprietary.classification.model.LabelsValidator;
import stirling.software.proprietary.classification.store.ClassificationLabelStore;
import stirling.software.proprietary.classification.store.TeamLabelsEntity;
import stirling.software.proprietary.policy.config.PolicyManagementAuthority;
/**
* Read/write the team's classification label set — the flat vocabulary the document classifier runs
* against. Shared and team-scoped exactly like policies: every user reads their own team's labels,
* and only a user who may edit policies (a team leader on SaaS, the global admin self-hosted; see
* {@link PolicyManagementAuthority}) may change it — gated only when login is enabled, since
* single-user deployments trust the local operator. A team with no stored labels reads as {@code
* 204}; that team has no vocabulary, so its documents are not classified (there is no built-in
* default on the backend or the engine — the label data lives only in the frontend).
*/
@RestController
@RequestMapping("/api/v1/classification/labels")
@Hidden
@RequiredArgsConstructor
@Tag(name = "Classification", description = "Team-scoped document-classification labels")
@ConditionalOnBooleanProperty(name = "policies.enabled")
public class ClassificationLabelsController {
private final ClassificationLabelStore labelStore;
private final PolicyManagementAuthority policyManagementAuthority;
private final ApplicationProperties applicationProperties;
private final UserServiceInterface userService;
@GetMapping
@Operation(
summary = "Get the team's classification labels",
description =
"Returns the caller's team label set, or 204 when the team has none (its"
+ " documents are then not classified).")
public ResponseEntity<ClassificationLabels> getTeamLabels() {
return labelStore
.findByTeam(currentTeamId())
.map(ResponseEntity::ok)
.orElseGet(() -> ResponseEntity.noContent().build());
}
@PutMapping(consumes = MediaType.APPLICATION_JSON_VALUE)
@Operation(
summary = "Save the team's classification labels",
description =
"Validates and stores the label set for the caller's team, shared by everyone"
+ " on the team. Requires the policy-editor role for the team.")
public ResponseEntity<ClassificationLabels> saveTeamLabels(
@RequestBody ClassificationLabels labels) {
requireEditingAllowed();
validate(labels);
ClassificationLabels saved = labelStore.save(currentTeamId(), labels, currentUsername());
return ResponseEntity.ok(saved);
}
@DeleteMapping
@Operation(
summary = "Reset the team's classification labels",
description =
"Removes the team's stored label set; its documents are then not classified"
+ " until labels are saved again. Requires the policy-editor role for the"
+ " team.")
public ResponseEntity<Void> resetTeamLabels() {
requireEditingAllowed();
labelStore.deleteByTeam(currentTeamId());
return ResponseEntity.noContent().build();
}
private static void validate(ClassificationLabels labels) {
try {
LabelsValidator.validate(labels);
} catch (IllegalArgumentException e) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, e.getMessage());
}
}
/**
* Editing the team labels requires the editor role for the caller's team — the same gate
* policies use (team leader on SaaS, global admin self-hosted). Single-user deployments (login
* disabled) have no such role, so they trust the local operator.
*/
private void requireEditingAllowed() {
if (!applicationProperties.getSecurity().isEnableLogin()) {
return;
}
if (!policyManagementAuthority.canEditPolicies()) {
throw new ResponseStatusException(
HttpStatus.FORBIDDEN,
"The team classification labels may only be changed by a team leader");
}
}
/**
* The caller's team key. With login disabled the single operator owns the {@link
* TeamLabelsEntity#NO_TEAM} sentinel row; with login enabled a caller with no resolvable team
* is an error rather than being dropped into the shared sentinel bucket (which would let
* unteamed users read and overwrite each other's "team" labels).
*/
private Long currentTeamId() {
Long teamId = policyManagementAuthority.currentUserTeamId();
if (teamId != null) {
return teamId;
}
if (!applicationProperties.getSecurity().isEnableLogin()) {
return TeamLabelsEntity.NO_TEAM;
}
throw new ResponseStatusException(
HttpStatus.UNAUTHORIZED, "Could not resolve the current user's team");
}
private String currentUsername() {
return userService == null ? null : userService.getCurrentUsername();
}
}
@@ -0,0 +1,116 @@
package stirling.software.proprietary.classification;
import org.springframework.boot.autoconfigure.condition.ConditionalOnBooleanProperty;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.server.ResponseStatusException;
import io.swagger.v3.oas.annotations.Hidden;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import stirling.software.common.model.ApplicationProperties;
import stirling.software.common.service.UserServiceInterface;
import stirling.software.proprietary.classification.model.ClassificationTaxonomy;
import stirling.software.proprietary.classification.model.TaxonomyValidator;
import stirling.software.proprietary.classification.store.TaxonomyStore;
import stirling.software.proprietary.policy.config.PolicyManagementAuthority;
/**
* Read/write the caller's team classification taxonomy — the vocabulary the document classifier
* runs against. Team-scoped exactly like policies: every user reads their own team's taxonomy, and
* only a user who may edit policies (a team leader on SaaS, the global admin self-hosted; see
* {@link PolicyManagementAuthority}) may change it. Editing is gated only when login is enabled;
* single-user deployments trust the local operator. A team with no stored taxonomy reads as {@code
* 204} and the classifier falls back to the engine's built-in default.
*/
@RestController
@RequestMapping("/api/v1/classification/taxonomy")
@Hidden
@RequiredArgsConstructor
@Tag(name = "Classification", description = "Team-scoped document-classification taxonomy")
@ConditionalOnBooleanProperty(name = "policies.enabled")
public class TaxonomyController {
private final TaxonomyStore taxonomyStore;
private final PolicyManagementAuthority policyManagementAuthority;
private final ApplicationProperties applicationProperties;
private final UserServiceInterface userService;
@GetMapping
@Operation(
summary = "Get the team's classification taxonomy",
description =
"Returns the caller's team taxonomy, or 204 when the team has none (the"
+ " classifier then uses the built-in default).")
public ResponseEntity<ClassificationTaxonomy> getTaxonomy() {
return taxonomyStore
.findByTeam(currentTeamId())
.map(ResponseEntity::ok)
.orElseGet(() -> ResponseEntity.noContent().build());
}
@PutMapping(consumes = MediaType.APPLICATION_JSON_VALUE)
@Operation(
summary = "Save the team's classification taxonomy",
description =
"Validates and stores the taxonomy for the caller's team, shared by everyone on"
+ " the team. Requires the policy-editor role for the team.")
public ResponseEntity<ClassificationTaxonomy> saveTaxonomy(
@RequestBody ClassificationTaxonomy taxonomy) {
requireEditingAllowed();
try {
TaxonomyValidator.validate(taxonomy);
} catch (IllegalArgumentException e) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, e.getMessage());
}
ClassificationTaxonomy saved =
taxonomyStore.save(currentTeamId(), taxonomy, currentUsername());
return ResponseEntity.ok(saved);
}
@DeleteMapping
@Operation(
summary = "Reset the team's classification taxonomy",
description =
"Removes the team's stored taxonomy so the classifier falls back to the built-in"
+ " default. Requires the policy-editor role for the team.")
public ResponseEntity<Void> resetTaxonomy() {
requireEditingAllowed();
taxonomyStore.deleteByTeam(currentTeamId());
return ResponseEntity.noContent().build();
}
/**
* Editing the taxonomy requires the editor role for the caller's team — the same gate policies
* use (team leader on SaaS, global admin self-hosted). Single-user deployments (login disabled)
* have no such role, so they trust the local operator.
*/
private void requireEditingAllowed() {
if (!applicationProperties.getSecurity().isEnableLogin()) {
return;
}
if (!policyManagementAuthority.canEditPolicies()) {
throw new ResponseStatusException(
HttpStatus.FORBIDDEN,
"The classification taxonomy may only be changed by a team leader");
}
}
private Long currentTeamId() {
return policyManagementAuthority.currentUserTeamId();
}
private String currentUsername() {
return userService == null ? null : userService.getCurrentUsername();
}
}
@@ -1,9 +0,0 @@
package stirling.software.proprietary.classification.model;
/**
* One entry in the classification vocabulary. {@code id} is the label's stable identity (a slug,
* unique within a set): it is what the engine returns and what is stored on the document. {@code
* name} is the human display text the classifier model reasons over. {@code icon} is an optional
* presentational key (a Material Symbols name shown in the file sidebar); the engine never sees it.
*/
public record ClassificationLabel(String id, String name, String icon) {}
@@ -1,16 +0,0 @@
package stirling.software.proprietary.classification.model;
import java.util.List;
/**
* A flat multi-label classification vocabulary — the set of labels a document may be assigned.
* Stored per team (admin-edited, shared by everyone on the team); the classifier runs against these
* label names. A team with no stored set has no vocabulary, so its documents are not classified —
* neither the backend nor the engine holds a default of its own.
*/
public record ClassificationLabels(List<ClassificationLabel> labels) {
public ClassificationLabels {
labels = labels == null ? List.of() : List.copyOf(labels);
}
}
@@ -0,0 +1,18 @@
package stirling.software.proprietary.classification.model;
import java.util.List;
/**
* The vocabulary a document is classified against — team-scoped and admin-editable. Its shape
* mirrors the engine's {@code ClassificationTaxonomy} contract (categories owning doc_types, plus
* free-standing cross-cutting tags), so a stored taxonomy is passed to the engine verbatim as the
* per-request override. When a team has no stored taxonomy the engine falls back to its built-in
* default.
*/
public record ClassificationTaxonomy(List<TaxonomyCategory> categories, List<String> tags) {
public ClassificationTaxonomy {
categories = categories == null ? List.of() : List.copyOf(categories);
tags = tags == null ? List.of() : List.copyOf(tags);
}
}
@@ -1,71 +0,0 @@
package stirling.software.proprietary.classification.model;
import java.util.HashSet;
import java.util.Locale;
import java.util.Set;
import java.util.regex.Pattern;
/**
* Structural validation for a user- or admin-supplied label set, run before it is stored so a
* malformed vocabulary can never reach the classifier. Mirrors the invariants the engine relies on:
* non-blank ids and names, each unique within the set (ids exactly, names case-insensitively).
*/
public final class LabelsValidator {
private LabelsValidator() {}
// Generous upper bounds so a legitimate label set is never blocked, but a single team or user
// can't store an unbounded blob that would bloat the row, balloon the classifier prompt, or
// exhaust memory on deserialize.
static final int MAX_LABELS = 500;
static final int MAX_TEXT_LENGTH = 128;
// Icon is a Material Symbols key (lowercase, digits, hyphens). Enforce the SHAPE server-side —
// the exact allowlist lives in the frontend — so a client bypassing the UI can't store
// arbitrary
// text that would render as garbage (or worse) in every teammate's sidebar.
private static final Pattern ICON_KEY = Pattern.compile("^[a-z0-9-]+$");
/**
* @throws IllegalArgumentException with a human-readable message when the label set is invalid.
*/
public static void validate(ClassificationLabels labels) {
if (labels == null || labels.labels() == null) {
throw new IllegalArgumentException("Labels are required");
}
if (labels.labels().size() > MAX_LABELS) {
throw new IllegalArgumentException("Too many labels (max " + MAX_LABELS + ")");
}
Set<String> ids = new HashSet<>();
Set<String> names = new HashSet<>();
for (ClassificationLabel label : labels.labels()) {
requireText(label.id(), "Label id");
requireText(label.name(), "Label name");
if (label.icon() != null && !label.icon().isEmpty()) {
if (label.icon().length() > MAX_TEXT_LENGTH) {
throw new IllegalArgumentException(
"Label icon is too long (max " + MAX_TEXT_LENGTH + " characters)");
}
if (!ICON_KEY.matcher(label.icon()).matches()) {
throw new IllegalArgumentException("Invalid label icon: " + label.icon());
}
}
if (!ids.add(label.id().trim())) {
throw new IllegalArgumentException("Duplicate label id: " + label.id());
}
if (!names.add(label.name().trim().toLowerCase(Locale.ROOT))) {
throw new IllegalArgumentException("Duplicate label name: " + label.name());
}
}
}
private static void requireText(String value, String field) {
if (value == null || value.isBlank()) {
throw new IllegalArgumentException(field + " must not be blank");
}
if (value.trim().length() > MAX_TEXT_LENGTH) {
throw new IllegalArgumentException(
field + " is too long (max " + MAX_TEXT_LENGTH + " characters)");
}
}
}
@@ -0,0 +1,15 @@
package stirling.software.proprietary.classification.model;
import java.util.List;
/**
* A structural family of documents, owning the doc_types shaped like it. {@code docTypes} is
* serialized in the engine's camelCase shape (the engine's {@code ClassificationTaxonomy} model
* aliases {@code doc_types} onto it), so a stored taxonomy passes straight through to the engine.
*/
public record TaxonomyCategory(String id, String label, List<TaxonomyDocumentType> docTypes) {
public TaxonomyCategory {
docTypes = docTypes == null ? List.of() : List.copyOf(docTypes);
}
}
@@ -0,0 +1,7 @@
package stirling.software.proprietary.classification.model;
/**
* A specific instrument within a category (e.g. {@code nda} under {@code contract}).
* Category-scoped: the engine enforces that a doc_type can only apply to its owning category.
*/
public record TaxonomyDocumentType(String id, String label) {}
@@ -0,0 +1,87 @@
package stirling.software.proprietary.classification.model;
import java.util.HashSet;
import java.util.Set;
/**
* Structural validation for an admin-supplied (or imported) taxonomy, run before it is stored so a
* malformed vocabulary can never reach the classifier. Mirrors the invariants the engine relies on:
* at least one category, non-blank ids/labels everywhere, ids unique among categories and among the
* doc_types within a category, and non-blank unique tags.
*/
public final class TaxonomyValidator {
private TaxonomyValidator() {}
// Generous upper bounds so a legitimate taxonomy is never blocked, but a single team can't
// store an unbounded blob that would bloat the row, balloon the classifier prompt, or exhaust
// memory on deserialize.
static final int MAX_CATEGORIES = 200;
static final int MAX_DOC_TYPES_PER_CATEGORY = 200;
static final int MAX_TAGS = 500;
static final int MAX_TEXT_LENGTH = 128;
/**
* @throws IllegalArgumentException with a human-readable message when the taxonomy is invalid.
*/
public static void validate(ClassificationTaxonomy taxonomy) {
if (taxonomy == null) {
throw new IllegalArgumentException("Taxonomy is required");
}
if (taxonomy.categories().isEmpty()) {
throw new IllegalArgumentException("Taxonomy must have at least one category");
}
if (taxonomy.categories().size() > MAX_CATEGORIES) {
throw new IllegalArgumentException("Too many categories (max " + MAX_CATEGORIES + ")");
}
if (taxonomy.tags().size() > MAX_TAGS) {
throw new IllegalArgumentException("Too many tags (max " + MAX_TAGS + ")");
}
Set<String> categoryIds = new HashSet<>();
for (TaxonomyCategory category : taxonomy.categories()) {
requireText(category.id(), "Category id");
requireText(category.label(), "Category label");
if (category.docTypes().size() > MAX_DOC_TYPES_PER_CATEGORY) {
throw new IllegalArgumentException(
"Too many sub-categories in '"
+ category.id()
+ "' (max "
+ MAX_DOC_TYPES_PER_CATEGORY
+ ")");
}
if (!categoryIds.add(category.id())) {
throw new IllegalArgumentException("Duplicate category id: " + category.id());
}
Set<String> docTypeIds = new HashSet<>();
for (TaxonomyDocumentType docType : category.docTypes()) {
requireText(docType.id(), "Doc type id");
requireText(docType.label(), "Doc type label");
if (!docTypeIds.add(docType.id())) {
throw new IllegalArgumentException(
"Duplicate doc type id '"
+ docType.id()
+ "' in category '"
+ category.id()
+ "'");
}
}
}
Set<String> tags = new HashSet<>();
for (String tag : taxonomy.tags()) {
requireText(tag, "Tag");
if (!tags.add(tag)) {
throw new IllegalArgumentException("Duplicate tag: " + tag);
}
}
}
private static void requireText(String value, String field) {
if (value == null || value.isBlank()) {
throw new IllegalArgumentException(field + " must not be blank");
}
if (value.length() > MAX_TEXT_LENGTH) {
throw new IllegalArgumentException(
field + " is too long (max " + MAX_TEXT_LENGTH + " characters)");
}
}
}
@@ -1,22 +0,0 @@
package stirling.software.proprietary.classification.store;
import java.util.Optional;
import stirling.software.proprietary.classification.model.ClassificationLabels;
/**
* Stores one {@link ClassificationLabels} set per team. A {@code null} teamId addresses the
* unteamed set (login disabled / no resolvable team), mirroring how the policy store treats a null
* team.
*/
public interface ClassificationLabelStore {
/** The team's stored labels, or empty when it has none (callers then skip classification). */
Optional<ClassificationLabels> findByTeam(Long teamId);
/** Create or replace the team's labels. Returns the stored value. */
ClassificationLabels save(Long teamId, ClassificationLabels labels, String updatedBy);
/** Remove the team's labels (reset to default). Returns whether a set existed. */
boolean deleteByTeam(Long teamId);
}
@@ -1,36 +0,0 @@
package stirling.software.proprietary.classification.store;
import java.util.Map;
import java.util.Optional;
import java.util.concurrent.ConcurrentHashMap;
import stirling.software.proprietary.classification.model.ClassificationLabels;
/**
* In-memory {@link ClassificationLabelStore} for tests and any future no-database mode. {@link
* JpaClassificationLabelStore} is the runtime bean.
*/
public class InProcessClassificationLabelStore implements ClassificationLabelStore {
private final Map<Long, ClassificationLabels> byTeam = new ConcurrentHashMap<>();
@Override
public Optional<ClassificationLabels> findByTeam(Long teamId) {
return Optional.ofNullable(byTeam.get(key(teamId)));
}
@Override
public ClassificationLabels save(Long teamId, ClassificationLabels labels, String updatedBy) {
byTeam.put(key(teamId), labels);
return labels;
}
@Override
public boolean deleteByTeam(Long teamId) {
return byTeam.remove(key(teamId)) != null;
}
private static long key(Long teamId) {
return teamId == null ? TeamLabelsEntity.NO_TEAM : teamId;
}
}
@@ -0,0 +1,37 @@
package stirling.software.proprietary.classification.store;
import java.util.Map;
import java.util.Optional;
import java.util.concurrent.ConcurrentHashMap;
import stirling.software.proprietary.classification.model.ClassificationTaxonomy;
/**
* In-memory {@link TaxonomyStore} for tests and any future no-database mode. {@link
* JpaTaxonomyStore} is the runtime bean.
*/
public class InProcessTaxonomyStore implements TaxonomyStore {
private final Map<Long, ClassificationTaxonomy> byTeam = new ConcurrentHashMap<>();
@Override
public Optional<ClassificationTaxonomy> findByTeam(Long teamId) {
return Optional.ofNullable(byTeam.get(key(teamId)));
}
@Override
public ClassificationTaxonomy save(
Long teamId, ClassificationTaxonomy taxonomy, String updatedBy) {
byTeam.put(key(teamId), taxonomy);
return taxonomy;
}
@Override
public boolean deleteByTeam(Long teamId) {
return byTeam.remove(key(teamId)) != null;
}
private static long key(Long teamId) {
return teamId == null ? TaxonomyEntity.NO_TEAM : teamId;
}
}
@@ -1,76 +0,0 @@
package stirling.software.proprietary.classification.store;
import java.time.Instant;
import java.util.Optional;
import org.springframework.boot.autoconfigure.condition.ConditionalOnBooleanProperty;
import org.springframework.stereotype.Service;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import stirling.software.proprietary.classification.model.ClassificationLabels;
import tools.jackson.core.JacksonException;
import tools.jackson.databind.ObjectMapper;
/**
* Durable {@link ClassificationLabelStore} backed by JPA; the runtime store. Gated on {@code
* policies.enabled} — stored labels only matter when the Classification policy can run — so it
* shares the policy subsystem's on/off switch. Each label set is persisted as JSON via {@link
* TeamLabelsEntity}.
*/
@Slf4j
@Service
@RequiredArgsConstructor
@ConditionalOnBooleanProperty(name = "policies.enabled")
public class JpaClassificationLabelStore implements ClassificationLabelStore {
private final TeamLabelsRepository teamRepository;
private final ObjectMapper objectMapper;
@Override
public Optional<ClassificationLabels> findByTeam(Long teamId) {
return teamRepository
.findById(key(teamId))
.flatMap(entity -> parse(entity.getLabelsJson(), "team " + teamId));
}
@Override
public ClassificationLabels save(Long teamId, ClassificationLabels labels, String updatedBy) {
TeamLabelsEntity entity = new TeamLabelsEntity();
entity.setTeamId(key(teamId));
entity.setLabelsJson(objectMapper.writeValueAsString(labels));
entity.setUpdatedAt(Instant.now());
entity.setUpdatedBy(updatedBy);
teamRepository.save(entity);
return labels;
}
@Override
public boolean deleteByTeam(Long teamId) {
long id = key(teamId);
if (!teamRepository.existsById(id)) {
return false;
}
teamRepository.deleteById(id);
return true;
}
private Optional<ClassificationLabels> parse(String json, String owner) {
try {
return Optional.of(objectMapper.readValue(json, ClassificationLabels.class));
} catch (JacksonException e) {
// A stored label set that no longer parses (corruption / manual DB edit) must not break
// classification: drop it so the caller treats the team as having no labels (and skips
// classification) rather than surfacing a 500 on every upload.
log.warn("Discarding unparseable stored labels for {}: {}", owner, e.getMessage());
return Optional.empty();
}
}
/** Map the nullable team id onto the entity's non-null key (sentinel for the unteamed case). */
private static long key(Long teamId) {
return teamId == null ? TeamLabelsEntity.NO_TEAM : teamId;
}
}
@@ -0,0 +1,79 @@
package stirling.software.proprietary.classification.store;
import java.time.Instant;
import java.util.Optional;
import org.springframework.boot.autoconfigure.condition.ConditionalOnBooleanProperty;
import org.springframework.stereotype.Service;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import stirling.software.proprietary.classification.model.ClassificationTaxonomy;
import tools.jackson.core.JacksonException;
import tools.jackson.databind.ObjectMapper;
/**
* Durable {@link TaxonomyStore} backed by JPA; the runtime store. Gated on {@code policies.enabled}
* — a team taxonomy only matters when the Classification policy can run — so it shares the policy
* subsystem's on/off switch. The taxonomy is persisted as JSON via {@link TaxonomyEntity}.
*/
@Slf4j
@Service
@RequiredArgsConstructor
@ConditionalOnBooleanProperty(name = "policies.enabled")
public class JpaTaxonomyStore implements TaxonomyStore {
private final TaxonomyRepository repository;
private final ObjectMapper objectMapper;
@Override
public Optional<ClassificationTaxonomy> findByTeam(Long teamId) {
Optional<TaxonomyEntity> entity = repository.findById(key(teamId));
if (entity.isEmpty()) {
return Optional.empty();
}
try {
return Optional.of(
objectMapper.readValue(
entity.get().getTaxonomyJson(), ClassificationTaxonomy.class));
} catch (JacksonException e) {
// A stored taxonomy that no longer parses (corruption / manual DB edit) must not break
// classification: drop it so the caller falls back to the built-in default rather than
// surfacing a 500 on every upload for the team.
log.warn(
"Discarding unparseable stored taxonomy for team {}: {}",
teamId,
e.getMessage());
return Optional.empty();
}
}
@Override
public ClassificationTaxonomy save(
Long teamId, ClassificationTaxonomy taxonomy, String updatedBy) {
TaxonomyEntity entity = new TaxonomyEntity();
entity.setTeamId(key(teamId));
entity.setTaxonomyJson(objectMapper.writeValueAsString(taxonomy));
entity.setUpdatedAt(Instant.now());
entity.setUpdatedBy(updatedBy);
repository.save(entity);
return taxonomy;
}
@Override
public boolean deleteByTeam(Long teamId) {
long id = key(teamId);
if (!repository.existsById(id)) {
return false;
}
repository.deleteById(id);
return true;
}
/** Map the nullable team id onto the entity's non-null key (sentinel for the unteamed case). */
private static long key(Long teamId) {
return teamId == null ? TaxonomyEntity.NO_TEAM : teamId;
}
}
@@ -13,31 +13,31 @@ import lombok.NoArgsConstructor;
import lombok.Setter;
/**
* JPA row for a team's classification labels one row per team. The label set lives as JSON in
* {@code labelsJson} (authoritative on read). {@code teamId} is the natural key; the sentinel
* JPA row for a team's classification taxonomy one row per team. The taxonomy lives as JSON in
* {@code taxonomyJson} (authoritative on read). {@code teamId} is the natural key; the sentinel
* {@link #NO_TEAM} stands in for the unteamed (login-disabled / self-hosted single-team) case,
* since a primary key can't be null (policies store a nullable {@code team_id}, but this table is
* keyed one-per-team). Kept decoupled from the security entities {@code teamId} is a plain value,
* not a foreign key so classification can be enabled or disabled without touching them.
*/
@Entity
@Table(name = "classification_labels")
@Table(name = "classification_taxonomies")
@NoArgsConstructor
@Getter
@Setter
public class TeamLabelsEntity implements Serializable {
public class TaxonomyEntity implements Serializable {
private static final long serialVersionUID = 1L;
/** Sentinel key for the unteamed label set (login disabled / no resolvable team). */
/** Sentinel key for the unteamed taxonomy (login disabled / no resolvable team). */
public static final long NO_TEAM = 0L;
@Id
@Column(name = "team_id")
private long teamId;
@Column(name = "labels_json", columnDefinition = "text")
private String labelsJson;
@Column(name = "taxonomy_json", columnDefinition = "text")
private String taxonomyJson;
@Column(name = "updated_at")
private Instant updatedAt;
@@ -4,4 +4,4 @@ import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
@Repository
public interface TeamLabelsRepository extends JpaRepository<TeamLabelsEntity, Long> {}
public interface TaxonomyRepository extends JpaRepository<TaxonomyEntity, Long> {}
@@ -0,0 +1,22 @@
package stirling.software.proprietary.classification.store;
import java.util.Optional;
import stirling.software.proprietary.classification.model.ClassificationTaxonomy;
/**
* Stores one {@link ClassificationTaxonomy} per team. A {@code null} teamId addresses the unteamed
* taxonomy (login disabled / no resolvable team), mirroring how the policy store treats a null
* team.
*/
public interface TaxonomyStore {
/** The team's stored taxonomy, or empty when it has none (callers fall back to the default). */
Optional<ClassificationTaxonomy> findByTeam(Long teamId);
/** Create or replace the team's taxonomy. Returns the stored value. */
ClassificationTaxonomy save(Long teamId, ClassificationTaxonomy taxonomy, String updatedBy);
/** Remove the team's taxonomy (reset to default). Returns whether one existed. */
boolean deleteByTeam(Long teamId);
}
@@ -60,8 +60,6 @@ public class CustomAuditEventRepository implements AuditEventRepository {
clean.put("requestId", rid);
}
String source = MDC.get("auditSource");
String auditEventData = mapper.writeValueAsString(clean);
log.debug("AuditEvent data (JSON): {}", auditEventData);
@@ -69,7 +67,6 @@ public class CustomAuditEventRepository implements AuditEventRepository {
PersistentAuditEvent.builder()
.principal(safePrincipal(ev.getPrincipal()))
.type(ev.getType())
.source(source)
.data(auditEventData)
.timestamp(ev.getTimestamp())
.build();
@@ -2,10 +2,8 @@ package stirling.software.proprietary.controller.api;
import java.io.IOException;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import org.apache.pdfbox.pdmodel.PDDocument;
@@ -19,6 +17,8 @@ import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.multipart.MultipartFile;
import com.fasterxml.jackson.annotation.JsonInclude;
import io.github.pixee.security.Filenames;
import io.swagger.v3.oas.annotations.Hidden;
import io.swagger.v3.oas.annotations.Operation;
@@ -31,8 +31,7 @@ import stirling.software.common.service.PdfMetadataService;
import stirling.software.common.service.UserServiceInterface;
import stirling.software.common.util.TempFileManager;
import stirling.software.common.util.WebResponseUtils;
import stirling.software.proprietary.classification.model.ClassificationLabel;
import stirling.software.proprietary.classification.store.ClassificationLabelStore;
import stirling.software.proprietary.classification.store.TaxonomyStore;
import stirling.software.proprietary.model.api.ai.AiPageText;
import stirling.software.proprietary.policy.config.PolicyManagementAuthority;
import stirling.software.proprietary.service.AiEngineClient;
@@ -46,17 +45,17 @@ import tools.jackson.databind.node.ObjectNode;
* Dispatchable tool that classifies a PDF and writes the result into its metadata.
*
* <p>Runs as a Classification-policy pipeline step: it reads a bounded page window, asks the AI
* engine to classify the document against the caller's team label set, and stores the engine's JSON
* answer minus the transport-only {@code outcome} field in the custom Info-dictionary key
* {@link PdfMetadataService#CLASSIFICATION_KEY}. Returns the labelled PDF. Not intended for direct
* client use.
* engine to classify the document, and stores the engine's JSON answer minus the transport-only
* {@code outcome} field in the custom Info-dictionary key {@link
* PdfMetadataService#CLASSIFICATION_KEY}. Returns the tagged PDF. Not intended for direct client
* use.
*/
@Slf4j
@Hidden
@RestController
@RequestMapping("/api/v1/ai/tools")
@Tag(name = "AI Tools", description = "Dispatchable AI-backed tools.")
public class ClassifyLabelController {
public class ClassifyTagController {
/** Pages read from each end of the document — mirrors the engine's window. */
private static final int WINDOW_PAGES = 2;
@@ -73,14 +72,14 @@ public class ClassifyLabelController {
/**
* Present only when the policy subsystem is enabled ({@code policies.enabled}); the store and
* team authority are gated on it. Null otherwise, in which case there are no team labels to
* classify against and the document is passed through unlabelled.
* team authority are gated on it. Null otherwise, in which case classification falls back to
* the engine's built-in default taxonomy.
*/
private final ClassificationLabelStore labelStore;
private final TaxonomyStore taxonomyStore;
private final PolicyManagementAuthority policyManagementAuthority;
public ClassifyLabelController(
public ClassifyTagController(
CustomPDFDocumentFactory pdfDocumentFactory,
TempFileManager tempFileManager,
PdfContentExtractor pdfContentExtractor,
@@ -88,7 +87,7 @@ public class ClassifyLabelController {
AiEngineClient aiEngineClient,
ObjectMapper objectMapper,
@Autowired(required = false) UserServiceInterface userService,
@Autowired(required = false) ClassificationLabelStore labelStore,
@Autowired(required = false) TaxonomyStore taxonomyStore,
@Autowired(required = false) PolicyManagementAuthority policyManagementAuthority) {
this.pdfDocumentFactory = pdfDocumentFactory;
this.tempFileManager = tempFileManager;
@@ -97,41 +96,33 @@ public class ClassifyLabelController {
this.aiEngineClient = aiEngineClient;
this.objectMapper = objectMapper;
this.userService = userService;
this.labelStore = labelStore;
this.taxonomyStore = taxonomyStore;
this.policyManagementAuthority = policyManagementAuthority;
}
@PostMapping(value = "/classify-and-label", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
@PostMapping(value = "/classify-and-tag", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
@Operation(
summary = "Classify a PDF and label its metadata",
summary = "Classify a PDF and tag its metadata",
description =
"Reads the first two and last two pages, classifies the document via the AI"
+ " engine, and stores the result in the StirlingPDFClassification"
+ " metadata field. Dispatched by the Classification policy; not"
+ " intended for direct client use.")
public ResponseEntity<Resource> classifyAndLabel(
public ResponseEntity<Resource> classifyAndTag(
@RequestParam("fileInput") MultipartFile fileInput) throws IOException {
try (PDDocument document = pdfDocumentFactory.load(fileInput, true)) {
String fileName = safeFileName(fileInput.getOriginalFilename());
List<EngineLabel> allowed = resolveAllowedLabels();
if (allowed.isEmpty()) {
// No vocabulary to classify against (the team stored no labels): pass the file
// through unlabelled rather than ask the engine to classify against nothing.
log.debug("[classify-and-label] {} has no team labels; skipping", fileName);
return WebResponseUtils.pdfDocToWebResponse(document, fileName, tempFileManager);
}
List<AiPageText> pages = extractWindow(document);
String requestBody =
objectMapper.writeValueAsString(
new ClassifyEngineRequest(fileName, pages, allowed));
new ClassifyEngineRequest(fileName, pages, resolveTaxonomyOverride()));
String userId = userService != null ? userService.getCurrentUsername() : null;
String responseJson = aiEngineClient.post(CLASSIFY_ENDPOINT, requestBody, userId);
pdfMetadataService.setClassificationMetadata(document, toMetadataValue(responseJson));
log.debug("[classify-and-label] labelled {} ({} window pages)", fileName, pages.size());
log.debug("[classify-and-tag] tagged {} ({} window pages)", fileName, pages.size());
return WebResponseUtils.pdfDocToWebResponse(document, fileName, tempFileManager);
}
@@ -175,44 +166,28 @@ public class ClassifyLabelController {
}
/**
* The allowed labels for the caller's team as {@code {id, name}} pairs, de-duplicated by id.
* The engine shows the model the names and returns the ids (icons are presentational and never
* sent). Returns an empty list the caller then skips classification when the policy
* subsystem is disabled (no store) or the team has no stored labels. The engine holds no
* default vocabulary of its own, so a team's stored labels are the only source.
* Resolve the caller's team taxonomy and return it in the engine's shape to classify against;
* {@code null} falls back to the engine's generated default. The stored taxonomy is already in
* the engine's camelCase shape ({@code categories}/{@code docTypes}/{@code tags}), so it is
* passed through verbatim. Returns null when the policy subsystem is disabled (no store), when
* the team has no stored taxonomy, or when the team can't be resolved.
*/
private List<EngineLabel> resolveAllowedLabels() {
if (labelStore == null) {
return List.of();
private JsonNode resolveTaxonomyOverride() {
if (taxonomyStore == null) {
return null;
}
Long teamId =
policyManagementAuthority == null
? null
: policyManagementAuthority.currentUserTeamId();
Map<String, EngineLabel> byId = new LinkedHashMap<>();
labelStore.findByTeam(teamId).ifPresent(labels -> collectLabels(labels.labels(), byId));
return List.copyOf(byId.values());
return taxonomyStore
.findByTeam(teamId)
.map(taxonomy -> (JsonNode) objectMapper.valueToTree(taxonomy))
.orElse(null);
}
private static void collectLabels(
List<ClassificationLabel> labels, Map<String, EngineLabel> into) {
for (ClassificationLabel label : labels) {
if (label.id() == null
|| label.id().isBlank()
|| label.name() == null
|| label.name().isBlank()) {
continue;
}
into.putIfAbsent(label.id(), new EngineLabel(label.id(), label.name()));
}
}
/** One allowed label sent to the engine: stable id + the name the model reasons over. */
private record EngineLabel(String id, String name) {}
/** Request body for the engine's {@code /api/v1/documents/classify} endpoint. */
@JsonInclude(JsonInclude.Include.NON_NULL)
private record ClassifyEngineRequest(
String fileName, List<AiPageText> pages, List<EngineLabel> labels) {}
String fileName, List<AiPageText> pages, JsonNode taxonomy) {}
}

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