Merge branch 'main' into bump_gradle_961
This commit is contained in:
@@ -0,0 +1,97 @@
|
||||
---
|
||||
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.
|
||||
@@ -0,0 +1,122 @@
|
||||
---
|
||||
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.
|
||||
@@ -0,0 +1,67 @@
|
||||
// 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 });
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
// 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 : ""}`);
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
"""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 )")
|
||||
@@ -0,0 +1,106 @@
|
||||
<!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 {"&":"&","<":"<",">":">"}[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 · base <code>'+esc(D.base)+'</code> → head <code>'+esc(D.head)+'</code>'+
|
||||
(D.cropSelector ? ' · 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>
|
||||
@@ -0,0 +1,25 @@
|
||||
// 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();
|
||||
@@ -0,0 +1,120 @@
|
||||
---
|
||||
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.
|
||||
@@ -0,0 +1,116 @@
|
||||
"""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"))
|
||||
@@ -0,0 +1,298 @@
|
||||
<!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">‹</button>
|
||||
<img id="stage-img" alt="" />
|
||||
<div class="missing hide" id="missing"></div>
|
||||
<button class="nav-btn next" id="next" aria-label="Next">›</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,6 +1,6 @@
|
||||
# Maintainer: Stirling PDF Inc <contact@stirlingpdf.com>
|
||||
pkgname=stirling-pdf-desktop
|
||||
pkgver=2.14.0
|
||||
pkgver=2.14.1
|
||||
pkgrel=1
|
||||
pkgdesc="Locally hosted, web-based PDF manipulation tool (Tauri desktop app, official Stirling PDF Inc build)"
|
||||
arch=('x86_64')
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Maintainer: Stirling PDF Inc <contact@stirlingpdf.com>
|
||||
pkgname=stirling-pdf-server-bin
|
||||
pkgver=2.14.0
|
||||
pkgver=2.14.1
|
||||
pkgrel=1
|
||||
pkgdesc="Locally hosted, web-based PDF manipulation tool (server JAR, prebuilt)"
|
||||
arch=('any')
|
||||
|
||||
@@ -87,6 +87,21 @@ 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"
|
||||
|
||||
@@ -116,6 +116,9 @@ 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
|
||||
@@ -246,7 +249,9 @@ 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-args: |
|
||||
VERSION_TAG=v2-alpha
|
||||
BUILD_PORTAL=${{ env.BUILD_PORTAL }}
|
||||
platforms: linux/amd64
|
||||
|
||||
- name: Build and push V2 image (Docker fork fallback)
|
||||
@@ -259,7 +264,9 @@ 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-args: |
|
||||
VERSION_TAG=v2-alpha
|
||||
BUILD_PORTAL=${{ env.BUILD_PORTAL }}
|
||||
platforms: linux/amd64
|
||||
|
||||
- name: Set up SSH
|
||||
@@ -290,6 +297,8 @@ 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 }}"
|
||||
@@ -359,12 +368,19 @@ jobs:
|
||||
}
|
||||
|
||||
const deploymentUrl = `http://${{ secrets.NEW_VPS_HOST }}:${v2Port}`;
|
||||
const httpsUrl = `https://${v2Port}.ssl.stirlingpdf.cloud`;
|
||||
|
||||
// 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`
|
||||
: ``;
|
||||
|
||||
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` +
|
||||
`🔐 **Secure HTTPS URL**: [${httpsUrl}](${httpsUrl})\n\n` +
|
||||
`🔐 **Secure HTTPS URL**: unsupported currently\n\n` +
|
||||
portalNote +
|
||||
`_This deployment will be automatically cleaned up when the PR is closed._\n\n` +
|
||||
`🔄 **Auto-deployed** for approved V2 contributors.`;
|
||||
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
name: AI Engine CI
|
||||
|
||||
# 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.
|
||||
# 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.
|
||||
on:
|
||||
workflow_call:
|
||||
push:
|
||||
@@ -34,104 +34,9 @@ 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.1
|
||||
|
||||
- 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
|
||||
|
||||
@@ -167,6 +167,8 @@ 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()
|
||||
@@ -240,6 +242,8 @@ 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()
|
||||
@@ -270,6 +274,8 @@ 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()
|
||||
@@ -282,6 +288,19 @@ 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
|
||||
|
||||
@@ -43,6 +43,7 @@ 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)
|
||||
@@ -171,6 +172,20 @@ 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:
|
||||
@@ -228,6 +243,7 @@ jobs:
|
||||
- test-build-docker-images
|
||||
- tauri-build
|
||||
- ai-engine
|
||||
- generated-models
|
||||
- pre-commit
|
||||
- dependency-review
|
||||
runs-on: ubuntu-latest
|
||||
@@ -253,6 +269,7 @@ 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: |
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
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,
|
||||
});
|
||||
}
|
||||
@@ -62,7 +62,17 @@ 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
|
||||
|
||||
@@ -44,7 +44,18 @@ 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
|
||||
|
||||
@@ -396,6 +396,23 @@ 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]
|
||||
|
||||
@@ -185,6 +185,16 @@ 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
|
||||
# ============================================================
|
||||
|
||||
@@ -57,6 +57,15 @@ public class RequestUriUtils {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Admin portal SPA shell. Served publicly like the editor root so a direct
|
||||
// nav / refresh to /portal 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("/portal") || normalizedUri.startsWith("/portal/")) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Treat common static file extensions as static resources
|
||||
return normalizedUri.endsWith(".svg")
|
||||
|| normalizedUri.endsWith(".png")
|
||||
|
||||
@@ -73,6 +73,14 @@ class RequestUriUtilsTest {
|
||||
assertTrue(RequestUriUtils.isStaticResource("/mobile-scanner"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void testIsStaticResource_portalShell() {
|
||||
// The admin portal SPA shell is served pre-auth so it's directly navigable.
|
||||
assertTrue(RequestUriUtils.isStaticResource("/portal"));
|
||||
assertTrue(RequestUriUtils.isStaticResource("/portal/users"));
|
||||
assertTrue(RequestUriUtils.isStaticResource("/app", "/app/portal"));
|
||||
}
|
||||
|
||||
// --- isFrontendRoute tests ---
|
||||
|
||||
@Test
|
||||
|
||||
+11
-1
@@ -175,6 +175,14 @@ 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/.
|
||||
@@ -297,9 +305,11 @@ 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=/)"
|
||||
println "Building editor frontend application for production (mode=${frontendMode}, VITE_API_BASE_URL=/, portal=${buildWithPortal})"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+34
-12
@@ -1,5 +1,7 @@
|
||||
package stirling.software.SPDF.model.api.general;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
|
||||
import lombok.Data;
|
||||
@@ -17,20 +19,8 @@ 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(
|
||||
@@ -38,4 +28,36 @@ 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;
|
||||
}
|
||||
}
|
||||
|
||||
+2
-1
@@ -29,7 +29,8 @@ public class AddPasswordRequest extends PDFFile {
|
||||
description = "The length of the encryption key",
|
||||
type = "integer",
|
||||
allowableValues = {"40", "128", "256"},
|
||||
requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
requiredMode = Schema.RequiredMode.NOT_REQUIRED,
|
||||
defaultValue = "256")
|
||||
private int keyLength = 256;
|
||||
|
||||
@Schema(description = "Whether document assembly is prevented", defaultValue = "false")
|
||||
|
||||
+10
@@ -130,6 +130,7 @@ 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
|
||||
@@ -161,6 +162,14 @@ 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
|
||||
@@ -247,6 +256,7 @@ public class ControllerAuditAspect {
|
||||
} finally {
|
||||
restoreMdcValue("auditPrincipal", previousPrincipal);
|
||||
restoreMdcValue("auditOrigin", previousOrigin);
|
||||
restoreMdcValue("auditSource", previousSource);
|
||||
restoreMdcValue("auditIp", previousIp);
|
||||
}
|
||||
}
|
||||
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
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();
|
||||
}
|
||||
}
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
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) {}
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
+18
@@ -0,0 +1,18 @@
|
||||
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()));
|
||||
}
|
||||
}
|
||||
+3
@@ -60,6 +60,8 @@ 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);
|
||||
|
||||
@@ -67,6 +69,7 @@ public class CustomAuditEventRepository implements AuditEventRepository {
|
||||
PersistentAuditEvent.builder()
|
||||
.principal(safePrincipal(ev.getPrincipal()))
|
||||
.type(ev.getType())
|
||||
.source(source)
|
||||
.data(auditEventData)
|
||||
.timestamp(ev.getTimestamp())
|
||||
.build();
|
||||
|
||||
+71
@@ -0,0 +1,71 @@
|
||||
package stirling.software.proprietary.controller.api;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.time.temporal.ChronoUnit;
|
||||
import java.util.List;
|
||||
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.common.model.enumeration.Role;
|
||||
import stirling.software.proprietary.audit.AuditLevel;
|
||||
import stirling.software.proprietary.config.AuditConfigurationProperties;
|
||||
import stirling.software.proprietary.model.api.usage.FleetUsageStats;
|
||||
import stirling.software.proprietary.repository.PersistentAuditEventRepository;
|
||||
import stirling.software.proprietary.security.config.EnterpriseEndpoint;
|
||||
import stirling.software.proprietary.security.database.repository.UserRepository;
|
||||
|
||||
/**
|
||||
* Admin endpoint exposing free-editor fleet usage for the portal Usage card. Audit-derived figures
|
||||
* (active editors, PDFs processed) are null (rendered as "N/A") rather than a misleading 0 whenever
|
||||
* the data can't exist: the events they count (PDF_PROCESS, FILE_OPERATION, HTTP_REQUEST) are all
|
||||
* STANDARD level, so a gate on {@code isEnabled()} alone would still return 0 at level=OFF/BASIC —
|
||||
* we gate on {@code isLevelEnabled(STANDARD)} instead.
|
||||
*
|
||||
* <p>Known limitation: on a login-disabled self-hosted instance every request is anonymous, so its
|
||||
* audit origin is SYSTEM (not WEB) and it is excluded from these WEB-only counts — active/PDFs then
|
||||
* read 0 despite real usage. Historical audit rows written before the {@code source} column existed
|
||||
* carry {@code source=null}, so the cumulative "PDFs edited" figure effectively starts at deploy.
|
||||
*/
|
||||
@Slf4j
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/usage")
|
||||
@PreAuthorize("hasRole('ADMIN')")
|
||||
@RequiredArgsConstructor
|
||||
@EnterpriseEndpoint
|
||||
public class FleetUsageController {
|
||||
|
||||
private final PersistentAuditEventRepository auditRepository;
|
||||
private final UserRepository userRepository;
|
||||
private final AuditConfigurationProperties auditConfig;
|
||||
|
||||
@GetMapping("/fleet-stats")
|
||||
public FleetUsageStats fleetStats() {
|
||||
// Exclude the reserved INTERNAL_API_USER row that InitialSecuritySetup creates on every
|
||||
// install, so a fresh single-admin instance reads 1 editor, not 2.
|
||||
Long deployed = userRepository.countByUsernameNot(Role.INTERNAL_API_USER.getRoleId());
|
||||
// STANDARD is the level at which the counted events are recorded; below it the data
|
||||
// can't exist, so report N/A instead of a 0 that would misrepresent an empty table.
|
||||
boolean auditOn = auditConfig.isLevelEnabled(AuditLevel.STANDARD);
|
||||
Instant since = Instant.now().minus(30, ChronoUnit.DAYS);
|
||||
Long active =
|
||||
auditOn
|
||||
? auditRepository.countDistinctPrincipalsBySourceExcludingTypeAfter(
|
||||
"WEB", "UI_DATA", since)
|
||||
: null;
|
||||
Long pdfs =
|
||||
auditOn
|
||||
? auditRepository.countByTypeInAndSourceAndTimestampAfter(
|
||||
List.of("PDF_PROCESS", "FILE_OPERATION"), "WEB", Instant.EPOCH)
|
||||
: null;
|
||||
if (active != null && deployed != null && active > deployed) {
|
||||
active = deployed; // active editors are a subset of those deployed
|
||||
}
|
||||
return new FleetUsageStats(deployed, active, pdfs);
|
||||
}
|
||||
}
|
||||
+46
@@ -0,0 +1,46 @@
|
||||
package stirling.software.proprietary.controller.api;
|
||||
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
|
||||
import stirling.software.common.annotations.api.ProprietaryUiDataApi;
|
||||
import stirling.software.proprietary.audit.PortalAuditScope;
|
||||
import stirling.software.proprietary.audit.PortalAuditScopeResolver;
|
||||
import stirling.software.proprietary.model.api.documents.PortalDocumentsResponseDto;
|
||||
import stirling.software.proprietary.security.config.EnterpriseEndpoint;
|
||||
import stirling.software.proprietary.service.PortalDocumentsService;
|
||||
|
||||
/** Serves the portal Documents review queue, derived from real audit data and scoped per caller. */
|
||||
@ProprietaryUiDataApi
|
||||
@RequiredArgsConstructor
|
||||
@EnterpriseEndpoint
|
||||
public class PortalDocumentsController {
|
||||
|
||||
private final PortalDocumentsService portalDocumentsService;
|
||||
private final PortalAuditScopeResolver auditScopeResolver;
|
||||
|
||||
// tier accepted for mock-seam symmetry; ignored (queue isn't tier-scoped).
|
||||
@GetMapping("/documents")
|
||||
@Operation(
|
||||
summary = "Documents review queue",
|
||||
description = "Files processed through the org, derived from the audit trail.")
|
||||
public ResponseEntity<PortalDocumentsResponseDto> getDocuments(
|
||||
@RequestParam(value = "tier", required = false) String tier) {
|
||||
PortalAuditScope scope = auditScopeResolver.resolve();
|
||||
if (!scope.allowed()) {
|
||||
return ResponseEntity.status(HttpStatus.FORBIDDEN).build();
|
||||
}
|
||||
PortalDocumentsResponseDto body =
|
||||
scope.fullServer()
|
||||
? portalDocumentsService.serverDocuments()
|
||||
: portalDocumentsService.scopedDocuments(
|
||||
scope.cacheKey(), scope.principals());
|
||||
return ResponseEntity.ok(body);
|
||||
}
|
||||
}
|
||||
+47
@@ -0,0 +1,47 @@
|
||||
package stirling.software.proprietary.controller.api;
|
||||
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
|
||||
import stirling.software.common.annotations.api.ProprietaryUiDataApi;
|
||||
import stirling.software.proprietary.audit.PortalAuditScope;
|
||||
import stirling.software.proprietary.audit.PortalAuditScopeResolver;
|
||||
import stirling.software.proprietary.model.api.audit.InfraAuditLogResponse;
|
||||
import stirling.software.proprietary.security.config.EnterpriseEndpoint;
|
||||
import stirling.software.proprietary.service.PortalInfraAuditService;
|
||||
|
||||
/** Serves the Infrastructure → Audit tab from real audit data, scoped and cached per caller. */
|
||||
@ProprietaryUiDataApi
|
||||
@RequiredArgsConstructor
|
||||
@EnterpriseEndpoint
|
||||
public class PortalInfraAuditController {
|
||||
|
||||
private final PortalInfraAuditService portalInfraAuditService;
|
||||
private final PortalAuditScopeResolver auditScopeResolver;
|
||||
|
||||
// tier accepted for endpoint symmetry; ignored (audit log isn't tier-scoped).
|
||||
@GetMapping("/infrastructure/audit-log")
|
||||
@Operation(
|
||||
summary = "Infrastructure audit log",
|
||||
description = "Recent audit events shaped for the portal Infrastructure → Audit tab.")
|
||||
public ResponseEntity<InfraAuditLogResponse> getInfrastructureAuditLog(
|
||||
@RequestParam(value = "tier", required = false) String tier) {
|
||||
PortalAuditScope scope = auditScopeResolver.resolve();
|
||||
if (!scope.allowed()) {
|
||||
// Return 403 (not throw) so the tab shows its access message, not a generic 500.
|
||||
return ResponseEntity.status(HttpStatus.FORBIDDEN).build();
|
||||
}
|
||||
InfraAuditLogResponse body =
|
||||
scope.fullServer()
|
||||
? portalInfraAuditService.serverAuditLog()
|
||||
: portalInfraAuditService.scopedAuditLog(
|
||||
scope.cacheKey(), scope.principals());
|
||||
return ResponseEntity.ok(body);
|
||||
}
|
||||
}
|
||||
+1
-2
@@ -21,8 +21,7 @@ public enum AiWorkflowOutcome {
|
||||
COMPLETED("completed"),
|
||||
UNSUPPORTED_CAPABILITY("unsupported_capability"),
|
||||
CANNOT_CONTINUE("cannot_continue"),
|
||||
GENERATE_FILE("generate_file"),
|
||||
CONVERT_MARKDOWN("convert_markdown");
|
||||
GENERATE_FILE("generate_file");
|
||||
|
||||
private final String value;
|
||||
|
||||
|
||||
+44
@@ -0,0 +1,44 @@
|
||||
package stirling.software.proprietary.model.api.audit;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
/**
|
||||
* A single infrastructure audit-log row, shaped for the portal Infrastructure → Audit tab. Derived
|
||||
* from a {@code audit_events} row: the real {@link
|
||||
* stirling.software.proprietary.audit.AuditEventType} is mapped to a display category/action.
|
||||
*/
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class InfraAuditEventDto {
|
||||
|
||||
@Schema(description = "Audit event id", example = "8841")
|
||||
private String id;
|
||||
|
||||
@Schema(description = "Display timestamp (UTC)", example = "2026-07-07 18:59:31")
|
||||
private String timestamp;
|
||||
|
||||
@Schema(description = "Category: auth | config | elevation | processing | security")
|
||||
private String category;
|
||||
|
||||
@Schema(description = "Human-readable action", example = "Compress PDF")
|
||||
private String action;
|
||||
|
||||
@Schema(description = "Actor principal", example = "alice.chen@acme.com")
|
||||
private String actor;
|
||||
|
||||
@Schema(description = "Affected target (file, endpoint, or session)")
|
||||
private String target;
|
||||
|
||||
@Schema(description = "Status: success | warning | danger | info")
|
||||
private String status;
|
||||
|
||||
@Schema(description = "Operation latency in milliseconds", example = "412")
|
||||
private long latencyMs;
|
||||
}
|
||||
+30
@@ -0,0 +1,30 @@
|
||||
package stirling.software.proprietary.model.api.audit;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
/** Response for the portal Infrastructure → Audit tab: summary strip + recent event rows. */
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class InfraAuditLogResponse {
|
||||
|
||||
@Schema(description = "Headline counts")
|
||||
private InfraAuditSummary summary;
|
||||
|
||||
@Schema(description = "Most-recent audit events, newest first")
|
||||
private List<InfraAuditEventDto> events;
|
||||
|
||||
@Schema(
|
||||
description =
|
||||
"True when this is the whole-server (admin) view. Team-scoped views are false; "
|
||||
+ "drives whether the admin-only, whole-server CSV export is offered.")
|
||||
private boolean fullServer;
|
||||
}
|
||||
+28
@@ -0,0 +1,28 @@
|
||||
package stirling.software.proprietary.model.api.audit;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
/** Headline counts for the infrastructure audit-log tab, derived from the returned events. */
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class InfraAuditSummary {
|
||||
|
||||
@Schema(description = "Total events in the returned window", example = "40")
|
||||
private int totalEvents;
|
||||
|
||||
@Schema(description = "Processing-category events", example = "24")
|
||||
private int processing;
|
||||
|
||||
@Schema(description = "Elevation-category events", example = "0")
|
||||
private int elevation;
|
||||
|
||||
@Schema(description = "Config-category events", example = "6")
|
||||
private int config;
|
||||
}
|
||||
+24
@@ -0,0 +1,24 @@
|
||||
package stirling.software.proprietary.model.api.documents;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
/** One event in a document's lifecycle timeline. */
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class PortalDocAuditEventDto {
|
||||
private String id;
|
||||
|
||||
/** ingested | extracted | flagged | reviewed | approved | archived | elevation */
|
||||
private String kind;
|
||||
|
||||
/** Relative-time string, e.g. "2m ago". */
|
||||
private String time;
|
||||
|
||||
private String actor;
|
||||
private String detail;
|
||||
}
|
||||
+18
@@ -0,0 +1,18 @@
|
||||
package stirling.software.proprietary.model.api.documents;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
/** Response for the portal Documents review queue. */
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class PortalDocumentsResponseDto {
|
||||
private PortalDocumentsSummaryDto summary;
|
||||
private List<PortalReviewDocumentDto> documents;
|
||||
}
|
||||
+18
@@ -0,0 +1,18 @@
|
||||
package stirling.software.proprietary.model.api.documents;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
/** KPI strip for the documents queue. */
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class PortalDocumentsSummaryDto {
|
||||
private int totalInQueue;
|
||||
private int processed;
|
||||
private int errors;
|
||||
private int processedToday;
|
||||
}
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
package stirling.software.proprietary.model.api.documents;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
/** A single extracted field. Empty for audit-derived documents (no extraction data yet). */
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class PortalExtractionDto {
|
||||
private String field;
|
||||
private String value;
|
||||
private double confidence;
|
||||
}
|
||||
+48
@@ -0,0 +1,48 @@
|
||||
package stirling.software.proprietary.model.api.documents;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
/**
|
||||
* A document in the review queue, derived from the audit trail of a processed file. Extraction
|
||||
* fields ({@code confidence}, {@code extractions}) are absent/empty - that data doesn't exist yet.
|
||||
*/
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class PortalReviewDocumentDto {
|
||||
private String id;
|
||||
private String name;
|
||||
private String type;
|
||||
|
||||
/** Where it was processed: "API" or "Editor". */
|
||||
private String product;
|
||||
|
||||
/** The operation / pipeline, e.g. "Compress PDF" (or "Editor"). */
|
||||
private String action;
|
||||
|
||||
/** The user who ran it. */
|
||||
private String user;
|
||||
|
||||
/** processed | error */
|
||||
private String status;
|
||||
|
||||
private String source;
|
||||
|
||||
/** Overall confidence 0..1, or null when there's no extraction data. */
|
||||
private Double confidence;
|
||||
|
||||
private int fieldsExtracted;
|
||||
|
||||
/** Relative-time string, e.g. "4m ago". */
|
||||
private String time;
|
||||
|
||||
private boolean sensitive;
|
||||
private List<PortalExtractionDto> extractions;
|
||||
private List<PortalDocAuditEventDto> audit;
|
||||
}
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
package stirling.software.proprietary.model.api.usage;
|
||||
|
||||
/**
|
||||
* Free-editor fleet usage for the portal Usage card. Null fields render as "N/A" (uncomputable).
|
||||
*/
|
||||
public record FleetUsageStats(Long editorsDeployed, Long activeThisMonth, Long pdfsProcessed) {}
|
||||
+10
-1
@@ -18,7 +18,15 @@ import lombok.*;
|
||||
columnList = "principal,type"),
|
||||
@jakarta.persistence.Index(
|
||||
name = "idx_audit_type_timestamp",
|
||||
columnList = "type,timestamp")
|
||||
columnList = "type,timestamp"),
|
||||
@jakarta.persistence.Index(
|
||||
name = "idx_audit_type_source_timestamp",
|
||||
columnList = "type,source,timestamp"),
|
||||
// Leads with source (equality) for the active-editors query, which filters on
|
||||
// source then a timestamp range and counts distinct principal.
|
||||
@jakarta.persistence.Index(
|
||||
name = "idx_audit_source_timestamp_principal",
|
||||
columnList = "source,timestamp,principal")
|
||||
})
|
||||
@Data
|
||||
@Builder
|
||||
@@ -32,6 +40,7 @@ public class PersistentAuditEvent {
|
||||
|
||||
private String principal;
|
||||
private String type;
|
||||
private String source;
|
||||
|
||||
@Column(columnDefinition = "text")
|
||||
private String data; // JSON blob
|
||||
|
||||
+17
@@ -259,4 +259,21 @@ public interface PersistentAuditEventRepository extends JpaRepository<Persistent
|
||||
"SELECT e FROM PersistentAuditEvent e WHERE e.type != :excludeType AND e.timestamp > :startDate")
|
||||
List<PersistentAuditEvent> findAllExceptTypeAndTimestampAfterForExport(
|
||||
@Param("excludeType") String excludeType, @Param("startDate") Instant startDate);
|
||||
|
||||
// Free-editor fleet usage: count genuine free-UI operations (source = "WEB") by type.
|
||||
@Query(
|
||||
"SELECT COUNT(e) FROM PersistentAuditEvent e "
|
||||
+ "WHERE e.type IN :types AND e.source = :source AND e.timestamp > :since")
|
||||
long countByTypeInAndSourceAndTimestampAfter(
|
||||
@Param("types") List<String> types,
|
||||
@Param("source") String source,
|
||||
@Param("since") Instant since);
|
||||
|
||||
@Query(
|
||||
"SELECT COUNT(DISTINCT e.principal) FROM PersistentAuditEvent e "
|
||||
+ "WHERE e.source = :source AND e.type <> :excludeType AND e.timestamp > :since")
|
||||
long countDistinctPrincipalsBySourceExcludingTypeAfter(
|
||||
@Param("source") String source,
|
||||
@Param("excludeType") String excludeType,
|
||||
@Param("since") Instant since);
|
||||
}
|
||||
|
||||
+11
@@ -24,6 +24,9 @@ public class CacheConfig {
|
||||
this.applicationProperties = applicationProperties;
|
||||
}
|
||||
|
||||
/** Short-TTL cache of recent audit rows, shared by every audit-derived portal view. */
|
||||
private static final String PORTAL_AUDIT_EVENTS_CACHE = "portalAuditEvents";
|
||||
|
||||
@Bean
|
||||
public CacheManager cacheManager() {
|
||||
int keyRetentionDays = applicationProperties.getSecurity().getJwt().getKeyRetentionDays();
|
||||
@@ -33,6 +36,14 @@ public class CacheConfig {
|
||||
.maximumSize(1000) // Make configurable?
|
||||
.expireAfterWrite(Duration.ofDays(keyRetentionDays))
|
||||
.recordStats());
|
||||
// 30s TTL keeps audit views near-live without re-scanning the DB; one entry per scope.
|
||||
cacheManager.registerCustomCache(
|
||||
PORTAL_AUDIT_EVENTS_CACHE,
|
||||
Caffeine.newBuilder()
|
||||
.maximumSize(256)
|
||||
.expireAfterWrite(Duration.ofSeconds(30))
|
||||
.recordStats()
|
||||
.build());
|
||||
return cacheManager;
|
||||
}
|
||||
}
|
||||
|
||||
+3
@@ -49,6 +49,9 @@ public interface UserRepository extends JpaRepository<User, Long> {
|
||||
|
||||
long countByTeam(Team team);
|
||||
|
||||
/** Count real users, excluding a reserved username such as the internal API user. */
|
||||
long countByUsernameNot(String username);
|
||||
|
||||
List<User> findAllByTeam(Team team);
|
||||
|
||||
// OAuth grandfathering queries
|
||||
|
||||
-68
@@ -68,7 +68,6 @@ import tools.jackson.databind.ObjectMapper;
|
||||
public class AiWorkflowService {
|
||||
|
||||
private static final String DOCUMENTS_ENDPOINT = "/api/v1/documents";
|
||||
private static final String PDF_TO_MARKDOWN_ENDPOINT = "/api/v1/convert/pdf/markdown";
|
||||
|
||||
private final CustomPDFDocumentFactory pdfDocumentFactory;
|
||||
private final AiEngineClient aiEngineClient;
|
||||
@@ -196,7 +195,6 @@ public class AiWorkflowService {
|
||||
return switch (response.getOutcome()) {
|
||||
case NEED_CONTENT -> onNeedContent(response, filesById, request, listener);
|
||||
case NEED_INGEST -> onNeedIngest(response, filesById, request, listener);
|
||||
case CONVERT_MARKDOWN -> onConvertMarkdown(response, filesById, listener);
|
||||
case TOOL_CALL -> onToolCall(response, filesById, listener);
|
||||
case PLAN -> onPlan(response, filesById, request, listener);
|
||||
case ANSWER -> onAnswer(response, filesById, request, listener);
|
||||
@@ -333,72 +331,6 @@ public class AiWorkflowService {
|
||||
return new WorkflowState.Pending(nextRequest);
|
||||
}
|
||||
|
||||
/**
|
||||
* Deterministically convert each requested PDF to Markdown via the {@code
|
||||
* /convert/pdf/markdown} endpoint (backed by {@code PdfMarkdownConverter}) and return the
|
||||
* {@code .md} file(s) as a completed result. No AI resume — the conversion output is the final
|
||||
* answer.
|
||||
*/
|
||||
private WorkflowState onConvertMarkdown(
|
||||
AiWorkflowResponse response,
|
||||
Map<String, MultipartFile> filesById,
|
||||
ProgressListener listener) {
|
||||
List<AiFile> filesToConvert = response.getFilesToIngest();
|
||||
if (filesToConvert == null || filesToConvert.isEmpty()) {
|
||||
return new WorkflowState.Terminal(
|
||||
cannotContinue(
|
||||
"AI engine requested markdown conversion without listing any files."));
|
||||
}
|
||||
|
||||
try {
|
||||
List<Resource> resultFiles = new ArrayList<>();
|
||||
List<String> inputNames = new ArrayList<>();
|
||||
for (int i = 0; i < filesToConvert.size(); i++) {
|
||||
AiFile file = filesToConvert.get(i);
|
||||
MultipartFile multipartFile = filesById.get(file.getId());
|
||||
if (multipartFile == null) {
|
||||
return new WorkflowState.Terminal(
|
||||
cannotContinue(
|
||||
"AI engine requested markdown conversion for unknown file: "
|
||||
+ file.getName()));
|
||||
}
|
||||
listener.onProgress(
|
||||
AiWorkflowProgressEvent.executingTool(
|
||||
PDF_TO_MARKDOWN_ENDPOINT, i + 1, filesToConvert.size()));
|
||||
Resource input = toResource(multipartFile);
|
||||
PipelineDefinition definition =
|
||||
new PipelineDefinition(
|
||||
"convert-markdown",
|
||||
List.of(new PipelineStep(PDF_TO_MARKDOWN_ENDPOINT, Map.of())),
|
||||
null);
|
||||
PolicyExecutionResult result =
|
||||
policyExecutor.execute(
|
||||
definition,
|
||||
PolicyInputs.of(List.of(input)),
|
||||
PolicyProgressListener.NOOP);
|
||||
resultFiles.addAll(result.files());
|
||||
inputNames.add(multipartFile.getOriginalFilename());
|
||||
}
|
||||
return new WorkflowState.Terminal(
|
||||
buildCompletedResponse(null, resultFiles, inputNames, null));
|
||||
} catch (InternalApiTimeoutException e) {
|
||||
log.error("PDF to Markdown conversion timed out: {}", e.getMessage());
|
||||
return new WorkflowState.Terminal(
|
||||
cannotContinue(toolTimeoutMessage(PDF_TO_MARKDOWN_ENDPOINT, e)));
|
||||
} catch (Exception e) {
|
||||
AiWorkflowResponse limit = paygLimitResponseOrNull(e);
|
||||
if (limit != null) {
|
||||
log.info(
|
||||
"AI markdown conversion blocked by downstream entitlement gate ({})",
|
||||
limit.getErrorCode());
|
||||
return new WorkflowState.Terminal(limit);
|
||||
}
|
||||
log.error("Failed to convert PDF to Markdown: {}", e.getMessage(), e);
|
||||
return new WorkflowState.Terminal(
|
||||
cannotContinue(toolFailureMessage(PDF_TO_MARKDOWN_ENDPOINT, e)));
|
||||
}
|
||||
}
|
||||
|
||||
private Resource toResource(MultipartFile file) throws IOException {
|
||||
TempFile tempFile = tempFileManager.createManagedTempFile("ai-workflow");
|
||||
file.transferTo(tempFile.getPath());
|
||||
|
||||
@@ -39,6 +39,7 @@ import stirling.software.common.model.api.PDFFile;
|
||||
import stirling.software.common.service.CustomPDFDocumentFactory;
|
||||
import stirling.software.common.util.RegexPatternUtils;
|
||||
import stirling.software.common.util.RequestUriUtils;
|
||||
import stirling.software.proprietary.accountlink.BillableOperationClassifier;
|
||||
import stirling.software.proprietary.audit.AuditEventType;
|
||||
import stirling.software.proprietary.audit.AuditLevel;
|
||||
import stirling.software.proprietary.audit.Audited;
|
||||
@@ -847,6 +848,39 @@ public class AuditService {
|
||||
return origin;
|
||||
}
|
||||
|
||||
/**
|
||||
* Refines {@link #determineOrigin()} into an audit {@code source} that isolates genuine
|
||||
* free-editor UI runs from automation/AI traffic that also arrives over the web channel.
|
||||
*
|
||||
* <p>API and SYSTEM origins pass through unchanged. A WEB origin is demoted to "AUTOMATION" or
|
||||
* "AI" when the request carries the automation marker or targets an AI surface; only a manual
|
||||
* interactive tool call ({@code BYPASSED}) stays "WEB". A "WEB" source therefore counts as a
|
||||
* free/BYPASSED UI run. The automation/AI resolution is delegated to {@link
|
||||
* BillableOperationClassifier} so it can't drift from the billing gate's own signal.
|
||||
*
|
||||
* <p>IMPORTANT: like {@link #captureCurrentOrigin()} this must be called on the request thread
|
||||
* before async execution, because it reads the current {@link HttpServletRequest}.
|
||||
*
|
||||
* @return "API", "SYSTEM", "AUTOMATION", "AI", or "WEB"
|
||||
*/
|
||||
public String captureCurrentSource() {
|
||||
String origin = determineOrigin();
|
||||
if (!"WEB".equals(origin)) {
|
||||
return origin;
|
||||
}
|
||||
|
||||
HttpServletRequest req = getCurrentRequest();
|
||||
if (req == null) {
|
||||
return "WEB";
|
||||
}
|
||||
// apiKey=false: a WEB origin already means the request is not API-key authenticated.
|
||||
return switch (BillableOperationClassifier.categorize(req, false)) {
|
||||
case AUTOMATION -> "AUTOMATION";
|
||||
case AI -> "AI";
|
||||
default -> "WEB";
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines the origin of the request: API (X-API-KEY), WEB (JWT), or SYSTEM (no auth).
|
||||
* IMPORTANT: This must be called in the request thread before async execution.
|
||||
|
||||
+60
@@ -0,0 +1,60 @@
|
||||
package stirling.software.proprietary.service;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import org.springframework.cache.annotation.Cacheable;
|
||||
import org.springframework.data.domain.PageRequest;
|
||||
import org.springframework.data.domain.Sort;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
|
||||
import stirling.software.proprietary.audit.PortalAuditEventRow;
|
||||
import stirling.software.proprietary.model.security.PersistentAuditEvent;
|
||||
import stirling.software.proprietary.repository.PersistentAuditEventRepository;
|
||||
|
||||
/** One cached read of recent {@code audit_events} per scope, shared by all audit-derived views. */
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class PortalAuditReadService {
|
||||
|
||||
/** Cache name - registered with a short TTL in CacheConfig. */
|
||||
public static final String CACHE_NAME = "portalAuditEvents";
|
||||
|
||||
/** Newest rows to scan; each surface filters this down to what it shows. */
|
||||
private static final int SCAN_LIMIT = 400;
|
||||
|
||||
private final PersistentAuditEventRepository auditRepository;
|
||||
|
||||
/** Recent whole-server events (admins). */
|
||||
@Cacheable(value = CACHE_NAME, key = "'server'")
|
||||
public List<PortalAuditEventRow> serverEvents() {
|
||||
return toRows(auditRepository.findAll(recentPage()).getContent());
|
||||
}
|
||||
|
||||
/** Recent events by the given principals (team scope). Empty principals yield an empty list. */
|
||||
@Cacheable(value = CACHE_NAME, key = "#cacheKey")
|
||||
public List<PortalAuditEventRow> scopedEvents(String cacheKey, List<String> principals) {
|
||||
if (principals.isEmpty()) {
|
||||
return List.of();
|
||||
}
|
||||
return toRows(auditRepository.findByPrincipalIn(principals, recentPage()).getContent());
|
||||
}
|
||||
|
||||
private static PageRequest recentPage() {
|
||||
return PageRequest.of(0, SCAN_LIMIT, Sort.by(Sort.Direction.DESC, "timestamp"));
|
||||
}
|
||||
|
||||
private static List<PortalAuditEventRow> toRows(List<PersistentAuditEvent> events) {
|
||||
return events.stream()
|
||||
.map(
|
||||
e ->
|
||||
new PortalAuditEventRow(
|
||||
e.getId() == null ? 0L : e.getId(),
|
||||
e.getPrincipal(),
|
||||
e.getType(),
|
||||
e.getData(),
|
||||
e.getTimestamp()))
|
||||
.toList();
|
||||
}
|
||||
}
|
||||
+268
@@ -0,0 +1,268 @@
|
||||
package stirling.software.proprietary.service;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.proprietary.audit.AuditEventType;
|
||||
import stirling.software.proprietary.audit.PortalAuditEventRow;
|
||||
import stirling.software.proprietary.model.api.documents.PortalDocAuditEventDto;
|
||||
import stirling.software.proprietary.model.api.documents.PortalDocumentsResponseDto;
|
||||
import stirling.software.proprietary.model.api.documents.PortalDocumentsSummaryDto;
|
||||
import stirling.software.proprietary.model.api.documents.PortalReviewDocumentDto;
|
||||
|
||||
import tools.jackson.core.JacksonException;
|
||||
import tools.jackson.databind.ObjectMapper;
|
||||
|
||||
/** Builds the Documents feed from audit_events: one row per file; extraction fields stay null. */
|
||||
@Slf4j
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class PortalDocumentsService {
|
||||
|
||||
private static final int RETURN_LIMIT = 40;
|
||||
|
||||
private final PortalAuditReadService auditReadService;
|
||||
private final ObjectMapper objectMapper;
|
||||
|
||||
/** Whole-server view (admins). */
|
||||
public PortalDocumentsResponseDto serverDocuments() {
|
||||
return build(auditReadService.serverEvents());
|
||||
}
|
||||
|
||||
/** Team-scoped view: only files touched by {@code principals}. */
|
||||
public PortalDocumentsResponseDto scopedDocuments(String cacheKey, List<String> principals) {
|
||||
return build(auditReadService.scopedEvents(cacheKey, principals));
|
||||
}
|
||||
|
||||
private PortalDocumentsResponseDto build(List<PortalAuditEventRow> events) {
|
||||
// Events arrive newest-first; each file in a processing event is one activity row.
|
||||
Instant dayAgo = Instant.now().minus(Duration.ofDays(1));
|
||||
List<PortalReviewDocumentDto> documents = new ArrayList<>();
|
||||
int processedToday = 0;
|
||||
for (PortalAuditEventRow event : events) {
|
||||
if (documents.size() >= RETURN_LIMIT) {
|
||||
break;
|
||||
}
|
||||
if (!isFileBearing(event.type())) {
|
||||
continue;
|
||||
}
|
||||
Map<String, Object> data = parseData(event);
|
||||
Object files = data.get("files");
|
||||
if (!(files instanceof List<?> fileList)) {
|
||||
continue;
|
||||
}
|
||||
String path = asString(data.get("path"));
|
||||
String source = sourceLabel(asString(data.get("__origin")));
|
||||
String product = "API integration".equals(source) ? "API" : "Editor";
|
||||
String action = prettyTool(path);
|
||||
boolean failed = isFailure(data);
|
||||
Instant ts = event.timestamp();
|
||||
long eventId = event.id();
|
||||
int idx = 0;
|
||||
for (Object f : fileList) {
|
||||
if (documents.size() >= RETURN_LIMIT) {
|
||||
break;
|
||||
}
|
||||
if (!(f instanceof Map<?, ?> fileMap)) {
|
||||
continue;
|
||||
}
|
||||
String name = asString(fileMap.get("name"));
|
||||
if (name == null || name.isBlank()) {
|
||||
continue;
|
||||
}
|
||||
documents.add(
|
||||
toDocument(
|
||||
eventId + "-" + idx++,
|
||||
name,
|
||||
asString(fileMap.get("type")),
|
||||
product,
|
||||
action,
|
||||
event.principal(),
|
||||
failed,
|
||||
source,
|
||||
ts));
|
||||
if (!failed && ts != null && ts.isAfter(dayAgo)) {
|
||||
processedToday++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
int processed =
|
||||
(int) documents.stream().filter(d -> "processed".equals(d.getStatus())).count();
|
||||
int errors = documents.size() - processed;
|
||||
|
||||
PortalDocumentsSummaryDto summary =
|
||||
PortalDocumentsSummaryDto.builder()
|
||||
.totalInQueue(documents.size())
|
||||
.processed(processed)
|
||||
.errors(errors)
|
||||
.processedToday(processedToday)
|
||||
.build();
|
||||
|
||||
return PortalDocumentsResponseDto.builder().summary(summary).documents(documents).build();
|
||||
}
|
||||
|
||||
/** Build one activity row from a single file inside one processing event. */
|
||||
private PortalReviewDocumentDto toDocument(
|
||||
String rowId,
|
||||
String name,
|
||||
String contentType,
|
||||
String product,
|
||||
String action,
|
||||
String user,
|
||||
boolean failed,
|
||||
String source,
|
||||
Instant timestamp) {
|
||||
PortalDocAuditEventDto op =
|
||||
PortalDocAuditEventDto.builder()
|
||||
.id(rowId + "-op")
|
||||
.kind(failed ? "flagged" : "extracted")
|
||||
.time(relativeTime(timestamp))
|
||||
.actor(user)
|
||||
.detail(failed ? action + " failed" : action + " via " + source)
|
||||
.build();
|
||||
|
||||
return PortalReviewDocumentDto.builder()
|
||||
.id("doc-" + rowId)
|
||||
.name(name)
|
||||
.type(docType(contentType, name))
|
||||
.product(product)
|
||||
.action(action)
|
||||
.user(user)
|
||||
.status(failed ? "error" : "processed")
|
||||
.source(source)
|
||||
.confidence(null)
|
||||
.fieldsExtracted(0)
|
||||
.time(relativeTime(timestamp))
|
||||
// Audit events don't reveal content sensitivity, so never guess it.
|
||||
.sensitive(false)
|
||||
.extractions(List.of())
|
||||
.audit(List.of(op))
|
||||
.build();
|
||||
}
|
||||
|
||||
private static boolean isFileBearing(String type) {
|
||||
return AuditEventType.PDF_PROCESS.name().equals(type)
|
||||
|| AuditEventType.FILE_OPERATION.name().equals(type);
|
||||
}
|
||||
|
||||
private static boolean isFailure(Map<String, Object> data) {
|
||||
Object status = data.get("status");
|
||||
if (status instanceof String s && "failure".equalsIgnoreCase(s)) {
|
||||
return true;
|
||||
}
|
||||
Object code = data.get("statusCode");
|
||||
return code instanceof Number n && n.intValue() >= 400;
|
||||
}
|
||||
|
||||
private static String sourceLabel(String origin) {
|
||||
if ("API".equals(origin)) {
|
||||
return "API integration";
|
||||
}
|
||||
if ("SYSTEM".equals(origin)) {
|
||||
return "System";
|
||||
}
|
||||
return "Web upload";
|
||||
}
|
||||
|
||||
private static String docType(String contentType, String name) {
|
||||
String ct = contentType == null ? "" : contentType.toLowerCase(Locale.ROOT);
|
||||
if (ct.contains("pdf") || name.toLowerCase(Locale.ROOT).endsWith(".pdf")) {
|
||||
return "PDF";
|
||||
}
|
||||
if (ct.startsWith("image/") || name.matches("(?i).*\\.(png|jpe?g|gif|webp|tiff?)$")) {
|
||||
return "Image";
|
||||
}
|
||||
if (ct.contains("word") || name.matches("(?i).*\\.docx?$")) {
|
||||
return "Word";
|
||||
}
|
||||
return "Document";
|
||||
}
|
||||
|
||||
private static String prettyTool(String path) {
|
||||
if (path == null || path.isBlank()) {
|
||||
return "Processed";
|
||||
}
|
||||
String[] parts = path.split("/");
|
||||
// Convert endpoints are /convert/{from}/{to}; label them as a conversion.
|
||||
for (int i = 0; i + 2 < parts.length; i++) {
|
||||
if ("convert".equals(parts[i]) && !parts[i + 1].isEmpty() && !parts[i + 2].isEmpty()) {
|
||||
return "Convert " + prettyWords(parts[i + 1]) + " to " + prettyWords(parts[i + 2]);
|
||||
}
|
||||
}
|
||||
String last = parts.length > 0 ? parts[parts.length - 1] : path;
|
||||
String pretty = prettyWords(last);
|
||||
return pretty.isEmpty() ? "Processed" : pretty;
|
||||
}
|
||||
|
||||
/** Title-case a hyphenated path segment, upper-casing known acronyms. */
|
||||
private static String prettyWords(String segment) {
|
||||
StringBuilder sb = new StringBuilder();
|
||||
for (String word : segment.split("-")) {
|
||||
if (word.isEmpty()) {
|
||||
continue;
|
||||
}
|
||||
if (sb.length() > 0) {
|
||||
sb.append(' ');
|
||||
}
|
||||
String lower = word.toLowerCase(Locale.ROOT);
|
||||
sb.append(
|
||||
switch (lower) {
|
||||
case "pdf" -> "PDF";
|
||||
case "pdfs" -> "PDFs";
|
||||
case "ocr" -> "OCR";
|
||||
case "img" -> "Image";
|
||||
case "csv" -> "CSV";
|
||||
default -> Character.toUpperCase(word.charAt(0)) + word.substring(1);
|
||||
});
|
||||
}
|
||||
return sb.toString();
|
||||
}
|
||||
|
||||
private static String relativeTime(Instant ts) {
|
||||
if (ts == null) {
|
||||
return "";
|
||||
}
|
||||
long seconds = Duration.between(ts, Instant.now()).getSeconds();
|
||||
if (seconds < 60) {
|
||||
return "just now";
|
||||
}
|
||||
long minutes = seconds / 60;
|
||||
if (minutes < 60) {
|
||||
return minutes + "m ago";
|
||||
}
|
||||
long hours = minutes / 60;
|
||||
if (hours < 24) {
|
||||
return hours + "h ago";
|
||||
}
|
||||
return (hours / 24) + "d ago";
|
||||
}
|
||||
|
||||
private Map<String, Object> parseData(PortalAuditEventRow event) {
|
||||
if (event.data() == null || event.data().isEmpty()) {
|
||||
return Map.of();
|
||||
}
|
||||
try {
|
||||
@SuppressWarnings("unchecked")
|
||||
Map<String, Object> parsed = objectMapper.readValue(event.data(), Map.class);
|
||||
// A literal "null" payload parses to null; treat it as empty, not an NPE.
|
||||
return parsed == null ? Map.of() : parsed;
|
||||
} catch (JacksonException e) {
|
||||
log.warn("Failed to parse audit event {} data as JSON", event.id());
|
||||
return Map.of();
|
||||
}
|
||||
}
|
||||
|
||||
private static String asString(Object o) {
|
||||
return o == null ? null : String.valueOf(o);
|
||||
}
|
||||
}
|
||||
+251
@@ -0,0 +1,251 @@
|
||||
package stirling.software.proprietary.service;
|
||||
|
||||
import java.time.ZoneOffset;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.proprietary.audit.AuditEventType;
|
||||
import stirling.software.proprietary.audit.PortalAuditEventRow;
|
||||
import stirling.software.proprietary.model.api.audit.InfraAuditEventDto;
|
||||
import stirling.software.proprietary.model.api.audit.InfraAuditLogResponse;
|
||||
import stirling.software.proprietary.model.api.audit.InfraAuditSummary;
|
||||
|
||||
import tools.jackson.core.JacksonException;
|
||||
import tools.jackson.databind.ObjectMapper;
|
||||
|
||||
/** Maps cached audit_events to the Infrastructure → Audit tab, dropping read-noise types. */
|
||||
@Slf4j
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class PortalInfraAuditService {
|
||||
|
||||
/** Rows returned to the tab after filtering. */
|
||||
private static final int RETURN_LIMIT = 40;
|
||||
|
||||
private static final DateTimeFormatter TS_FORMAT =
|
||||
DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss").withZone(ZoneOffset.UTC);
|
||||
|
||||
private final PortalAuditReadService auditReadService;
|
||||
private final ObjectMapper objectMapper;
|
||||
|
||||
/** Whole-server view (admins). */
|
||||
public InfraAuditLogResponse serverAuditLog() {
|
||||
return buildFromEvents(auditReadService.serverEvents(), true);
|
||||
}
|
||||
|
||||
/** Team-scoped view: only events by the given principals; empty yields an empty log. */
|
||||
public InfraAuditLogResponse scopedAuditLog(String cacheKey, List<String> principals) {
|
||||
return buildFromEvents(auditReadService.scopedEvents(cacheKey, principals), false);
|
||||
}
|
||||
|
||||
private InfraAuditLogResponse buildFromEvents(
|
||||
List<PortalAuditEventRow> recent, boolean fullServer) {
|
||||
List<InfraAuditEventDto> events =
|
||||
recent.stream()
|
||||
.filter(e -> isInfraRelevant(e.type()))
|
||||
.map(this::toDto)
|
||||
.limit(RETURN_LIMIT)
|
||||
.toList();
|
||||
|
||||
int processing =
|
||||
(int) events.stream().filter(e -> "processing".equals(e.getCategory())).count();
|
||||
int elevation =
|
||||
(int) events.stream().filter(e -> "elevation".equals(e.getCategory())).count();
|
||||
int config = (int) events.stream().filter(e -> "config".equals(e.getCategory())).count();
|
||||
|
||||
InfraAuditSummary summary =
|
||||
InfraAuditSummary.builder()
|
||||
.totalEvents(events.size())
|
||||
.processing(processing)
|
||||
.elevation(elevation)
|
||||
.config(config)
|
||||
.build();
|
||||
|
||||
return InfraAuditLogResponse.builder()
|
||||
.summary(summary)
|
||||
.events(events)
|
||||
.fullServer(fullServer)
|
||||
.build();
|
||||
}
|
||||
|
||||
/** UI_DATA and HTTP_REQUEST are read/polling noise - excluded from the infrastructure view. */
|
||||
private static boolean isInfraRelevant(String type) {
|
||||
return !AuditEventType.UI_DATA.name().equals(type)
|
||||
&& !AuditEventType.HTTP_REQUEST.name().equals(type);
|
||||
}
|
||||
|
||||
private InfraAuditEventDto toDto(PortalAuditEventRow event) {
|
||||
Map<String, Object> data = parseData(event);
|
||||
String path = asString(data.get("path"));
|
||||
String category = categoryFor(event.type(), path);
|
||||
|
||||
return InfraAuditEventDto.builder()
|
||||
.id(String.valueOf(event.id()))
|
||||
.timestamp(event.timestamp() == null ? "" : TS_FORMAT.format(event.timestamp()))
|
||||
.category(category)
|
||||
.action(actionFor(event.type(), path))
|
||||
.actor(event.principal())
|
||||
.target(targetFor(category, path, data))
|
||||
.status(statusFor(event.type(), category, data))
|
||||
.latencyMs(asLong(data.get("latencyMs")))
|
||||
.build();
|
||||
}
|
||||
|
||||
private Map<String, Object> parseData(PortalAuditEventRow event) {
|
||||
if (event.data() == null || event.data().isEmpty()) {
|
||||
return Map.of();
|
||||
}
|
||||
try {
|
||||
@SuppressWarnings("unchecked")
|
||||
Map<String, Object> parsed = objectMapper.readValue(event.data(), Map.class);
|
||||
// A literal "null" payload parses to null; treat it as empty, not an NPE.
|
||||
return parsed == null ? Map.of() : parsed;
|
||||
} catch (JacksonException e) {
|
||||
log.warn("Failed to parse audit event {} data as JSON", event.id());
|
||||
return Map.of();
|
||||
}
|
||||
}
|
||||
|
||||
private static String categoryFor(String type, String path) {
|
||||
AuditEventType t = AuditEventType.fromString(type);
|
||||
if (t == null) {
|
||||
return "processing";
|
||||
}
|
||||
return switch (t) {
|
||||
case USER_LOGIN, USER_LOGOUT, USER_FAILED_LOGIN -> "auth";
|
||||
case SETTINGS_CHANGED, USER_PROFILE_UPDATE -> "config";
|
||||
case PDF_PROCESS, FILE_OPERATION -> isSecurityPath(path) ? "security" : "processing";
|
||||
// UI_DATA / HTTP_REQUEST are filtered out before mapping.
|
||||
default -> "processing";
|
||||
};
|
||||
}
|
||||
|
||||
private static boolean isSecurityPath(String path) {
|
||||
if (path == null) {
|
||||
return false;
|
||||
}
|
||||
String p = path.toLowerCase(Locale.ROOT);
|
||||
return p.contains("/security/")
|
||||
|| p.contains("password")
|
||||
|| p.contains("watermark")
|
||||
|| p.contains("sign")
|
||||
|| p.contains("cert")
|
||||
|| p.contains("redact");
|
||||
}
|
||||
|
||||
private static String actionFor(String type, String path) {
|
||||
AuditEventType t = AuditEventType.fromString(type);
|
||||
if (t == null) {
|
||||
return prettyTool(path);
|
||||
}
|
||||
return switch (t) {
|
||||
case USER_LOGIN -> "User signed in";
|
||||
case USER_LOGOUT -> "User signed out";
|
||||
case USER_FAILED_LOGIN -> "Failed sign-in attempt";
|
||||
case USER_PROFILE_UPDATE -> "Profile settings updated";
|
||||
case SETTINGS_CHANGED -> "Admin settings changed";
|
||||
case PDF_PROCESS, FILE_OPERATION -> prettyTool(path);
|
||||
default -> prettyTool(path);
|
||||
};
|
||||
}
|
||||
|
||||
/** Acronyms/tokens that get special casing when title-casing a tool path. */
|
||||
private static final Map<String, String> WORD_FIXUPS =
|
||||
Map.of(
|
||||
"pdf", "PDF",
|
||||
"pdfs", "PDFs",
|
||||
"ocr", "OCR",
|
||||
"img", "Image",
|
||||
"csv", "CSV",
|
||||
"html", "HTML",
|
||||
"url", "URL",
|
||||
"xml", "XML");
|
||||
|
||||
/** "/api/v1/misc/compress-pdf" → "Compress PDF"; "merge-pdfs" → "Merge PDFs". */
|
||||
private static String prettyTool(String path) {
|
||||
if (path == null || path.isBlank()) {
|
||||
return "PDF operation";
|
||||
}
|
||||
String[] parts = path.split("/");
|
||||
String last = parts.length > 0 ? parts[parts.length - 1] : path;
|
||||
StringBuilder sb = new StringBuilder();
|
||||
for (String word : last.split("-")) {
|
||||
if (word.isEmpty()) {
|
||||
continue;
|
||||
}
|
||||
if (sb.length() > 0) {
|
||||
sb.append(' ');
|
||||
}
|
||||
String lower = word.toLowerCase(Locale.ROOT);
|
||||
sb.append(
|
||||
WORD_FIXUPS.getOrDefault(
|
||||
lower, Character.toUpperCase(word.charAt(0)) + word.substring(1)));
|
||||
}
|
||||
return sb.isEmpty() ? "PDF operation" : sb.toString();
|
||||
}
|
||||
|
||||
private static String targetFor(String category, String path, Map<String, Object> data) {
|
||||
if ("auth".equals(category)) {
|
||||
// Auth events don't act on a resource; the session is the closest thing.
|
||||
return "Web session";
|
||||
}
|
||||
if ("config".equals(category)) {
|
||||
return path != null && !path.isBlank() ? path : "System settings";
|
||||
}
|
||||
// processing / security: prefer the first affected file name.
|
||||
String file = firstFileName(data);
|
||||
if (file != null) {
|
||||
return file;
|
||||
}
|
||||
return path != null && !path.isBlank() ? prettyTool(path) : "Document";
|
||||
}
|
||||
|
||||
private static String statusFor(String type, String category, Map<String, Object> data) {
|
||||
if (AuditEventType.USER_FAILED_LOGIN.name().equals(type)) {
|
||||
return "danger";
|
||||
}
|
||||
String status = asString(data.get("status"));
|
||||
Integer code = asInteger(data.get("statusCode"));
|
||||
if ("failure".equalsIgnoreCase(status) || (code != null && code >= 500)) {
|
||||
return "danger";
|
||||
}
|
||||
if (code != null && code >= 400) {
|
||||
return "warning";
|
||||
}
|
||||
if ("config".equals(category)) {
|
||||
return "info";
|
||||
}
|
||||
return "success";
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
private static String firstFileName(Map<String, Object> data) {
|
||||
Object files = data.get("files");
|
||||
if (files instanceof List<?> list
|
||||
&& !list.isEmpty()
|
||||
&& list.get(0) instanceof Map<?, ?> f) {
|
||||
Object name = ((Map<String, Object>) f).get("name");
|
||||
return name != null ? String.valueOf(name) : null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static String asString(Object o) {
|
||||
return o == null ? null : String.valueOf(o);
|
||||
}
|
||||
|
||||
private static long asLong(Object o) {
|
||||
return o instanceof Number n ? n.longValue() : 0L;
|
||||
}
|
||||
|
||||
private static Integer asInteger(Object o) {
|
||||
return o instanceof Number n ? n.intValue() : null;
|
||||
}
|
||||
}
|
||||
+49
@@ -3,12 +3,61 @@ package stirling.software.proprietary.config;
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertNotEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.verify;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.Map;
|
||||
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
import org.slf4j.MDC;
|
||||
import org.springframework.boot.actuate.audit.AuditEvent;
|
||||
|
||||
import stirling.software.proprietary.model.security.PersistentAuditEvent;
|
||||
import stirling.software.proprietary.repository.PersistentAuditEventRepository;
|
||||
|
||||
import tools.jackson.databind.json.JsonMapper;
|
||||
|
||||
class CustomAuditEventRepositoryTest {
|
||||
|
||||
@AfterEach
|
||||
void clearMdc() {
|
||||
MDC.clear();
|
||||
}
|
||||
|
||||
@Test
|
||||
void sourceIsPopulatedFromMdcAuditSource() {
|
||||
PersistentAuditEventRepository repo = mock(PersistentAuditEventRepository.class);
|
||||
CustomAuditEventRepository writer =
|
||||
new CustomAuditEventRepository(repo, JsonMapper.builder().build());
|
||||
|
||||
MDC.put("auditSource", "WEB");
|
||||
writer.add(new AuditEvent(Instant.now(), "admin", "PDF_PROCESS", Map.of("k", "v")));
|
||||
|
||||
ArgumentCaptor<PersistentAuditEvent> captor =
|
||||
ArgumentCaptor.forClass(PersistentAuditEvent.class);
|
||||
verify(repo).save(captor.capture());
|
||||
assertEquals("WEB", captor.getValue().getSource());
|
||||
}
|
||||
|
||||
@Test
|
||||
void sourceIsNullWhenMdcAbsent() {
|
||||
PersistentAuditEventRepository repo = mock(PersistentAuditEventRepository.class);
|
||||
CustomAuditEventRepository writer =
|
||||
new CustomAuditEventRepository(repo, JsonMapper.builder().build());
|
||||
|
||||
writer.add(new AuditEvent(Instant.now(), "admin", "PDF_PROCESS", Map.of("k", "v")));
|
||||
|
||||
ArgumentCaptor<PersistentAuditEvent> captor =
|
||||
ArgumentCaptor.forClass(PersistentAuditEvent.class);
|
||||
verify(repo).save(captor.capture());
|
||||
assertNull(captor.getValue().getSource());
|
||||
}
|
||||
|
||||
@Test
|
||||
void shortPrincipalPassesThroughUnchanged() {
|
||||
assertEquals(
|
||||
|
||||
+108
@@ -0,0 +1,108 @@
|
||||
package stirling.software.proprietary.controller.api;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyList;
|
||||
import static org.mockito.ArgumentMatchers.anyString;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import java.time.Instant;
|
||||
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
|
||||
import stirling.software.common.model.enumeration.Role;
|
||||
import stirling.software.proprietary.audit.AuditLevel;
|
||||
import stirling.software.proprietary.config.AuditConfigurationProperties;
|
||||
import stirling.software.proprietary.model.api.usage.FleetUsageStats;
|
||||
import stirling.software.proprietary.repository.PersistentAuditEventRepository;
|
||||
import stirling.software.proprietary.security.database.repository.UserRepository;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class FleetUsageControllerTest {
|
||||
|
||||
@Mock private PersistentAuditEventRepository auditRepository;
|
||||
@Mock private UserRepository userRepository;
|
||||
@Mock private AuditConfigurationProperties auditConfig;
|
||||
|
||||
private FleetUsageController controller;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
controller = new FleetUsageController(auditRepository, userRepository, auditConfig);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("deployed reflects the user count, excluding the internal API user")
|
||||
void deployedFromUserCount() {
|
||||
when(userRepository.countByUsernameNot(anyString())).thenReturn(7L);
|
||||
when(auditConfig.isLevelEnabled(AuditLevel.STANDARD)).thenReturn(false);
|
||||
|
||||
FleetUsageStats stats = controller.fleetStats();
|
||||
|
||||
assertThat(stats.editorsDeployed()).isEqualTo(7L);
|
||||
verify(userRepository).countByUsernameNot(Role.INTERNAL_API_USER.getRoleId());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("audit-derived figures are null when auditing is below STANDARD")
|
||||
void auditOffYieldsNulls() {
|
||||
when(userRepository.countByUsernameNot(anyString())).thenReturn(3L);
|
||||
// Covers both disabled and the enabled-but-level=OFF/BASIC misconfig: isLevelEnabled
|
||||
// is false, so no events can exist and we must report N/A, not a 0 from an empty table.
|
||||
when(auditConfig.isLevelEnabled(AuditLevel.STANDARD)).thenReturn(false);
|
||||
|
||||
FleetUsageStats stats = controller.fleetStats();
|
||||
|
||||
assertThat(stats.activeThisMonth()).isNull();
|
||||
assertThat(stats.pdfsProcessed()).isNull();
|
||||
verify(auditRepository, never())
|
||||
.countDistinctPrincipalsBySourceExcludingTypeAfter(
|
||||
any(), any(), any(Instant.class));
|
||||
verify(auditRepository, never())
|
||||
.countByTypeInAndSourceAndTimestampAfter(anyList(), any(), any(Instant.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("audit-derived figures come from the repository when auditing is enabled")
|
||||
void auditOnReadsRepository() {
|
||||
when(userRepository.countByUsernameNot(anyString())).thenReturn(10L);
|
||||
when(auditConfig.isLevelEnabled(AuditLevel.STANDARD)).thenReturn(true);
|
||||
when(auditRepository.countDistinctPrincipalsBySourceExcludingTypeAfter(
|
||||
eq("WEB"), eq("UI_DATA"), any(Instant.class)))
|
||||
.thenReturn(4L);
|
||||
when(auditRepository.countByTypeInAndSourceAndTimestampAfter(
|
||||
anyList(), eq("WEB"), any(Instant.class)))
|
||||
.thenReturn(1234L);
|
||||
|
||||
FleetUsageStats stats = controller.fleetStats();
|
||||
|
||||
assertThat(stats.editorsDeployed()).isEqualTo(10L);
|
||||
assertThat(stats.activeThisMonth()).isEqualTo(4L);
|
||||
assertThat(stats.pdfsProcessed()).isEqualTo(1234L);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("active editors are clamped to deployed (active is a subset)")
|
||||
void activeClampedToDeployed() {
|
||||
when(userRepository.countByUsernameNot(anyString())).thenReturn(2L);
|
||||
when(auditConfig.isLevelEnabled(AuditLevel.STANDARD)).thenReturn(true);
|
||||
when(auditRepository.countDistinctPrincipalsBySourceExcludingTypeAfter(
|
||||
eq("WEB"), eq("UI_DATA"), any(Instant.class)))
|
||||
.thenReturn(9L);
|
||||
when(auditRepository.countByTypeInAndSourceAndTimestampAfter(
|
||||
anyList(), eq("WEB"), any(Instant.class)))
|
||||
.thenReturn(50L);
|
||||
|
||||
FleetUsageStats stats = controller.fleetStats();
|
||||
|
||||
assertThat(stats.activeThisMonth()).isEqualTo(2L);
|
||||
}
|
||||
}
|
||||
-28
@@ -221,34 +221,6 @@ class AiWorkflowServiceMoreTest {
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("convert_markdown guards")
|
||||
class ConvertMarkdownGuards {
|
||||
|
||||
@Test
|
||||
@DisplayName("no files listed yields CANNOT_CONTINUE")
|
||||
void noFiles() throws IOException {
|
||||
stubOrchestrator("{\"outcome\":\"convert_markdown\",\"filesToIngest\":[]}");
|
||||
AiWorkflowResponse result = service.orchestrate(requestFor(pdf("a.pdf", "x"), "to md"));
|
||||
assertThat(result.getOutcome()).isEqualTo(AiWorkflowOutcome.CANNOT_CONTINUE);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("unknown file id yields CANNOT_CONTINUE")
|
||||
void unknownFile() throws IOException {
|
||||
when(fileIdStrategy.idFor(any())).thenReturn("real-id");
|
||||
stubOrchestrator(
|
||||
"""
|
||||
{"outcome":"convert_markdown",
|
||||
"filesToIngest":[{"id":"other-id","name":"other.pdf"}]}
|
||||
""");
|
||||
AiWorkflowResponse result =
|
||||
service.orchestrate(requestFor(pdf("real.pdf", "x"), "to md"));
|
||||
assertThat(result.getOutcome()).isEqualTo(AiWorkflowOutcome.CANNOT_CONTINUE);
|
||||
assertThat(result.getReason()).contains("other.pdf");
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("plan guards and errors")
|
||||
class PlanGuardsAndErrors {
|
||||
|
||||
+14
-14
@@ -78,6 +78,7 @@ class AiWorkflowServiceTest {
|
||||
private static final String SPLIT_ENDPOINT = "/api/v1/general/split-pages";
|
||||
private static final String MERGE_ENDPOINT = "/api/v1/general/merge-pdfs";
|
||||
private static final String COMPRESS_ENDPOINT = "/api/v1/misc/compress-pdf";
|
||||
private static final String MARKDOWN_ENDPOINT = "/api/v1/convert/pdf/markdown";
|
||||
|
||||
@Mock private CustomPDFDocumentFactory pdfDocumentFactory;
|
||||
@Mock private AiEngineClient aiEngineClient;
|
||||
@@ -440,23 +441,23 @@ class AiWorkflowServiceTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
void convertMarkdownRunsDeterministicConversionAndReturnsMdFile() throws IOException {
|
||||
void planWithMarkdownStepReturnsMdFile() throws IOException {
|
||||
// PDF→Markdown is a normal tool the edit agent emits as a plan step (no bespoke
|
||||
// outcome); the plan executor runs the converter and returns the .md file.
|
||||
MockMultipartFile input = pdf("multi-column-test_lorem.pdf", "pdf-bytes");
|
||||
when(fileIdStrategy.idFor(any())).thenReturn("doc-1");
|
||||
stubOrchestrator(
|
||||
"""
|
||||
{
|
||||
"outcome":"convert_markdown",
|
||||
"reason":"PDF to Markdown requested.",
|
||||
"filesToIngest":[{"id":"doc-1","name":"multi-column-test_lorem.pdf"}]
|
||||
"outcome":"plan",
|
||||
"summary":"Convert to Markdown",
|
||||
"steps":[{"tool":"%s","parameters":{}}]
|
||||
}
|
||||
""");
|
||||
when(toolMetadataService.shouldUnpackZipResponse("/api/v1/convert/pdf/markdown"))
|
||||
.thenReturn(false);
|
||||
stubEndpoint(
|
||||
"/api/v1/convert/pdf/markdown",
|
||||
pdfResource("# Title", "multi-column-test_lorem.md"));
|
||||
AtomicInteger ids = stubFileStorage();
|
||||
"""
|
||||
.formatted(MARKDOWN_ENDPOINT));
|
||||
when(toolMetadataService.isMultiInput(anyString())).thenReturn(false);
|
||||
when(toolMetadataService.shouldUnpackZipResponse(anyString())).thenReturn(false);
|
||||
stubEndpoint(MARKDOWN_ENDPOINT, pdfResource("# Title", "multi-column-test_lorem.md"));
|
||||
stubFileStorage();
|
||||
|
||||
AiWorkflowResponse result = service.orchestrate(requestFor(input, "convert to markdown"));
|
||||
|
||||
@@ -464,8 +465,7 @@ class AiWorkflowServiceTest {
|
||||
assertEquals(1, result.getResultFiles().size());
|
||||
// Extension changes (pdf -> md), so the converter's response filename wins.
|
||||
assertEquals("multi-column-test_lorem.md", result.getResultFiles().get(0).getFileName());
|
||||
assertEquals(1, ids.get());
|
||||
verify(internalApiClient, times(1)).post(eq("/api/v1/convert/pdf/markdown"), any());
|
||||
verify(internalApiClient, times(1)).post(eq(MARKDOWN_ENDPOINT), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
+56
@@ -35,6 +35,7 @@ import jakarta.servlet.http.HttpServletResponse;
|
||||
|
||||
import stirling.software.common.model.ApplicationProperties;
|
||||
import stirling.software.common.service.CustomPDFDocumentFactory;
|
||||
import stirling.software.common.service.InternalApiClient;
|
||||
import stirling.software.proprietary.audit.AuditEventType;
|
||||
import stirling.software.proprietary.audit.AuditLevel;
|
||||
import stirling.software.proprietary.audit.Audited;
|
||||
@@ -83,6 +84,61 @@ class AuditServiceTest {
|
||||
RequestContextHolder.setRequestAttributes(new ServletRequestAttributes(request));
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("captureCurrentSource()")
|
||||
class CaptureSource {
|
||||
|
||||
@Test
|
||||
@DisplayName("a plain authenticated web tool call is WEB")
|
||||
void plainWebIsWeb() {
|
||||
authenticateAs("alice");
|
||||
bindRequest(new MockHttpServletRequest("POST", "/api/v1/general/merge-pdfs"));
|
||||
|
||||
assertThat(service.captureCurrentSource()).isEqualTo("WEB");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("the automation marker header demotes WEB to AUTOMATION")
|
||||
void automationHeaderIsAutomation() {
|
||||
authenticateAs("alice");
|
||||
MockHttpServletRequest req =
|
||||
new MockHttpServletRequest("POST", "/api/v1/general/merge");
|
||||
req.addHeader(InternalApiClient.AUTOMATION_HEADER, "true");
|
||||
bindRequest(req);
|
||||
|
||||
assertThat(service.captureCurrentSource()).isEqualTo("AUTOMATION");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("the AI surface demotes WEB to AI")
|
||||
void aiSurfaceIsAi() {
|
||||
authenticateAs("alice");
|
||||
bindRequest(new MockHttpServletRequest("POST", "/api/v1/ai/tools/ask"));
|
||||
|
||||
assertThat(service.captureCurrentSource()).isEqualTo("AI");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("the AI surface is matched after stripping the deployment context path")
|
||||
void aiSurfaceWithContextPathIsAi() {
|
||||
authenticateAs("alice");
|
||||
MockHttpServletRequest req = new MockHttpServletRequest("POST", "/api/v1/ai/tools/ask");
|
||||
req.setContextPath("/stirling");
|
||||
req.setRequestURI("/stirling/api/v1/ai/tools/ask");
|
||||
bindRequest(req);
|
||||
|
||||
assertThat(service.captureCurrentSource()).isEqualTo("AI");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("an unauthenticated request is SYSTEM, not WEB")
|
||||
void anonymousIsSystem() {
|
||||
bindRequest(new MockHttpServletRequest("POST", "/api/v1/general/merge-pdfs"));
|
||||
|
||||
assertThat(service.captureCurrentSource()).isEqualTo("SYSTEM");
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("audit() gating")
|
||||
class AuditGating {
|
||||
|
||||
@@ -18,13 +18,15 @@ import org.springframework.data.jpa.repository.config.EnableJpaRepositories;
|
||||
"stirling.software.saas.repository",
|
||||
"stirling.software.saas.billing.repository",
|
||||
"stirling.software.saas.ai.repository",
|
||||
"stirling.software.saas.payg.repository"
|
||||
"stirling.software.saas.payg.repository",
|
||||
"stirling.software.saas.procurement.repository"
|
||||
})
|
||||
@EntityScan({
|
||||
"stirling.software.saas.accountlink",
|
||||
"stirling.software.saas.model",
|
||||
"stirling.software.saas.billing.model",
|
||||
"stirling.software.saas.ai.model",
|
||||
"stirling.software.saas.payg"
|
||||
"stirling.software.saas.payg",
|
||||
"stirling.software.saas.procurement.model"
|
||||
})
|
||||
public class SaasJpaConfig {}
|
||||
|
||||
+378
@@ -0,0 +1,378 @@
|
||||
package stirling.software.saas.procurement.api;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
|
||||
import org.springframework.context.annotation.Profile;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
|
||||
import io.swagger.v3.oas.annotations.Hidden;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.common.model.enumeration.TeamRole;
|
||||
import stirling.software.proprietary.security.database.repository.UserRepository;
|
||||
import stirling.software.proprietary.security.model.User;
|
||||
import stirling.software.saas.model.TeamMembership;
|
||||
import stirling.software.saas.procurement.config.ProcurementConfigurationProperties;
|
||||
import stirling.software.saas.procurement.model.ProcurementDeal;
|
||||
import stirling.software.saas.procurement.model.ProcurementQuote;
|
||||
import stirling.software.saas.procurement.pricing.QuoteConfig;
|
||||
import stirling.software.saas.procurement.pricing.QuoteLineItem;
|
||||
import stirling.software.saas.procurement.service.ProcurementService;
|
||||
import stirling.software.saas.repository.TeamMembershipRepository;
|
||||
import stirling.software.saas.util.AuthenticationUtils;
|
||||
|
||||
/**
|
||||
* The enterprise procurement journey for a linked team: read the deal snapshot, start/extend a
|
||||
* (mock-licensed) trial, build a server-priced quote, and accept it. Stripe checkout itself is a
|
||||
* Supabase edge function the portal calls with the accepted quote — this controller never touches
|
||||
* Stripe. The caller's team is resolved from the authenticated principal; a team id is never
|
||||
* trusted from the request. Mutations require the team leader.
|
||||
*/
|
||||
@Slf4j
|
||||
@Hidden
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/procurement")
|
||||
@Profile("saas")
|
||||
public class ProcurementController {
|
||||
|
||||
// Local mapper to parse the stored line-items JSON; the saas context exposes no injectable
|
||||
// ObjectMapper bean.
|
||||
private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();
|
||||
|
||||
private final ProcurementService procurement;
|
||||
private final TeamMembershipRepository memberRepo;
|
||||
private final UserRepository userRepository;
|
||||
private final ProcurementConfigurationProperties config;
|
||||
|
||||
public ProcurementController(
|
||||
ProcurementService procurement,
|
||||
TeamMembershipRepository memberRepo,
|
||||
UserRepository userRepository,
|
||||
ProcurementConfigurationProperties config) {
|
||||
this.procurement = Objects.requireNonNull(procurement);
|
||||
this.memberRepo = Objects.requireNonNull(memberRepo);
|
||||
this.userRepository = Objects.requireNonNull(userRepository);
|
||||
this.config = Objects.requireNonNull(config);
|
||||
}
|
||||
|
||||
// ---- request / response DTOs -------------------------------------------
|
||||
|
||||
public record QuoteRequest(
|
||||
long volume,
|
||||
int users,
|
||||
int intensity, // policy posture (runs/PDF): 2 / 4 / 7; 0 → default Governed
|
||||
String deployment,
|
||||
int termYears,
|
||||
String serviceLevel,
|
||||
boolean indemnification,
|
||||
boolean training,
|
||||
boolean qbr,
|
||||
boolean offlineLicense,
|
||||
String currency,
|
||||
String businessName) {
|
||||
QuoteConfig toConfig() {
|
||||
return new QuoteConfig(
|
||||
volume,
|
||||
users,
|
||||
intensity,
|
||||
deployment,
|
||||
termYears,
|
||||
serviceLevel,
|
||||
indemnification,
|
||||
training,
|
||||
qbr,
|
||||
offlineLicense,
|
||||
currency);
|
||||
}
|
||||
}
|
||||
|
||||
public record QuoteResponse(
|
||||
Long quoteId,
|
||||
String quoteNumber,
|
||||
String status,
|
||||
String currency,
|
||||
long annualNetMinor,
|
||||
long tcvMinor,
|
||||
List<QuoteLineItem> lineItems,
|
||||
String validUntil,
|
||||
String stripeQuoteId,
|
||||
String invoiceUrl,
|
||||
QuoteConfigEcho config) {}
|
||||
|
||||
/**
|
||||
* The inputs the quote was priced from, echoed back so the builder can seed itself when the
|
||||
* buyer re-edits an existing quote. {@code users} is not persisted (only the resulting volume
|
||||
* is), so it is always 0 here; the builder treats the seeded volume as manually set.
|
||||
*/
|
||||
public record QuoteConfigEcho(
|
||||
long volume,
|
||||
int users,
|
||||
int intensity,
|
||||
String deployment,
|
||||
int termYears,
|
||||
String serviceLevel,
|
||||
boolean indemnification,
|
||||
boolean training,
|
||||
boolean qbr,
|
||||
boolean offlineLicense,
|
||||
String currency,
|
||||
String businessName) {}
|
||||
|
||||
public record SnapshotResponse(
|
||||
Long dealId,
|
||||
String stage,
|
||||
String trialStartedAt,
|
||||
String trialEndsAt,
|
||||
int trialExtensionsUsed,
|
||||
boolean licensed,
|
||||
String licenseKey,
|
||||
QuoteResponse latestQuote) {}
|
||||
|
||||
// ---- endpoints ----------------------------------------------------------
|
||||
|
||||
/**
|
||||
* The team's deal snapshot. Always 200 with a single shape; an unstarted procurement returns an
|
||||
* empty snapshot ({@code dealId == null}) so the portal can render the "start" state without
|
||||
* special-casing an empty body.
|
||||
*/
|
||||
@GetMapping
|
||||
@PreAuthorize("isAuthenticated()")
|
||||
public ResponseEntity<SnapshotResponse> snapshot(Authentication auth) {
|
||||
Optional<TeamMembership> membership = primaryMembership(auth);
|
||||
if (membership.isEmpty()) return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build();
|
||||
Long teamId = membership.get().getTeam().getId();
|
||||
// The licence key is the team's secret entitlement — leader-only. Members still see the
|
||||
// journey (stage, trial, quote) but the key is withheld; the .lic file is likewise gated.
|
||||
boolean leader = membership.get().getRole() == TeamRole.LEADER;
|
||||
return ResponseEntity.ok(
|
||||
procurement.getDeal(teamId).map(d -> toSnapshot(d, leader)).orElse(EMPTY_SNAPSHOT));
|
||||
}
|
||||
|
||||
private static final SnapshotResponse EMPTY_SNAPSHOT =
|
||||
new SnapshotResponse(null, null, null, null, 0, false, null, null);
|
||||
|
||||
/**
|
||||
* Download the offline / air-gapped licence file (.lic) for the team, when the paid offline
|
||||
* add-on was purchased. 404 when there's no licence or the add-on wasn't taken — we don't leak
|
||||
* that a licence exists to a team without the add-on.
|
||||
*/
|
||||
@GetMapping("/license/file")
|
||||
@PreAuthorize("isAuthenticated()")
|
||||
public ResponseEntity<String> licenseFile(Authentication auth) {
|
||||
// Leader-only: the offline .lic is the team's portable entitlement, not a member artefact.
|
||||
Long teamId = requireLeader(auth);
|
||||
if (teamId == null) return ResponseEntity.status(HttpStatus.FORBIDDEN).build();
|
||||
return procurement
|
||||
.offlineLicenseFile(teamId)
|
||||
.<ResponseEntity<String>>map(
|
||||
cert ->
|
||||
ResponseEntity.ok()
|
||||
.header(
|
||||
HttpHeaders.CONTENT_DISPOSITION,
|
||||
"attachment; filename=\"stirling-enterprise.lic\"")
|
||||
.contentType(MediaType.TEXT_PLAIN)
|
||||
.body(cert))
|
||||
.orElseGet(() -> ResponseEntity.notFound().build());
|
||||
}
|
||||
|
||||
@PostMapping("/trial/start")
|
||||
@PreAuthorize("isAuthenticated()")
|
||||
public ResponseEntity<SnapshotResponse> startTrial(Authentication auth) {
|
||||
Long teamId = requireLeader(auth);
|
||||
if (teamId == null) return ResponseEntity.status(HttpStatus.FORBIDDEN).build();
|
||||
return ResponseEntity.ok(toSnapshot(procurement.startTrial(teamId), true));
|
||||
}
|
||||
|
||||
@PostMapping("/trial/extend")
|
||||
@PreAuthorize("isAuthenticated()")
|
||||
public ResponseEntity<SnapshotResponse> extendTrial(Authentication auth) {
|
||||
Long teamId = requireLeader(auth);
|
||||
if (teamId == null) return ResponseEntity.status(HttpStatus.FORBIDDEN).build();
|
||||
try {
|
||||
return ResponseEntity.ok(toSnapshot(procurement.extendTrial(teamId), true));
|
||||
} catch (IllegalStateException e) {
|
||||
return ResponseEntity.status(HttpStatus.CONFLICT).build();
|
||||
}
|
||||
}
|
||||
|
||||
@PostMapping("/quote")
|
||||
@PreAuthorize("isAuthenticated()")
|
||||
public ResponseEntity<QuoteResponse> buildQuote(
|
||||
@RequestBody QuoteRequest request, Authentication auth) {
|
||||
Long teamId = requireLeader(auth);
|
||||
if (teamId == null) return ResponseEntity.status(HttpStatus.FORBIDDEN).build();
|
||||
return ResponseEntity.ok(
|
||||
toQuote(
|
||||
procurement.buildQuote(
|
||||
teamId, request.toConfig(), request.businessName())));
|
||||
}
|
||||
|
||||
// Issue + accept are Supabase edge functions (they own Stripe): issue-procurement-quote turns a
|
||||
// draft into a finalized Stripe Quote; accept-procurement-quote accepts it into a subscription.
|
||||
// Both persist their results via SECURITY DEFINER RPCs; the snapshot above reflects them.
|
||||
|
||||
/**
|
||||
* Advance an issued quote to the agreement (security) stage, where the buyer reviews + agrees.
|
||||
*/
|
||||
@PostMapping("/agreement")
|
||||
@PreAuthorize("isAuthenticated()")
|
||||
public ResponseEntity<SnapshotResponse> startAgreement(Authentication auth) {
|
||||
Long teamId = requireLeader(auth);
|
||||
if (teamId == null) return ResponseEntity.status(HttpStatus.FORBIDDEN).build();
|
||||
try {
|
||||
return ResponseEntity.ok(toSnapshot(procurement.startAgreement(teamId), true));
|
||||
} catch (IllegalStateException e) {
|
||||
return ResponseEntity.status(HttpStatus.CONFLICT).build();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Provision on accept: upgrade the team's licence to the committed annual term, valid
|
||||
* immediately. Called server-side by the accept edge function (ROLE_ADMIN via X-API-Key) once
|
||||
* the subscription + invoice exist, so the buyer is licensed the moment they accept — the deal
|
||||
* stays in the payment step until the invoice settles. Idempotent.
|
||||
*/
|
||||
@PostMapping("/provision")
|
||||
@PreAuthorize("hasRole('ADMIN')")
|
||||
public ResponseEntity<Void> provision(@RequestParam("teamId") long teamId) {
|
||||
try {
|
||||
procurement.provisionLicense(teamId);
|
||||
return ResponseEntity.ok().build();
|
||||
} catch (IllegalStateException e) {
|
||||
log.warn("[procurement] provision rejected team={}: {}", teamId, e.getMessage());
|
||||
return ResponseEntity.status(HttpStatus.CONFLICT).build();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Demo/manual stand-in for the {@code invoice.paid} webhook: mark the deal live (issue the
|
||||
* annual licence, advance to active). The real go-live is webhook-driven once payment settles.
|
||||
*/
|
||||
@PostMapping("/go-live")
|
||||
@PreAuthorize("isAuthenticated()")
|
||||
public ResponseEntity<SnapshotResponse> goLive(Authentication auth) {
|
||||
if (!config.isDemoControlsEnabled()) return ResponseEntity.notFound().build();
|
||||
Long teamId = requireLeader(auth);
|
||||
if (teamId == null) return ResponseEntity.status(HttpStatus.FORBIDDEN).build();
|
||||
try {
|
||||
return ResponseEntity.ok(toSnapshot(procurement.markLive(teamId), true));
|
||||
} catch (IllegalStateException e) {
|
||||
return ResponseEntity.status(HttpStatus.CONFLICT).build();
|
||||
}
|
||||
}
|
||||
|
||||
/** Reset the team's procurement (delete the deal + quotes); returns the empty snapshot. */
|
||||
@PostMapping("/reset")
|
||||
@PreAuthorize("isAuthenticated()")
|
||||
public ResponseEntity<SnapshotResponse> reset(Authentication auth) {
|
||||
if (!config.isDemoControlsEnabled()) return ResponseEntity.notFound().build();
|
||||
Long teamId = requireLeader(auth);
|
||||
if (teamId == null) return ResponseEntity.status(HttpStatus.FORBIDDEN).build();
|
||||
procurement.resetDeal(teamId);
|
||||
return ResponseEntity.ok(EMPTY_SNAPSHOT);
|
||||
}
|
||||
|
||||
// ---- helpers ------------------------------------------------------------
|
||||
|
||||
/** The caller's primary team membership; empty when unauthenticated/teamless. */
|
||||
private Optional<TeamMembership> primaryMembership(Authentication auth) {
|
||||
User user;
|
||||
try {
|
||||
user = AuthenticationUtils.getCurrentUser(auth, userRepository);
|
||||
} catch (SecurityException e) {
|
||||
return Optional.empty();
|
||||
}
|
||||
return memberRepo.findPrimaryMembership(user.getId()).stream().findFirst();
|
||||
}
|
||||
|
||||
/** Team id only when the caller is the team leader; null otherwise (commercial actions). */
|
||||
private Long requireLeader(Authentication auth) {
|
||||
return primaryMembership(auth)
|
||||
.filter(m -> m.getRole() == TeamRole.LEADER)
|
||||
.map(m -> m.getTeam().getId())
|
||||
.orElse(null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the snapshot for a deal. {@code includeLicenseKey} is true only for the team leader; a
|
||||
* member sees {@code licensed} but not the key itself (see {@link #snapshot}). Mutation
|
||||
* endpoints are leader-gated, so they always pass true.
|
||||
*/
|
||||
private SnapshotResponse toSnapshot(ProcurementDeal deal, boolean includeLicenseKey) {
|
||||
QuoteResponse latest =
|
||||
procurement.quotesForDeal(deal.getDealId()).stream()
|
||||
.findFirst()
|
||||
.map(this::toQuote)
|
||||
.orElse(null);
|
||||
return new SnapshotResponse(
|
||||
deal.getDealId(),
|
||||
deal.getStage(),
|
||||
str(deal.getTrialStartedAt()),
|
||||
str(deal.getTrialEndsAt()),
|
||||
deal.getTrialExtensionsUsed(),
|
||||
deal.getLicenseRef() != null,
|
||||
includeLicenseKey ? deal.getLicenseRef() : null,
|
||||
latest);
|
||||
}
|
||||
|
||||
private QuoteResponse toQuote(ProcurementQuote q) {
|
||||
return new QuoteResponse(
|
||||
q.getQuoteId(),
|
||||
q.getQuoteNumber(),
|
||||
q.getStatus(),
|
||||
q.getCurrency(),
|
||||
q.getAnnualNetMinor(),
|
||||
q.getTcvMinor(),
|
||||
parseLineItems(q.getLineItemsJson()),
|
||||
q.getValidUntil() == null ? null : q.getValidUntil().toString(),
|
||||
q.getStripeQuoteId(),
|
||||
q.getStripeInvoiceUrl(),
|
||||
new QuoteConfigEcho(
|
||||
q.getVolume(),
|
||||
0,
|
||||
q.getIntensity(),
|
||||
q.getDeployment(),
|
||||
q.getTermYears(),
|
||||
q.getServiceLevel(),
|
||||
q.isIndemnification(),
|
||||
q.isTraining(),
|
||||
q.isQbr(),
|
||||
q.isOfflineLicense(),
|
||||
q.getCurrency(),
|
||||
q.getBusinessName()));
|
||||
}
|
||||
|
||||
private List<QuoteLineItem> parseLineItems(String json) {
|
||||
if (json == null || json.isBlank()) return List.of();
|
||||
try {
|
||||
return OBJECT_MAPPER.readValue(
|
||||
json,
|
||||
OBJECT_MAPPER
|
||||
.getTypeFactory()
|
||||
.constructCollectionType(List.class, QuoteLineItem.class));
|
||||
} catch (Exception e) {
|
||||
log.warn("[procurement] failed to parse line items", e);
|
||||
return List.of();
|
||||
}
|
||||
}
|
||||
|
||||
private static String str(Object o) {
|
||||
return o == null ? null : o.toString();
|
||||
}
|
||||
}
|
||||
+55
@@ -0,0 +1,55 @@
|
||||
package stirling.software.saas.procurement.config;
|
||||
|
||||
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||
import org.springframework.context.annotation.Profile;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import lombok.Getter;
|
||||
import lombok.Setter;
|
||||
|
||||
/**
|
||||
* Keygen credentials + policy for issuing enterprise procurement licences directly from Java.
|
||||
* Prefix {@code stirling.keygen}. All secrets come from the environment (relaxed binding: {@code
|
||||
* STIRLING_KEYGEN_ACCOUNT_ID}, {@code STIRLING_KEYGEN_API_TOKEN}, …) — never committed.
|
||||
*
|
||||
* <p>{@code enabled} is the switch between {@code MockEnterpriseLicenseService} (default) and the
|
||||
* real {@code KeygenEnterpriseLicenseService}; the mock stays in place until the env vars are
|
||||
* wired.
|
||||
*/
|
||||
@Getter
|
||||
@Setter
|
||||
@Component
|
||||
@Profile("saas")
|
||||
@ConfigurationProperties(prefix = "stirling.keygen")
|
||||
public class KeygenConfigurationProperties {
|
||||
|
||||
/** Master switch: when true, the real Keygen client replaces the mock licence service. */
|
||||
private boolean enabled = false;
|
||||
|
||||
/** Keygen account id (UUID or slug). From {@code STIRLING_KEYGEN_ACCOUNT_ID}. */
|
||||
private String accountId;
|
||||
|
||||
/** Keygen admin API token. From {@code STIRLING_KEYGEN_API_TOKEN}. Never log this. */
|
||||
private String apiToken;
|
||||
|
||||
/** Policy the committed-enterprise licences are created under. From {@code ..._POLICY_ID}. */
|
||||
private String policyId;
|
||||
|
||||
/** API base; overridable for self-hosted Keygen, defaults to the hosted service. */
|
||||
private String apiBase = "https://api.keygen.sh/v1";
|
||||
|
||||
/**
|
||||
* License-file check-out algorithm. Must stay {@code base64+ed25519} — the self-hosted {@code
|
||||
* KeygenLicenseVerifier} only verifies that scheme (signed, unencrypted) offline.
|
||||
*/
|
||||
private String licenseFileAlgorithm = "base64+ed25519";
|
||||
|
||||
/** True when the credentials needed to talk to Keygen are all present. */
|
||||
public boolean isConfigured() {
|
||||
return notBlank(accountId) && notBlank(apiToken) && notBlank(policyId);
|
||||
}
|
||||
|
||||
private static boolean notBlank(String s) {
|
||||
return s != null && !s.isBlank();
|
||||
}
|
||||
}
|
||||
+33
@@ -0,0 +1,33 @@
|
||||
package stirling.software.saas.procurement.config;
|
||||
|
||||
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||
import org.springframework.context.annotation.Profile;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import lombok.Getter;
|
||||
import lombok.Setter;
|
||||
|
||||
/** Tunables for the enterprise procurement flow. Prefix {@code stirling.procurement}. */
|
||||
@Getter
|
||||
@Setter
|
||||
@Component
|
||||
@Profile("saas")
|
||||
@ConfigurationProperties(prefix = "stirling.procurement")
|
||||
public class ProcurementConfigurationProperties {
|
||||
|
||||
/** Free trial length, in days (no card). */
|
||||
private int trialDurationDays = 14;
|
||||
|
||||
/** Days added per trial extension. */
|
||||
private int trialExtensionDays = 7;
|
||||
|
||||
/** Maximum number of trial extensions a buyer may take. */
|
||||
private int maxTrialExtensions = 2;
|
||||
|
||||
/**
|
||||
* Enables the demo-only endpoints (POST /reset, POST /go-live) that reset a team's procurement
|
||||
* or mark it live without payment. Off by default; turn on ONLY in demo/dev environments —
|
||||
* /go-live is a stand-in for the invoice.paid webhook and would let a leader activate unpaid.
|
||||
*/
|
||||
private boolean demoControlsEnabled = false;
|
||||
}
|
||||
+47
@@ -0,0 +1,47 @@
|
||||
package stirling.software.saas.procurement.license;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/**
|
||||
* Issues and modifies the customer-facing entitlement that actually unlocks the product for an
|
||||
* enterprise deal — a Keygen licence (trial or annual, connected or air-gapped). This is the seam
|
||||
* the real Keygen management client plugs into; today {@link MockEnterpriseLicenseService} records
|
||||
* intent without calling Keygen. Distinct from the EE {@code KeygenLicenseVerifier}, which only
|
||||
* verifies this instance's own licence.
|
||||
*/
|
||||
public interface EnterpriseLicenseService {
|
||||
|
||||
/**
|
||||
* Issue a time-boxed trial licence for the team, owned by {@code ownerEmail} (the team leader);
|
||||
* returns the licence reference (the Keygen key, stored on the deal).
|
||||
*/
|
||||
String issueTrialLicense(Long teamId, String ownerEmail, LocalDateTime expiresAt);
|
||||
|
||||
/** Move a licence's expiry out (trial extension). */
|
||||
void extendLicense(String licenseRef, LocalDateTime newExpiry);
|
||||
|
||||
/**
|
||||
* Issue/upgrade to a committed annual licence carrying the deal's {@link LicenseEntitlements}
|
||||
* ({@code seats} = 0 means unlimited). When {@code existingRef} is non-null (the team already
|
||||
* has a trial licence), that licence is upgraded in place so the key the buyer already holds
|
||||
* keeps working; otherwise a new licence is created. Owned by {@code ownerEmail}; returns the
|
||||
* licence reference.
|
||||
*/
|
||||
String issueAnnualLicense(
|
||||
Long teamId,
|
||||
String ownerEmail,
|
||||
LocalDateTime expiresAt,
|
||||
String existingRef,
|
||||
LicenseEntitlements entitlements);
|
||||
|
||||
/** Suspend a licence (e.g. payment failed, deal lost). */
|
||||
void suspendLicense(String licenseRef);
|
||||
|
||||
/**
|
||||
* Check out a signed, offline-verifiable licence file (a {@code -----BEGIN LICENSE FILE-----}
|
||||
* certificate) for the given licence, for an air-gapped self-hosted instance. The paid offline
|
||||
* add-on gates whether this is offered; the certificate itself is generated on demand and never
|
||||
* stored.
|
||||
*/
|
||||
String checkOutLicenseFile(String licenseRef);
|
||||
}
|
||||
+304
@@ -0,0 +1,304 @@
|
||||
package stirling.software.saas.procurement.license;
|
||||
|
||||
import java.net.URI;
|
||||
import java.net.URLEncoder;
|
||||
import java.net.http.HttpClient;
|
||||
import java.net.http.HttpRequest;
|
||||
import java.net.http.HttpResponse;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.time.Duration;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.ZoneOffset;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||
import org.springframework.context.annotation.Profile;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.saas.procurement.config.KeygenConfigurationProperties;
|
||||
|
||||
/**
|
||||
* Real {@link EnterpriseLicenseService}: manages the team's enterprise licence directly against the
|
||||
* Keygen API (the "call Keygen from Java" direction), rather than via the Supabase edge functions
|
||||
* the self-hosted checkout uses. Active only when {@code stirling.keygen.enabled=true}; otherwise
|
||||
* {@link MockEnterpriseLicenseService} is the bean.
|
||||
*
|
||||
* <p>Licences are owned by the team leader (a Keygen user, found or created by email) and created
|
||||
* under the committed-enterprise policy. Metadata carries {@code isEnterprise} + {@code users} so a
|
||||
* checked-out offline licence file satisfies the self-hosted {@code KeygenLicenseVerifier}. The
|
||||
* licence key returned is stored on the deal as its {@code license_ref}.
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
@Profile("saas")
|
||||
@ConditionalOnProperty(name = "stirling.keygen.enabled", havingValue = "true")
|
||||
public class KeygenEnterpriseLicenseService implements EnterpriseLicenseService {
|
||||
|
||||
private static final String VND_JSON = "application/vnd.api+json";
|
||||
|
||||
private final ObjectMapper mapper = new ObjectMapper();
|
||||
private final HttpClient http =
|
||||
HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(10)).build();
|
||||
private final KeygenConfigurationProperties config;
|
||||
|
||||
public KeygenEnterpriseLicenseService(KeygenConfigurationProperties config) {
|
||||
this.config = config;
|
||||
// Fail fast: if the flag is on but creds are missing, a misconfigured prod deploy should be
|
||||
// caught at startup, not at the first trial/provision. (Flag off → Mock bean, never here.)
|
||||
if (!config.isConfigured()) {
|
||||
throw new IllegalStateException(
|
||||
"stirling.keygen.enabled=true but Keygen is not fully configured — set "
|
||||
+ "STIRLING_KEYGEN_ACCOUNT_ID / _API_TOKEN / _POLICY_ID, or turn the flag off");
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public String issueTrialLicense(Long teamId, String ownerEmail, LocalDateTime expiresAt) {
|
||||
String ownerId = findOrCreateUser(ownerEmail);
|
||||
// Trial: enterprise entitlement, unlimited users, expiring at the trial end. No committed
|
||||
// volume/add-ons yet — those are stamped on the annual licence at provision.
|
||||
return createLicense(ownerId, expiresAt, trialMetadata(teamId));
|
||||
}
|
||||
|
||||
@Override
|
||||
public void extendLicense(String licenseRef, LocalDateTime newExpiry) {
|
||||
Map<String, Object> attrs = new LinkedHashMap<>();
|
||||
attrs.put("expiry", iso(newExpiry));
|
||||
patchLicense(licenseRef, attrs);
|
||||
}
|
||||
|
||||
@Override
|
||||
public String issueAnnualLicense(
|
||||
Long teamId,
|
||||
String ownerEmail,
|
||||
LocalDateTime expiresAt,
|
||||
String existingRef,
|
||||
LicenseEntitlements ent) {
|
||||
Map<String, Object> metadata = annualMetadata(teamId, ent);
|
||||
// Upgrade the trial licence in place so the key the buyer already holds keeps working.
|
||||
if (existingRef != null && !existingRef.isBlank()) {
|
||||
Map<String, Object> attrs = new LinkedHashMap<>();
|
||||
attrs.put("expiry", iso(expiresAt));
|
||||
attrs.put("suspended", false);
|
||||
attrs.put("metadata", metadata);
|
||||
patchLicense(existingRef, attrs);
|
||||
return existingRef;
|
||||
}
|
||||
String ownerId = findOrCreateUser(ownerEmail);
|
||||
return createLicense(ownerId, expiresAt, metadata);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void suspendLicense(String licenseRef) {
|
||||
HttpResponse<String> res =
|
||||
send(
|
||||
authed(licenseUrl(licenseRef) + "/actions/suspend")
|
||||
.POST(HttpRequest.BodyPublishers.noBody())
|
||||
.build());
|
||||
expect(res, 200, "suspend licence");
|
||||
}
|
||||
|
||||
@Override
|
||||
public String checkOutLicenseFile(String licenseRef) {
|
||||
// Signed, unencrypted (base64+ed25519) so the self-hosted verifier can validate it offline;
|
||||
// include entitlements in the snapshot. Never encrypt — the verifier only reads
|
||||
// base64+ed25519.
|
||||
String url =
|
||||
licenseUrl(licenseRef)
|
||||
+ "/actions/check-out?include=entitlements&algorithm="
|
||||
+ enc(config.getLicenseFileAlgorithm());
|
||||
HttpResponse<String> res =
|
||||
send(authed(url).POST(HttpRequest.BodyPublishers.noBody()).build());
|
||||
expect(res, 200, "check-out licence file");
|
||||
JsonNode cert = readJson(res).at("/data/attributes/certificate");
|
||||
if (cert.isMissingNode() || cert.asText().isBlank()) {
|
||||
throw new IllegalStateException("Keygen check-out returned no certificate");
|
||||
}
|
||||
return cert.asText();
|
||||
}
|
||||
|
||||
// ---- Keygen primitives --------------------------------------------------
|
||||
|
||||
/** Find the Keygen user by email, creating it if absent; returns the user id. */
|
||||
private String findOrCreateUser(String email) {
|
||||
if (email == null || email.isBlank()) {
|
||||
throw new IllegalArgumentException("Cannot own a licence without an owner email");
|
||||
}
|
||||
HttpResponse<String> find =
|
||||
send(authed(accountUrl() + "/users/" + enc(email)).GET().build());
|
||||
if (find.statusCode() == 200) {
|
||||
return readJson(find).at("/data/id").asText();
|
||||
}
|
||||
if (find.statusCode() != 404) {
|
||||
expect(find, 200, "find Keygen user"); // throws with the real status
|
||||
}
|
||||
Map<String, Object> body =
|
||||
jsonApi("users", Map.of("email", email), null); // no relationships
|
||||
HttpResponse<String> create =
|
||||
send(
|
||||
authed(accountUrl() + "/users")
|
||||
.POST(HttpRequest.BodyPublishers.ofString(write(body)))
|
||||
.build());
|
||||
expect(create, 201, "create Keygen user");
|
||||
return readJson(create).at("/data/id").asText();
|
||||
}
|
||||
|
||||
private String createLicense(
|
||||
String ownerId, LocalDateTime expiresAt, Map<String, Object> metadata) {
|
||||
Map<String, Object> attributes = new LinkedHashMap<>();
|
||||
attributes.put("expiry", iso(expiresAt));
|
||||
attributes.put("metadata", metadata);
|
||||
Map<String, Object> relationships =
|
||||
Map.of(
|
||||
"policy",
|
||||
Map.of("data", Map.of("type", "policies", "id", config.getPolicyId())),
|
||||
"owner",
|
||||
Map.of("data", Map.of("type", "users", "id", ownerId)));
|
||||
Map<String, Object> body = jsonApi("licenses", attributes, relationships);
|
||||
HttpResponse<String> res =
|
||||
send(
|
||||
authed(accountUrl() + "/licenses")
|
||||
.POST(HttpRequest.BodyPublishers.ofString(write(body)))
|
||||
.build());
|
||||
expect(res, 201, "create licence");
|
||||
return readJson(res).at("/data/attributes/key").asText();
|
||||
}
|
||||
|
||||
private void patchLicense(String licenseRef, Map<String, Object> attributes) {
|
||||
Map<String, Object> body = jsonApi("licenses", attributes, null);
|
||||
HttpResponse<String> res =
|
||||
send(
|
||||
authed(licenseUrl(licenseRef))
|
||||
.method("PATCH", HttpRequest.BodyPublishers.ofString(write(body)))
|
||||
.build());
|
||||
expect(res, 200, "update licence");
|
||||
}
|
||||
|
||||
// ---- helpers ------------------------------------------------------------
|
||||
|
||||
// The self-hosted verifier only reads isEnterprise + users; everything else is informational
|
||||
// (dashboard / reconciliation) but kept so the licence is a self-describing record of the deal.
|
||||
|
||||
/** Trial licence: enterprise, unlimited users, no committed volume/add-ons yet. */
|
||||
private Map<String, Object> trialMetadata(Long teamId) {
|
||||
Map<String, Object> m = baseMetadata(teamId, true);
|
||||
m.put("users", 0); // 0 = unlimited during the trial
|
||||
m.put("seat_count", 0);
|
||||
return m;
|
||||
}
|
||||
|
||||
/** Committed annual licence: the full entitlement snapshot from the accepted quote + deal. */
|
||||
private Map<String, Object> annualMetadata(Long teamId, LicenseEntitlements ent) {
|
||||
Map<String, Object> m = baseMetadata(teamId, false);
|
||||
int seats = Math.max(0, ent.seats());
|
||||
m.put("users", seats); // 0 = unlimited
|
||||
m.put("seat_count", seats); // parity with the self-hosted edge's metadata
|
||||
m.put("volume", ent.volume()); // committed PDFs / year
|
||||
m.put("term_years", ent.termYears());
|
||||
if (ent.serviceLevel() != null && !ent.serviceLevel().isBlank()) {
|
||||
m.put("service_level", ent.serviceLevel());
|
||||
}
|
||||
m.put("indemnification", ent.indemnification());
|
||||
m.put("training", ent.training());
|
||||
m.put("qbr", ent.qbr());
|
||||
m.put("offline_license", ent.offlineLicense());
|
||||
if (ent.deployment() != null && !ent.deployment().isBlank()) {
|
||||
m.put("deployment", ent.deployment());
|
||||
}
|
||||
if (ent.dealId() != null) m.put("deal_id", ent.dealId());
|
||||
if (ent.subscriptionId() != null && !ent.subscriptionId().isBlank()) {
|
||||
m.put("subscription_id", ent.subscriptionId());
|
||||
}
|
||||
return m;
|
||||
}
|
||||
|
||||
private Map<String, Object> baseMetadata(Long teamId, boolean trial) {
|
||||
Map<String, Object> m = new LinkedHashMap<>();
|
||||
m.put("team_id", teamId);
|
||||
m.put("plan_type", "enterprise");
|
||||
m.put("isEnterprise", true);
|
||||
m.put("trial", trial);
|
||||
return m;
|
||||
}
|
||||
|
||||
private Map<String, Object> jsonApi(
|
||||
String type, Map<String, Object> attributes, Map<String, Object> relationships) {
|
||||
Map<String, Object> data = new LinkedHashMap<>();
|
||||
data.put("type", type);
|
||||
data.put("attributes", attributes);
|
||||
if (relationships != null) data.put("relationships", relationships);
|
||||
return Map.of("data", data);
|
||||
}
|
||||
|
||||
private HttpRequest.Builder authed(String url) {
|
||||
return HttpRequest.newBuilder()
|
||||
.uri(URI.create(url))
|
||||
.header("Authorization", "Bearer " + config.getApiToken())
|
||||
.header("Content-Type", VND_JSON)
|
||||
.header("Accept", VND_JSON)
|
||||
.timeout(Duration.ofSeconds(30));
|
||||
}
|
||||
|
||||
private HttpResponse<String> send(HttpRequest request) {
|
||||
try {
|
||||
return http.send(request, HttpResponse.BodyHandlers.ofString());
|
||||
} catch (java.io.IOException e) {
|
||||
throw new IllegalStateException("Keygen request failed: " + e.getMessage(), e);
|
||||
} catch (InterruptedException e) {
|
||||
Thread.currentThread().interrupt();
|
||||
throw new IllegalStateException("Keygen request interrupted", e);
|
||||
}
|
||||
}
|
||||
|
||||
private void expect(HttpResponse<String> res, int status, String action) {
|
||||
if (res.statusCode() != status) {
|
||||
// Keep the response body out of the thrown message: it never carries the token, but can
|
||||
// echo owner emails / metadata, and the message reaches warn-level logs. Body at debug.
|
||||
log.debug(
|
||||
"[procurement][keygen] {} failed: HTTP {} body={}",
|
||||
action,
|
||||
res.statusCode(),
|
||||
res.body());
|
||||
throw new IllegalStateException(
|
||||
"Keygen " + action + " failed: HTTP " + res.statusCode());
|
||||
}
|
||||
}
|
||||
|
||||
private JsonNode readJson(HttpResponse<String> res) {
|
||||
try {
|
||||
return mapper.readTree(res.body());
|
||||
} catch (Exception e) {
|
||||
throw new IllegalStateException("Keygen returned unparseable JSON", e);
|
||||
}
|
||||
}
|
||||
|
||||
private String write(Map<String, Object> body) {
|
||||
try {
|
||||
return mapper.writeValueAsString(body);
|
||||
} catch (Exception e) {
|
||||
throw new IllegalStateException("Failed to serialise Keygen request", e);
|
||||
}
|
||||
}
|
||||
|
||||
private String accountUrl() {
|
||||
return config.getApiBase() + "/accounts/" + config.getAccountId();
|
||||
}
|
||||
|
||||
private String licenseUrl(String licenseRef) {
|
||||
return accountUrl() + "/licenses/" + enc(licenseRef);
|
||||
}
|
||||
|
||||
private static String enc(String s) {
|
||||
return URLEncoder.encode(s, StandardCharsets.UTF_8);
|
||||
}
|
||||
|
||||
private static String iso(LocalDateTime dt) {
|
||||
return dt.toInstant(ZoneOffset.UTC).toString();
|
||||
}
|
||||
}
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
package stirling.software.saas.procurement.license;
|
||||
|
||||
/**
|
||||
* The committed deal's entitlements, stamped onto the annual Keygen licence's metadata so the
|
||||
* licence is a self-describing record of what was bought. Only {@code seats} (as {@code users}) and
|
||||
* the enterprise flag are read by the self-hosted verifier; the rest is informational — kept "for
|
||||
* good measure" so the Keygen dashboard and any downstream reconciliation can see the full picture.
|
||||
* Built by {@code ProcurementService} from the accepted quote + deal.
|
||||
*/
|
||||
public record LicenseEntitlements(
|
||||
long volume, // committed PDFs / year
|
||||
int seats, // 0 = unlimited
|
||||
String deployment, // cloud | selfhost | airgap
|
||||
int termYears,
|
||||
String serviceLevel, // standard | priority | dedicated
|
||||
boolean indemnification,
|
||||
boolean training,
|
||||
boolean qbr,
|
||||
boolean offlineLicense,
|
||||
Long dealId,
|
||||
String subscriptionId) {}
|
||||
+83
@@ -0,0 +1,83 @@
|
||||
package stirling.software.saas.procurement.license;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.UUID;
|
||||
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||
import org.springframework.context.annotation.Profile;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
/**
|
||||
* Mock implementation of {@link EnterpriseLicenseService}: records the intended licence action and
|
||||
* returns a synthetic reference, without calling Keygen. Lets the whole procurement journey run
|
||||
* end-to-end while the real Keygen management client is a later drop-in — the seam and the stored
|
||||
* {@code license_ref} on the deal stay identical.
|
||||
*
|
||||
* <p>This is the default; it steps aside for {@code KeygenEnterpriseLicenseService} when {@code
|
||||
* stirling.keygen.enabled=true} (real Keygen secrets are wired).
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
@Profile("saas")
|
||||
@ConditionalOnProperty(
|
||||
name = "stirling.keygen.enabled",
|
||||
havingValue = "false",
|
||||
matchIfMissing = true)
|
||||
public class MockEnterpriseLicenseService implements EnterpriseLicenseService {
|
||||
|
||||
@Override
|
||||
public String issueTrialLicense(Long teamId, String ownerEmail, LocalDateTime expiresAt) {
|
||||
String ref = "mock-trial-" + UUID.randomUUID();
|
||||
// Owner email is deliberately not logged — it's PII and adds nothing to the mock trace.
|
||||
log.info(
|
||||
"[procurement][mock-license] issue trial team={} expires={} ref={}",
|
||||
teamId,
|
||||
expiresAt,
|
||||
ref);
|
||||
return ref;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void extendLicense(String licenseRef, LocalDateTime newExpiry) {
|
||||
log.info("[procurement][mock-license] extend ref={} newExpiry={}", licenseRef, newExpiry);
|
||||
}
|
||||
|
||||
@Override
|
||||
public String issueAnnualLicense(
|
||||
Long teamId,
|
||||
String ownerEmail,
|
||||
LocalDateTime expiresAt,
|
||||
String existingRef,
|
||||
LicenseEntitlements ent) {
|
||||
// Upgrade in place when a trial licence already exists, so the key stays stable.
|
||||
String ref = existingRef != null ? existingRef : "mock-annual-" + UUID.randomUUID();
|
||||
// Owner email is deliberately not logged — it's PII and adds nothing to the mock trace.
|
||||
log.info(
|
||||
"[procurement][mock-license] issue annual team={} seats={} volume={} deployment={} expires={} ref={} upgrade={}",
|
||||
teamId,
|
||||
ent.seats(),
|
||||
ent.volume(),
|
||||
ent.deployment(),
|
||||
expiresAt,
|
||||
ref,
|
||||
existingRef != null);
|
||||
return ref;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void suspendLicense(String licenseRef) {
|
||||
log.info("[procurement][mock-license] suspend ref={}", licenseRef);
|
||||
}
|
||||
|
||||
@Override
|
||||
public String checkOutLicenseFile(String licenseRef) {
|
||||
log.info("[procurement][mock-license] check-out licence file ref={}", licenseRef);
|
||||
// A syntactically shaped stand-in so the portal download path is exercisable without
|
||||
// Keygen; not a valid certificate.
|
||||
return "-----BEGIN LICENSE FILE-----\nmock-offline-license-for-"
|
||||
+ licenseRef
|
||||
+ "\n-----END LICENSE FILE-----\n";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
package stirling.software.saas.procurement.model;
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
import org.hibernate.annotations.CreationTimestamp;
|
||||
import org.hibernate.annotations.UpdateTimestamp;
|
||||
|
||||
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.Version;
|
||||
|
||||
import lombok.Getter;
|
||||
import lombok.NoArgsConstructor;
|
||||
import lombok.Setter;
|
||||
|
||||
/**
|
||||
* A linked team's enterprise commercial journey (one per team). Stage mirrors the buyer journey the
|
||||
* portal renders (trial -> quote -> agreement -> payment -> live). The entitlement that
|
||||
* actually unlocks the product is the Keygen licence in {@code licenseRef}; the paid subscription,
|
||||
* once commercial, is mirrored in {@code billing_subscriptions} and referenced by {@code
|
||||
* subscriptionId}.
|
||||
*/
|
||||
@Entity
|
||||
@Table(name = "procurement_deal")
|
||||
@NoArgsConstructor
|
||||
@Getter
|
||||
@Setter
|
||||
public class ProcurementDeal implements Serializable {
|
||||
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
public static final String STAGE_TRIAL = "trial";
|
||||
public static final String STAGE_QUOTE = "quote";
|
||||
public static final String STAGE_AGREEMENT = "security";
|
||||
public static final String STAGE_PAYMENT = "procurement";
|
||||
public static final String STAGE_LIVE = "active";
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
@Column(name = "deal_id")
|
||||
private Long dealId;
|
||||
|
||||
@Column(name = "team_id", nullable = false, unique = true)
|
||||
private Long teamId;
|
||||
|
||||
@Column(name = "stage", nullable = false, length = 32)
|
||||
private String stage = STAGE_TRIAL;
|
||||
|
||||
@Column(name = "trial_started_at")
|
||||
private LocalDateTime trialStartedAt;
|
||||
|
||||
@Column(name = "trial_ends_at")
|
||||
private LocalDateTime trialEndsAt;
|
||||
|
||||
@Column(name = "trial_extensions_used", nullable = false)
|
||||
private int trialExtensionsUsed;
|
||||
|
||||
@Column(name = "license_ref", length = 128)
|
||||
private String licenseRef;
|
||||
|
||||
@Column(name = "subscription_id", length = 255)
|
||||
private String subscriptionId;
|
||||
|
||||
@Column(name = "accepted_quote_id")
|
||||
private Long acceptedQuoteId;
|
||||
|
||||
@CreationTimestamp
|
||||
@Column(name = "created_at", nullable = false, updatable = false)
|
||||
private LocalDateTime createdAt;
|
||||
|
||||
@UpdateTimestamp
|
||||
@Column(name = "updated_at", nullable = false)
|
||||
private LocalDateTime updatedAt;
|
||||
|
||||
@Version
|
||||
@Column(name = "version", nullable = false)
|
||||
private Long version;
|
||||
|
||||
public ProcurementDeal(Long teamId) {
|
||||
this.teamId = teamId;
|
||||
}
|
||||
}
|
||||
+126
@@ -0,0 +1,126 @@
|
||||
package stirling.software.saas.procurement.model;
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.time.LocalDate;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
import org.hibernate.annotations.CreationTimestamp;
|
||||
import org.hibernate.annotations.UpdateTimestamp;
|
||||
|
||||
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.Version;
|
||||
|
||||
import lombok.Getter;
|
||||
import lombok.NoArgsConstructor;
|
||||
import lombok.Setter;
|
||||
|
||||
/**
|
||||
* A priced, itemised offer built against a {@link ProcurementDeal}. The config columns are the
|
||||
* buyer's choices; {@code annualNetMinor}/{@code tcvMinor} and {@code lineItemsJson} are the
|
||||
* server-computed result (never trusted from the client). Stripe fields are populated when the
|
||||
* accepted quote is turned into a checkout.
|
||||
*/
|
||||
@Entity
|
||||
@Table(name = "procurement_quote")
|
||||
@NoArgsConstructor
|
||||
@Getter
|
||||
@Setter
|
||||
public class ProcurementQuote implements Serializable {
|
||||
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
public static final String STATUS_DRAFT = "draft";
|
||||
public static final String STATUS_SENT = "sent";
|
||||
public static final String STATUS_ACCEPTED = "accepted";
|
||||
public static final String STATUS_EXPIRED = "expired";
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
@Column(name = "quote_id")
|
||||
private Long quoteId;
|
||||
|
||||
@Column(name = "deal_id", nullable = false)
|
||||
private Long dealId;
|
||||
|
||||
@Column(name = "quote_number", nullable = false, length = 64)
|
||||
private String quoteNumber;
|
||||
|
||||
@Column(name = "status", nullable = false, length = 24)
|
||||
private String status = STATUS_DRAFT;
|
||||
|
||||
@Column(name = "currency", nullable = false, length = 8)
|
||||
private String currency = "USD";
|
||||
|
||||
@Column(name = "volume", nullable = false)
|
||||
private long volume;
|
||||
|
||||
@Column(name = "seats")
|
||||
private Integer seats;
|
||||
|
||||
/** Policy posture as runs-per-PDF: Essentials 2, Governed 4, Regulated 7. Defaults Governed. */
|
||||
@Column(name = "intensity", nullable = false)
|
||||
private int intensity = 4;
|
||||
|
||||
@Column(name = "deployment", length = 24)
|
||||
private String deployment;
|
||||
|
||||
@Column(name = "term_years", nullable = false)
|
||||
private int termYears;
|
||||
|
||||
@Column(name = "service_level", nullable = false, length = 24)
|
||||
private String serviceLevel;
|
||||
|
||||
@Column(name = "indemnification", nullable = false)
|
||||
private boolean indemnification;
|
||||
|
||||
@Column(name = "training", nullable = false)
|
||||
private boolean training;
|
||||
|
||||
@Column(name = "qbr", nullable = false)
|
||||
private boolean qbr;
|
||||
|
||||
@Column(name = "offline_license", nullable = false)
|
||||
private boolean offlineLicense;
|
||||
|
||||
@Column(name = "annual_net_minor", nullable = false)
|
||||
private long annualNetMinor;
|
||||
|
||||
@Column(name = "tcv_minor", nullable = false)
|
||||
private long tcvMinor;
|
||||
|
||||
@Column(name = "line_items", columnDefinition = "text")
|
||||
private String lineItemsJson;
|
||||
|
||||
// The Stripe Quote this was issued as (finalized → has a number + PDF). Set by the edge fn.
|
||||
@Column(name = "stripe_quote_id", length = 128)
|
||||
private String stripeQuoteId;
|
||||
|
||||
// Hosted Stripe invoice URL for the subscription's first invoice, set once the quote is
|
||||
// accepted.
|
||||
@Column(name = "stripe_invoice_url", columnDefinition = "text")
|
||||
private String stripeInvoiceUrl;
|
||||
|
||||
// Buyer's company name (shown on the quote/agreement); echoed back so an edit remembers it.
|
||||
@Column(name = "business_name", length = 255)
|
||||
private String businessName;
|
||||
|
||||
@Column(name = "valid_until")
|
||||
private LocalDate validUntil;
|
||||
|
||||
@CreationTimestamp
|
||||
@Column(name = "created_at", nullable = false, updatable = false)
|
||||
private LocalDateTime createdAt;
|
||||
|
||||
@UpdateTimestamp
|
||||
@Column(name = "updated_at", nullable = false)
|
||||
private LocalDateTime updatedAt;
|
||||
|
||||
@Version
|
||||
@Column(name = "version", nullable = false)
|
||||
private Long version;
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
package stirling.software.saas.procurement.pricing;
|
||||
|
||||
/**
|
||||
* The enterprise rate card (D71): the inputs pricing derives a quote from. In production these are
|
||||
* read from the Stripe price mirror; {@link #defaults()} is the fallback used when the {@code
|
||||
* stripe} schema isn't synced (dev / tests) and is the single source of the numbers the marketing
|
||||
* prototype ({@code quotePricing}) encodes.
|
||||
*
|
||||
* <p>The meter is denominated in <b>runs</b> (a PDF running N policies is N runs). The per-run rate
|
||||
* is a fraction of a dollar, so it lives here as a {@code double} in dollars; flat and one-time
|
||||
* fees are whole-dollar amounts held in minor units (cents). See {@link ProcurementPricingService}
|
||||
* for how they combine.
|
||||
*/
|
||||
public record PricingRates(
|
||||
double listRatePerRun, // $0.01 list per run
|
||||
double floorRatePerRun, // $0.005 asymptotic floor (cost + margin)
|
||||
double discountPerDoubling, // 0.06 = 6% off the per-run rate per doubling past 1M runs/yr
|
||||
double[] termDiscountByYear, // meter-only discount; index 0 = 1yr … index 4 = 5yr
|
||||
double indemnificationRate, // fraction of the net meter (legal exposure scales with usage)
|
||||
long dedicatedSupportMinor, // flat: dedicated SE/CSM (standard + priority are included)
|
||||
long selfHostDeployMinor, // flat: self-hosted deployment
|
||||
long airgapDeployMinor, // flat: air-gapped deployment
|
||||
long qbrAnnualMinor, // flat: quarterly business reviews
|
||||
long trainingOneTimeMinor) { // one-time: onboarding & training
|
||||
|
||||
public static PricingRates defaults() {
|
||||
return new PricingRates(
|
||||
0.01, // $0.01 / run list
|
||||
0.005, // $0.005 / run floor
|
||||
0.06, // 6% off per doubling of committed runs past 1M/yr
|
||||
new double[] {0.0, 0.03, 0.05, 0.06, 0.07}, // 1–5 year term, meter only
|
||||
0.05, // IP indemnification = 5% of the net meter
|
||||
3_000_000, // dedicated SE/CSM $30,000 / yr
|
||||
1_200_000, // self-hosted $12,000 / yr
|
||||
3_600_000, // air-gapped $36,000 / yr
|
||||
800_000, // QBRs $8,000 / yr
|
||||
750_000); // onboarding & training $7,500 one-time
|
||||
}
|
||||
|
||||
public double termDiscount(int termYears) {
|
||||
int idx = Math.max(1, Math.min(termYears, 5)) - 1;
|
||||
return termDiscountByYear[idx];
|
||||
}
|
||||
}
|
||||
+178
@@ -0,0 +1,178 @@
|
||||
package stirling.software.saas.procurement.pricing;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
/**
|
||||
* The canonical enterprise pricing engine (D71) — the single server-side definition the quote
|
||||
* builder, the order form, and Stripe checkout all derive from. A faithful port of the marketing
|
||||
* prototype's {@code quotePricing}:
|
||||
*
|
||||
* <pre>
|
||||
* runVol = volume x intensity // posture: Essentials x2, Governed x4, Regulated x7
|
||||
* volDisc = min(0.5, 0.06 x log2(runVol / 1M)) // 6% off the per-run rate per doubling past 1M
|
||||
* rate = max($0.005, $0.01 x (1 - volDisc)) // continuous curve, floors at half a cent
|
||||
* meterNet = round(runVol x rate x (1 - termDisc)) // whole dollars; term discounts the meter only
|
||||
* annualNet = meterNet + dedicated + deployment + indemnification + qbr // needs priced flat
|
||||
* tcv = annualNet x termYears + training
|
||||
* </pre>
|
||||
*
|
||||
* <p>No volume tiers, no service-level multipliers, no ACV floor — those were the retired model.
|
||||
* Deployment (self-hosted / air-gapped) and dedicated support are flat line items at cost basis;
|
||||
* SSO / SCIM / RBAC / audit are always included. Money is USD only; amounts are in minor units
|
||||
* (cents). Rates come from {@link PricingRates} (Stripe-backed in prod).
|
||||
*/
|
||||
@Service
|
||||
public class ProcurementPricingService {
|
||||
|
||||
private static final double LOG2 = Math.log(2.0);
|
||||
private static final long RUN_CURVE_KNEE = 1_000_000L; // discount starts past 1M committed runs
|
||||
|
||||
/**
|
||||
* Estimated annual PDF volume from seat count (~2,012.5 PDFs/user/yr = 5 docs/day x 230 working
|
||||
* days x 1.75), used to prefill the builder's volume step. Matches the prototype's {@code users
|
||||
* x 5 x 230 x 1.75}.
|
||||
*/
|
||||
public long estimateAnnualVolume(int users) {
|
||||
return Math.round(Math.max(0, users) * 5.0 * 230.0 * 1.75);
|
||||
}
|
||||
|
||||
public QuoteBreakdown price(QuoteConfig cfg) {
|
||||
return price(cfg, PricingRates.defaults());
|
||||
}
|
||||
|
||||
public QuoteBreakdown price(QuoteConfig cfg, PricingRates rates) {
|
||||
// Never trust the client's volume/intensity: clamp so a tampered request can't drive a
|
||||
// negative amount. The curve and flat fees are server-side, so the browser can only pick a
|
||||
// smaller legitimate config, never a cheaper rate.
|
||||
long volume = Math.max(0, cfg.volume());
|
||||
int intensity = Math.max(1, cfg.intensity());
|
||||
long runVol = volume * (long) intensity;
|
||||
|
||||
// Committed-volume curve: continuous, no cliffs. Floors at the per-run cost + margin.
|
||||
double volDisc =
|
||||
runVol > RUN_CURVE_KNEE
|
||||
? Math.min(
|
||||
0.5,
|
||||
rates.discountPerDoubling()
|
||||
* (Math.log(runVol / (double) RUN_CURVE_KNEE) / LOG2))
|
||||
: 0.0;
|
||||
double rate = Math.max(rates.floorRatePerRun(), rates.listRatePerRun() * (1.0 - volDisc));
|
||||
double termDisc = rates.termDiscount(cfg.termYears());
|
||||
|
||||
// The meter is a whole-dollar figure (the quote reads in dollars), then minor units.
|
||||
long annualBaseMinor = Math.round((double) runVol * rate) * 100L;
|
||||
long meterNetMinor = Math.round((double) runVol * rate * (1.0 - termDisc)) * 100L;
|
||||
long termDiscountMinor = meterNetMinor - annualBaseMinor; // <= 0
|
||||
|
||||
long support =
|
||||
"dedicated".equalsIgnoreCase(cfg.serviceLevel())
|
||||
? rates.dedicatedSupportMinor()
|
||||
: 0L;
|
||||
long deploy = deployFeeMinor(cfg.deployment(), rates);
|
||||
long indemnity =
|
||||
cfg.indemnification()
|
||||
? Math.round(meterNetMinor * rates.indemnificationRate())
|
||||
: 0L;
|
||||
long qbr = cfg.qbr() ? rates.qbrAnnualMinor() : 0L;
|
||||
long training = cfg.training() ? rates.trainingOneTimeMinor() : 0L;
|
||||
|
||||
long annualNet = meterNetMinor + support + deploy + indemnity + qbr;
|
||||
long tcv = annualNet * cfg.termYears() + training;
|
||||
|
||||
double effectivePerPdf = rate * intensity; // quotes speak per-PDF-at-posture, never per-run
|
||||
|
||||
List<QuoteLineItem> lines = new ArrayList<>();
|
||||
lines.add(
|
||||
new QuoteLineItem(
|
||||
"usage",
|
||||
String.format(
|
||||
Locale.ROOT,
|
||||
"PDF processing — %,d PDFs/yr at $%.4f/PDF (%s posture)",
|
||||
volume,
|
||||
effectivePerPdf,
|
||||
postureLabel(intensity)),
|
||||
QuoteLineItem.Kind.RECURRING,
|
||||
annualBaseMinor));
|
||||
lines.add(
|
||||
new QuoteLineItem(
|
||||
"seats",
|
||||
"Unlimited users + SSO / SCIM / RBAC / audit",
|
||||
QuoteLineItem.Kind.INCLUDED,
|
||||
0L));
|
||||
if (termDiscountMinor < 0) {
|
||||
lines.add(
|
||||
new QuoteLineItem(
|
||||
"multi-year",
|
||||
cfg.termYears() + "-year commitment",
|
||||
QuoteLineItem.Kind.DISCOUNT,
|
||||
termDiscountMinor));
|
||||
}
|
||||
if (support > 0) {
|
||||
lines.add(
|
||||
new QuoteLineItem(
|
||||
"support",
|
||||
"Dedicated SE / CSM",
|
||||
QuoteLineItem.Kind.RECURRING,
|
||||
support));
|
||||
}
|
||||
if (deploy > 0) {
|
||||
lines.add(
|
||||
new QuoteLineItem(
|
||||
"deployment",
|
||||
deploymentLabel(cfg.deployment()) + " deployment",
|
||||
QuoteLineItem.Kind.RECURRING,
|
||||
deploy));
|
||||
}
|
||||
if (indemnity > 0) {
|
||||
lines.add(
|
||||
new QuoteLineItem(
|
||||
"indemnification",
|
||||
"IP indemnification",
|
||||
QuoteLineItem.Kind.RECURRING,
|
||||
indemnity));
|
||||
}
|
||||
if (qbr > 0) {
|
||||
lines.add(
|
||||
new QuoteLineItem(
|
||||
"qbr",
|
||||
"Quarterly business reviews",
|
||||
QuoteLineItem.Kind.RECURRING,
|
||||
qbr));
|
||||
}
|
||||
if (training > 0) {
|
||||
lines.add(
|
||||
new QuoteLineItem(
|
||||
"training",
|
||||
"Onboarding & training",
|
||||
QuoteLineItem.Kind.ONE_TIME,
|
||||
training));
|
||||
}
|
||||
return new QuoteBreakdown(lines, annualNet, tcv, cfg.currency());
|
||||
}
|
||||
|
||||
private static long deployFeeMinor(String deployment, PricingRates rates) {
|
||||
if ("airgap".equalsIgnoreCase(deployment)) return rates.airgapDeployMinor();
|
||||
if ("selfhost".equalsIgnoreCase(deployment)) return rates.selfHostDeployMinor();
|
||||
return 0L; // cloud (managed) has no deployment fee
|
||||
}
|
||||
|
||||
/** Buyer-facing posture name for the intensity (policy count); the demo's POLICY_POSTURES. */
|
||||
private static String postureLabel(int intensity) {
|
||||
return switch (intensity) {
|
||||
case 2 -> "Essentials";
|
||||
case 4 -> "Governed";
|
||||
case 7 -> "Regulated";
|
||||
default -> intensity + "-policy";
|
||||
};
|
||||
}
|
||||
|
||||
private static String deploymentLabel(String deployment) {
|
||||
if ("airgap".equalsIgnoreCase(deployment)) return "Air-gapped";
|
||||
if ("selfhost".equalsIgnoreCase(deployment)) return "Self-hosted";
|
||||
return "Stirling Cloud";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
package stirling.software.saas.procurement.pricing;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* The priced result of a {@link QuoteConfig}: the itemised lines plus the two headline figures the
|
||||
* order form and Stripe checkout are built from. {@code annualNetMinor} is the recurring annual fee
|
||||
* after the multi-year discount; {@code tcvMinor} is total contract value across the term including
|
||||
* one-time fees. Minor units (cents).
|
||||
*/
|
||||
public record QuoteBreakdown(
|
||||
List<QuoteLineItem> lineItems, long annualNetMinor, long tcvMinor, String currency) {}
|
||||
@@ -0,0 +1,32 @@
|
||||
package stirling.software.saas.procurement.pricing;
|
||||
|
||||
/**
|
||||
* The buyer-configurable inputs to an enterprise quote. Mirrors the quote builder's four steps
|
||||
* (volume, commitment & service, add-ons) and is the sole input to {@link
|
||||
* ProcurementPricingService}. Amounts are never carried here — the service derives them from these
|
||||
* choices and the rate card.
|
||||
*/
|
||||
public record QuoteConfig(
|
||||
long volume, // committed PDFs per year
|
||||
int users, // seats (drives the volume auto-estimate when the buyer hasn't overridden)
|
||||
int intensity, // policy posture: runs per PDF — Essentials 2, Governed 4, Regulated 7
|
||||
String deployment, // cloud | selfhost | airgap (priced flat; inherited from the trial)
|
||||
int termYears, // 1..5
|
||||
String serviceLevel, // standard | priority (both included) | dedicated (flat SE/CSM fee)
|
||||
boolean indemnification,
|
||||
boolean training,
|
||||
boolean qbr,
|
||||
boolean offlineLicense, // offline .lic availability (gates download; no longer priced here)
|
||||
String currency) { // USD only for now
|
||||
|
||||
/** Default posture when none is chosen — Governed (x4), per the pricing alignment decision. */
|
||||
public static final int DEFAULT_INTENSITY = 4;
|
||||
|
||||
public QuoteConfig {
|
||||
if (termYears < 1) termYears = 1;
|
||||
if (termYears > 5) termYears = 5;
|
||||
if (intensity < 1) intensity = DEFAULT_INTENSITY;
|
||||
if (serviceLevel == null || serviceLevel.isBlank()) serviceLevel = "standard";
|
||||
if (currency == null || currency.isBlank()) currency = "USD";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package stirling.software.saas.procurement.pricing;
|
||||
|
||||
/**
|
||||
* One line on the itemised quote. {@code amountMinor} is in the currency's minor unit (cents);
|
||||
* discounts are negative. {@code kind} drives how the portal groups it (recurring annual vs a
|
||||
* one-time fee vs a discount line).
|
||||
*/
|
||||
public record QuoteLineItem(String key, String label, Kind kind, long amountMinor) {
|
||||
|
||||
public enum Kind {
|
||||
RECURRING,
|
||||
ONE_TIME,
|
||||
DISCOUNT,
|
||||
INCLUDED
|
||||
}
|
||||
}
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
package stirling.software.saas.procurement.repository;
|
||||
|
||||
import java.util.Optional;
|
||||
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
|
||||
import stirling.software.saas.procurement.model.ProcurementDeal;
|
||||
|
||||
public interface ProcurementDealRepository extends JpaRepository<ProcurementDeal, Long> {
|
||||
|
||||
Optional<ProcurementDeal> findByTeamId(Long teamId);
|
||||
|
||||
boolean existsByTeamId(Long teamId);
|
||||
|
||||
/** Reset: drop the team's deal (quotes + activity cascade via FK). */
|
||||
void deleteByTeamId(Long teamId);
|
||||
}
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
package stirling.software.saas.procurement.repository;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
|
||||
import stirling.software.saas.procurement.model.ProcurementQuote;
|
||||
|
||||
public interface ProcurementQuoteRepository extends JpaRepository<ProcurementQuote, Long> {
|
||||
|
||||
List<ProcurementQuote> findByDealIdOrderByCreatedAtDesc(Long dealId);
|
||||
}
|
||||
+338
@@ -0,0 +1,338 @@
|
||||
package stirling.software.saas.procurement.service;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
|
||||
import org.springframework.context.annotation.Profile;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.common.model.enumeration.TeamRole;
|
||||
import stirling.software.saas.model.TeamMembership;
|
||||
import stirling.software.saas.procurement.config.ProcurementConfigurationProperties;
|
||||
import stirling.software.saas.procurement.license.EnterpriseLicenseService;
|
||||
import stirling.software.saas.procurement.license.LicenseEntitlements;
|
||||
import stirling.software.saas.procurement.model.ProcurementDeal;
|
||||
import stirling.software.saas.procurement.model.ProcurementQuote;
|
||||
import stirling.software.saas.procurement.pricing.ProcurementPricingService;
|
||||
import stirling.software.saas.procurement.pricing.QuoteBreakdown;
|
||||
import stirling.software.saas.procurement.pricing.QuoteConfig;
|
||||
import stirling.software.saas.procurement.repository.ProcurementDealRepository;
|
||||
import stirling.software.saas.procurement.repository.ProcurementQuoteRepository;
|
||||
import stirling.software.saas.repository.TeamMembershipRepository;
|
||||
|
||||
/**
|
||||
* Orchestrates a linked team's procurement journey: start a (mock-licensed) trial, build a
|
||||
* server-priced quote, and accept it. Stripe checkout itself lives in a Supabase edge function the
|
||||
* portal calls with the accepted quote; on payment the webhook seeds {@code billing_subscriptions}
|
||||
* and this service issues the annual licence. All amounts are minor units (cents).
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
@Profile("saas")
|
||||
public class ProcurementService {
|
||||
|
||||
// Local mapper for the line-items JSON snapshot; the saas context exposes no injectable
|
||||
// ObjectMapper bean, and this (de)serialisation doesn't need Spring's configured one.
|
||||
private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();
|
||||
|
||||
private final ProcurementDealRepository dealRepo;
|
||||
private final ProcurementQuoteRepository quoteRepo;
|
||||
private final ProcurementPricingService pricing;
|
||||
private final EnterpriseLicenseService licenses;
|
||||
private final ProcurementConfigurationProperties config;
|
||||
private final TeamMembershipRepository memberRepo;
|
||||
|
||||
public ProcurementService(
|
||||
ProcurementDealRepository dealRepo,
|
||||
ProcurementQuoteRepository quoteRepo,
|
||||
ProcurementPricingService pricing,
|
||||
EnterpriseLicenseService licenses,
|
||||
ProcurementConfigurationProperties config,
|
||||
TeamMembershipRepository memberRepo) {
|
||||
this.dealRepo = dealRepo;
|
||||
this.quoteRepo = quoteRepo;
|
||||
this.pricing = pricing;
|
||||
this.licenses = licenses;
|
||||
this.config = config;
|
||||
this.memberRepo = memberRepo;
|
||||
}
|
||||
|
||||
/**
|
||||
* The team leader's email — the natural owner of the team's Keygen licence. Falls back to the
|
||||
* username when no email is set; null when the team has no leader.
|
||||
*/
|
||||
private String leaderEmail(Long teamId) {
|
||||
return memberRepo.findByTeamIdAndRole(teamId, TeamRole.LEADER).stream()
|
||||
.findFirst()
|
||||
.map(TeamMembership::getUser)
|
||||
.map(
|
||||
u ->
|
||||
u.getEmail() != null && !u.getEmail().isBlank()
|
||||
? u.getEmail()
|
||||
: u.getUsername())
|
||||
.orElse(null);
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public Optional<ProcurementDeal> getDeal(Long teamId) {
|
||||
return dealRepo.findByTeamId(teamId);
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public List<ProcurementQuote> quotesForDeal(Long dealId) {
|
||||
return quoteRepo.findByDealIdOrderByCreatedAtDesc(dealId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Start (or restart) the free trial for a team: issue a mock trial licence and stamp the trial
|
||||
* window on the deal. No Stripe: a no-card trial has no subscription; the entitlement is the
|
||||
* Keygen licence, and the deal row is the journey state.
|
||||
*/
|
||||
@Transactional
|
||||
public ProcurementDeal startTrial(Long teamId) {
|
||||
ProcurementDeal deal =
|
||||
dealRepo.findByTeamId(teamId).orElseGet(() -> new ProcurementDeal(teamId));
|
||||
LocalDateTime now = LocalDateTime.now();
|
||||
LocalDateTime ends = now.plusDays(config.getTrialDurationDays());
|
||||
deal.setStage(ProcurementDeal.STAGE_TRIAL);
|
||||
deal.setTrialStartedAt(now);
|
||||
deal.setTrialEndsAt(ends);
|
||||
deal.setTrialExtensionsUsed(0);
|
||||
deal.setLicenseRef(licenses.issueTrialLicense(teamId, leaderEmail(teamId), ends));
|
||||
deal = dealRepo.save(deal);
|
||||
log.info(
|
||||
"[procurement] trial started team={} deal={} ends={}",
|
||||
teamId,
|
||||
deal.getDealId(),
|
||||
ends);
|
||||
return deal;
|
||||
}
|
||||
|
||||
/** Extend the current trial by the configured increment, up to the cap. */
|
||||
@Transactional
|
||||
public ProcurementDeal extendTrial(Long teamId) {
|
||||
ProcurementDeal deal =
|
||||
dealRepo.findByTeamId(teamId)
|
||||
.orElseThrow(() -> new IllegalStateException("No deal for team " + teamId));
|
||||
// Only extend while still in the trial. Past the trial (e.g. active), licenseRef points at
|
||||
// the committed annual licence — extending would rewind its expiry.
|
||||
if (!ProcurementDeal.STAGE_TRIAL.equals(deal.getStage())) {
|
||||
throw new IllegalStateException("Trial extension only allowed during the trial stage");
|
||||
}
|
||||
if (deal.getTrialExtensionsUsed() >= config.getMaxTrialExtensions()) {
|
||||
throw new IllegalStateException("Trial extension cap reached");
|
||||
}
|
||||
LocalDateTime base =
|
||||
deal.getTrialEndsAt() != null ? deal.getTrialEndsAt() : LocalDateTime.now();
|
||||
LocalDateTime newEnd = base.plusDays(config.getTrialExtensionDays());
|
||||
deal.setTrialEndsAt(newEnd);
|
||||
deal.setTrialExtensionsUsed(deal.getTrialExtensionsUsed() + 1);
|
||||
if (deal.getLicenseRef() != null) {
|
||||
licenses.extendLicense(deal.getLicenseRef(), newEnd);
|
||||
}
|
||||
return dealRepo.save(deal);
|
||||
}
|
||||
|
||||
/** Price a quote config server-side and persist it as a draft against the team's deal. */
|
||||
@Transactional
|
||||
public ProcurementQuote buildQuote(Long teamId, QuoteConfig cfg, String businessName) {
|
||||
ProcurementDeal deal =
|
||||
dealRepo.findByTeamId(teamId).orElseGet(() -> new ProcurementDeal(teamId));
|
||||
if (ProcurementDeal.STAGE_LIVE.equals(deal.getStage())) {
|
||||
throw new IllegalStateException("Cannot rebuild a quote on a live deal");
|
||||
}
|
||||
// (Re)building a quote returns the deal to the quote stage and drops any prior acceptance,
|
||||
// so a rebuild from security/payment can't leave a stale stage or accepted-quote pointer.
|
||||
deal.setStage(ProcurementDeal.STAGE_QUOTE);
|
||||
deal.setAcceptedQuoteId(null);
|
||||
deal = dealRepo.save(deal);
|
||||
|
||||
QuoteBreakdown breakdown = pricing.price(cfg);
|
||||
|
||||
ProcurementQuote quote = new ProcurementQuote();
|
||||
quote.setDealId(deal.getDealId());
|
||||
quote.setQuoteNumber(nextQuoteNumber(deal.getDealId()));
|
||||
// Priced but not yet issued: the edge fn creates the Stripe Quote and flips this to SENT.
|
||||
quote.setStatus(ProcurementQuote.STATUS_DRAFT);
|
||||
quote.setCurrency(cfg.currency());
|
||||
quote.setVolume(cfg.volume());
|
||||
quote.setSeats(cfg.users() > 0 ? cfg.users() : null);
|
||||
quote.setIntensity(cfg.intensity());
|
||||
quote.setDeployment(cfg.deployment());
|
||||
quote.setTermYears(cfg.termYears());
|
||||
quote.setServiceLevel(cfg.serviceLevel());
|
||||
quote.setIndemnification(cfg.indemnification());
|
||||
quote.setTraining(cfg.training());
|
||||
quote.setQbr(cfg.qbr());
|
||||
quote.setOfflineLicense(cfg.offlineLicense());
|
||||
quote.setBusinessName(businessName);
|
||||
quote.setAnnualNetMinor(breakdown.annualNetMinor());
|
||||
quote.setTcvMinor(breakdown.tcvMinor());
|
||||
quote.setLineItemsJson(writeLineItems(breakdown));
|
||||
quote.setValidUntil(LocalDate.now().plusDays(30));
|
||||
quote = quoteRepo.save(quote);
|
||||
log.info(
|
||||
"[procurement] quote built team={} quote={} annualNet={} tcv={}",
|
||||
teamId,
|
||||
quote.getQuoteNumber(),
|
||||
quote.getAnnualNetMinor(),
|
||||
quote.getTcvMinor());
|
||||
return quote;
|
||||
}
|
||||
|
||||
/**
|
||||
* Advance the deal to the agreement (security) stage: the buyer has an issued quote and is
|
||||
* reviewing the enterprise agreement before it's accepted into a subscription. Requires an
|
||||
* issued quote on the deal.
|
||||
*/
|
||||
@Transactional
|
||||
public ProcurementDeal startAgreement(Long teamId) {
|
||||
ProcurementDeal deal =
|
||||
dealRepo.findByTeamId(teamId)
|
||||
.orElseThrow(() -> new IllegalStateException("No deal for team " + teamId));
|
||||
boolean hasIssuedQuote =
|
||||
quoteRepo.findByDealIdOrderByCreatedAtDesc(deal.getDealId()).stream()
|
||||
.anyMatch(q -> ProcurementQuote.STATUS_SENT.equals(q.getStatus()));
|
||||
if (!hasIssuedQuote) {
|
||||
throw new IllegalStateException("No issued quote for team " + teamId);
|
||||
}
|
||||
deal.setStage(ProcurementDeal.STAGE_AGREEMENT);
|
||||
deal = dealRepo.save(deal);
|
||||
log.info("[procurement] agreement stage team={} deal={}", teamId, deal.getDealId());
|
||||
return deal;
|
||||
}
|
||||
|
||||
/**
|
||||
* Provision on accept: upgrade the team's licence to the committed annual term (valid
|
||||
* immediately), so the buyer can get going the moment they accept — before the invoice is paid.
|
||||
* Driven by the accept edge function once the subscription + invoice are created. Idempotent
|
||||
* (upgrades the existing licence in place); deliberately does NOT change the stage — the deal
|
||||
* stays in the payment step so the outstanding invoice remains visible until it settles.
|
||||
*/
|
||||
@Transactional
|
||||
public ProcurementDeal provisionLicense(Long teamId) {
|
||||
ProcurementDeal deal =
|
||||
dealRepo.findByTeamId(teamId)
|
||||
.orElseThrow(() -> new IllegalStateException("No deal for team " + teamId));
|
||||
deal.setLicenseRef(issueOrUpgradeAnnual(deal));
|
||||
deal = dealRepo.save(deal);
|
||||
log.info("[procurement] licence provisioned team={} deal={}", teamId, deal.getDealId());
|
||||
return deal;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark the deal fully live (advance to the active stage) once payment settles. In production
|
||||
* this is the {@code invoice.paid} webhook; here it's the demo/manual stand-in. Re-affirms the
|
||||
* annual licence in case provisioning didn't run at accept.
|
||||
*/
|
||||
@Transactional
|
||||
public ProcurementDeal markLive(Long teamId) {
|
||||
ProcurementDeal deal =
|
||||
dealRepo.findByTeamId(teamId)
|
||||
.orElseThrow(() -> new IllegalStateException("No deal for team " + teamId));
|
||||
deal.setLicenseRef(issueOrUpgradeAnnual(deal));
|
||||
deal.setStage(ProcurementDeal.STAGE_LIVE);
|
||||
deal = dealRepo.save(deal);
|
||||
log.info("[procurement] deal live team={} deal={}", teamId, deal.getDealId());
|
||||
return deal;
|
||||
}
|
||||
|
||||
/**
|
||||
* Issue or upgrade the committed annual licence from the deal's accepted (else latest) quote,
|
||||
* stamping the full entitlement snapshot onto it and upgrading the trial licence in place when
|
||||
* one exists.
|
||||
*/
|
||||
private String issueOrUpgradeAnnual(ProcurementDeal deal) {
|
||||
ProcurementQuote q =
|
||||
deal.getAcceptedQuoteId() != null
|
||||
? quoteRepo.findById(deal.getAcceptedQuoteId()).orElse(null)
|
||||
: quoteRepo.findByDealIdOrderByCreatedAtDesc(deal.getDealId()).stream()
|
||||
.findFirst()
|
||||
.orElse(null);
|
||||
int term = q != null ? Math.max(1, q.getTermYears()) : 1;
|
||||
String deployment =
|
||||
q != null && q.getDeployment() != null && !q.getDeployment().isBlank()
|
||||
? q.getDeployment()
|
||||
: "cloud";
|
||||
int seats = q != null && q.getSeats() != null ? q.getSeats() : 0; // 0 = unlimited
|
||||
LicenseEntitlements entitlements =
|
||||
new LicenseEntitlements(
|
||||
q != null ? q.getVolume() : 0,
|
||||
seats,
|
||||
deployment,
|
||||
term,
|
||||
q != null ? q.getServiceLevel() : null,
|
||||
q != null && q.isIndemnification(),
|
||||
q != null && q.isTraining(),
|
||||
q != null && q.isQbr(),
|
||||
q != null && q.isOfflineLicense(),
|
||||
deal.getDealId(),
|
||||
deal.getSubscriptionId());
|
||||
return licenses.issueAnnualLicense(
|
||||
deal.getTeamId(),
|
||||
leaderEmail(deal.getTeamId()),
|
||||
LocalDateTime.now().plusYears(term),
|
||||
deal.getLicenseRef(),
|
||||
entitlements);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check out the offline/air-gapped licence file for a team, when the offline add-on was
|
||||
* purchased. Requires an issued licence on the deal and the accepted quote to carry the offline
|
||||
* add-on; returns empty otherwise (so the controller can 404 rather than leak that a licence
|
||||
* exists). The certificate is generated on demand by Keygen and never stored.
|
||||
*/
|
||||
@Transactional(readOnly = true)
|
||||
public Optional<String> offlineLicenseFile(Long teamId) {
|
||||
ProcurementDeal deal = dealRepo.findByTeamId(teamId).orElse(null);
|
||||
if (deal == null || deal.getLicenseRef() == null) return Optional.empty();
|
||||
if (!hasOfflineAddOn(deal)) return Optional.empty();
|
||||
return Optional.of(licenses.checkOutLicenseFile(deal.getLicenseRef()));
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the deal's <b>accepted</b> quote carries the paid offline-licence add-on. Gated on
|
||||
* the accepted quote (not the latest) so merely toggling the add-on on an unaccepted draft
|
||||
* can't unlock the offline file — it's only available once the add-on has actually been bought.
|
||||
*/
|
||||
private boolean hasOfflineAddOn(ProcurementDeal deal) {
|
||||
if (deal.getAcceptedQuoteId() == null) return false;
|
||||
ProcurementQuote quote = quoteRepo.findById(deal.getAcceptedQuoteId()).orElse(null);
|
||||
return quote != null && quote.isOfflineLicense();
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset a team's procurement: delete the deal (quotes + activity cascade). For
|
||||
* re-demos/testing.
|
||||
*/
|
||||
@Transactional
|
||||
public void resetDeal(Long teamId) {
|
||||
dealRepo.deleteByTeamId(teamId);
|
||||
log.info("[procurement] deal reset team={}", teamId);
|
||||
}
|
||||
|
||||
private String nextQuoteNumber(Long dealId) {
|
||||
int seq = quoteRepo.findByDealIdOrderByCreatedAtDesc(dealId).size() + 1;
|
||||
String token = UUID.randomUUID().toString().substring(0, 4).toUpperCase(Locale.ROOT);
|
||||
return String.format(Locale.ROOT, "QT-%s-%04d", token, seq);
|
||||
}
|
||||
|
||||
private String writeLineItems(QuoteBreakdown breakdown) {
|
||||
try {
|
||||
return OBJECT_MAPPER.writeValueAsString(breakdown.lineItems());
|
||||
} catch (JsonProcessingException e) {
|
||||
log.warn("[procurement] failed to serialise line items", e);
|
||||
return "[]";
|
||||
}
|
||||
}
|
||||
}
|
||||
+45
@@ -0,0 +1,45 @@
|
||||
package stirling.software.saas.security;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
|
||||
import org.springframework.context.annotation.Primary;
|
||||
import org.springframework.context.annotation.Profile;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
|
||||
import stirling.software.proprietary.audit.PortalAuditScope;
|
||||
import stirling.software.proprietary.audit.PortalAuditScopeResolver;
|
||||
import stirling.software.saas.repository.TeamMembershipRepository;
|
||||
|
||||
/** SaaS audit visibility: admins see the server; team LEADERs see their team (by member email). */
|
||||
@Component
|
||||
@Primary
|
||||
@Profile("saas")
|
||||
@RequiredArgsConstructor
|
||||
public class SaasPortalAuditScopeResolver implements PortalAuditScopeResolver {
|
||||
|
||||
private final TeamSecurityExpressions teamSecurity;
|
||||
private final TeamMembershipRepository membershipRepository;
|
||||
|
||||
@Override
|
||||
public PortalAuditScope resolve() {
|
||||
if (PortalAuditScopeResolver.hasAdminAuthority()) {
|
||||
return PortalAuditScope.server();
|
||||
}
|
||||
if (!teamSecurity.isCurrentUserTeamLeader()) {
|
||||
return PortalAuditScope.denied();
|
||||
}
|
||||
Long teamId = teamSecurity.currentUserTeamId();
|
||||
if (teamId == null) {
|
||||
return PortalAuditScope.denied();
|
||||
}
|
||||
List<String> memberEmails =
|
||||
membershipRepository.findByTeamId(teamId).stream()
|
||||
.map(m -> m.getUser() == null ? null : m.getUser().getEmail())
|
||||
.filter(Objects::nonNull)
|
||||
.toList();
|
||||
return PortalAuditScope.team("team:" + teamId, memberEmails);
|
||||
}
|
||||
}
|
||||
@@ -35,6 +35,15 @@ app.supabase.clock-skew-seconds=${app.jwt.clock-skew-seconds:120}
|
||||
app.supabase.edge-function-url=https://${app.supabase.project-ref}.supabase.co/functions/v1
|
||||
app.supabase.edge-function-secret=${SUPABASE_EDGE_FUNCTION_SECRET:}
|
||||
|
||||
# ---------- Enterprise procurement licences (Keygen) ----------
|
||||
# Off by default → MockEnterpriseLicenseService (no real Keygen calls). Set enabled=true + the
|
||||
# three creds to switch to the real KeygenEnterpriseLicenseService. Same account/policy as the
|
||||
# self-hosted products; env names match the edge functions' KEYGEN_* for consistency.
|
||||
stirling.keygen.enabled=${STIRLING_KEYGEN_ENABLED:false}
|
||||
stirling.keygen.account-id=${KEYGEN_ACCOUNT_ID:}
|
||||
stirling.keygen.api-token=${KEYGEN_API_TOKEN:}
|
||||
stirling.keygen.policy-id=${KEYGEN_POLICY_ID:}
|
||||
|
||||
# ---------- PAYG meter reporting ----------
|
||||
# Posts billable usage to the Supabase `meter-payg-units` edge function in the JobChargeService
|
||||
# close() afterCommit hook. Defaults to empty so unit tests / local dev are no-ops; set
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
-- Enterprise procurement: the tables that track a linked team's journey from trial to live.
|
||||
--
|
||||
-- One deal per team (the commercial journey: trial -> quote -> agreement -> payment -> live), the
|
||||
-- quotes built against it (the itemised, priced offers), and an append-only activity log for the
|
||||
-- money/licence-touching actions. The resulting subscription is mirrored in billing_subscriptions
|
||||
-- (seeded on trial start / payment); the entitlement that unlocks the product is a Keygen licence
|
||||
-- referenced by procurement_deal.license_ref. Prices are computed server-side (ProcurementPricingService).
|
||||
--
|
||||
-- Additive and idempotent (IF NOT EXISTS) — safe on the shared dev branch.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS stirling_pdf.procurement_deal (
|
||||
deal_id BIGSERIAL PRIMARY KEY,
|
||||
team_id BIGINT NOT NULL UNIQUE REFERENCES stirling_pdf.teams(team_id) ON DELETE CASCADE,
|
||||
-- one active deal per team; the journey lives on this row.
|
||||
stage VARCHAR(32) NOT NULL DEFAULT 'trial',
|
||||
-- trial | quote | security (agreement) | procurement (payment) | active (live)
|
||||
trial_started_at TIMESTAMP,
|
||||
trial_ends_at TIMESTAMP,
|
||||
trial_extensions_used INT NOT NULL DEFAULT 0,
|
||||
license_ref VARCHAR(128),
|
||||
-- Keygen licence id issued for this deal (trial or annual). Mocked until Keygen mgmt lands.
|
||||
subscription_id VARCHAR(255),
|
||||
-- Stripe subscription id, mirrored into billing_subscriptions once commercial.
|
||||
accepted_quote_id BIGINT,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
version BIGINT NOT NULL DEFAULT 0
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS stirling_pdf.procurement_quote (
|
||||
quote_id BIGSERIAL PRIMARY KEY,
|
||||
deal_id BIGINT NOT NULL REFERENCES stirling_pdf.procurement_deal(deal_id) ON DELETE CASCADE,
|
||||
quote_number VARCHAR(64) NOT NULL,
|
||||
status VARCHAR(24) NOT NULL DEFAULT 'draft',
|
||||
-- draft | sent | accepted | expired
|
||||
currency VARCHAR(8) NOT NULL DEFAULT 'USD',
|
||||
volume BIGINT NOT NULL,
|
||||
seats INT,
|
||||
deployment VARCHAR(24),
|
||||
term_years INT NOT NULL,
|
||||
service_level VARCHAR(24) NOT NULL,
|
||||
indemnification BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
training BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
qbr BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
annual_net_minor BIGINT NOT NULL,
|
||||
-- recurring annual fee after the multi-year discount, in minor units (cents).
|
||||
tcv_minor BIGINT NOT NULL,
|
||||
-- total contract value across the term incl. one-time fees, minor units.
|
||||
line_items TEXT,
|
||||
-- JSON snapshot of the itemised lines the order form renders.
|
||||
stripe_price_id VARCHAR(128),
|
||||
checkout_session_id VARCHAR(255),
|
||||
checkout_url TEXT,
|
||||
valid_until DATE,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
version BIGINT NOT NULL DEFAULT 0
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS stirling_pdf.procurement_activity (
|
||||
activity_id BIGSERIAL PRIMARY KEY,
|
||||
deal_id BIGINT NOT NULL REFERENCES stirling_pdf.procurement_deal(deal_id) ON DELETE CASCADE,
|
||||
actor_user_id BIGINT,
|
||||
-- the internal/portal user who took the action; informational (no FK).
|
||||
action VARCHAR(48) NOT NULL,
|
||||
-- trial_started | trial_extended | quote_built | quote_accepted | checkout_created | went_live ...
|
||||
detail TEXT,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_procurement_quote_deal ON stirling_pdf.procurement_quote (deal_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_procurement_activity_deal ON stirling_pdf.procurement_activity (deal_id);
|
||||
@@ -0,0 +1,8 @@
|
||||
-- Stripe Quote support: a procurement quote is issued as a real Stripe Quote (finalized → PDF +
|
||||
-- shareable), and on acceptance Stripe creates the committed subscription + first invoice. The
|
||||
-- Stripe operations live in Supabase edge functions; these columns hold the references they write
|
||||
-- back. Twin of Supabase migration 20260703000000_procurement_stripe_quote.sql.
|
||||
|
||||
ALTER TABLE stirling_pdf.procurement_quote
|
||||
ADD COLUMN IF NOT EXISTS stripe_quote_id VARCHAR(128),
|
||||
ADD COLUMN IF NOT EXISTS stripe_invoice_url TEXT;
|
||||
@@ -0,0 +1,5 @@
|
||||
-- Persist the buyer's company name on the quote so re-editing remembers it and it can be shown on
|
||||
-- the quote/agreement. Twin of Supabase migration 20260705000000_procurement_business_name.sql.
|
||||
|
||||
ALTER TABLE stirling_pdf.procurement_quote
|
||||
ADD COLUMN IF NOT EXISTS business_name VARCHAR(255);
|
||||
@@ -0,0 +1,6 @@
|
||||
-- Offline / air-gapped licence add-on flag on the quote (a paid add-on; priced like QBR). Written
|
||||
-- and read by the Java backend via JPA. Twin of Supabase migration
|
||||
-- 20260710000000_procurement_offline_license.sql.
|
||||
|
||||
ALTER TABLE stirling_pdf.procurement_quote
|
||||
ADD COLUMN IF NOT EXISTS offline_license BOOLEAN NOT NULL DEFAULT false;
|
||||
@@ -0,0 +1,6 @@
|
||||
-- Policy posture (runs per PDF) on the quote: the D71 meter is denominated in runs, so a quote
|
||||
-- must remember the posture it was priced at (Essentials 2, Governed 4, Regulated 7). Defaults to
|
||||
-- Governed (4). Written and read by the Java backend via JPA. A Supabase twin migration mirrors it.
|
||||
|
||||
ALTER TABLE stirling_pdf.procurement_quote
|
||||
ADD COLUMN IF NOT EXISTS intensity INTEGER NOT NULL DEFAULT 4;
|
||||
+153
@@ -0,0 +1,153 @@
|
||||
package stirling.software.saas.procurement.pricing;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import stirling.software.saas.procurement.pricing.QuoteLineItem.Kind;
|
||||
|
||||
/**
|
||||
* Locks the D71 pricing engine to the numbers marketing publishes. The two anchor fixtures are the
|
||||
* ones the demo memo foots against: acme (90M PDFs · Governed · self-hosted · dedicated · 3-yr =
|
||||
* $1,752,000/yr, $5,256,000 TCV) and Northwind (6M · Governed · cloud · standard · 3-yr =
|
||||
* $165,278/yr). If either moves, the engine has drifted from marketing.
|
||||
*/
|
||||
class ProcurementPricingServiceTest {
|
||||
|
||||
private final ProcurementPricingService pricing = new ProcurementPricingService();
|
||||
|
||||
private static QuoteConfig cfg(
|
||||
long volume, int intensity, String deployment, int term, String sla) {
|
||||
return new QuoteConfig(
|
||||
volume, 0, intensity, deployment, term, sla, false, false, false, false, "USD");
|
||||
}
|
||||
|
||||
@Test
|
||||
void acmeFixtureFootsExactly() {
|
||||
// 90M PDFs × Governed(×4) = 360M runs → curve floors at $0.005/run → $0.0200/PDF effective.
|
||||
// meter $1.8M − 5% (3-yr) = $1,710,000 + self-hosted $12K + dedicated SE/CSM $30K.
|
||||
QuoteBreakdown q = pricing.price(cfg(90_000_000, 4, "selfhost", 3, "dedicated"));
|
||||
|
||||
assertThat(q.annualNetMinor()).isEqualTo(175_200_000L); // $1,752,000
|
||||
assertThat(q.tcvMinor()).isEqualTo(525_600_000L); // $5,256,000
|
||||
assertThat(lineAmount(q, "support")).isEqualTo(3_000_000L); // dedicated SE/CSM $30K
|
||||
assertThat(lineAmount(q, "deployment")).isEqualTo(1_200_000L); // self-hosted $12K
|
||||
}
|
||||
|
||||
@Test
|
||||
void northwindFixtureFootsExactly() {
|
||||
// 6M × Governed(×4) = 24M runs → $0.0290/PDF effective → $165,278/yr at 3-yr.
|
||||
QuoteBreakdown q = pricing.price(cfg(6_000_000, 4, "cloud", 3, "standard"));
|
||||
|
||||
assertThat(q.annualNetMinor()).isEqualTo(16_527_800L); // $165,278
|
||||
assertThat(q.tcvMinor()).isEqualTo(49_583_400L); // × 3 years
|
||||
// Cloud + standard: no deployment or support line.
|
||||
assertThat(q.lineItems()).noneMatch(l -> l.key().equals("deployment"));
|
||||
assertThat(q.lineItems()).noneMatch(l -> l.key().equals("support"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void rateFloorsAtHalfACent() {
|
||||
// 100M × Regulated(×7) = 700M runs — deep past the knee, so the per-run rate is pinned to
|
||||
// the $0.005 floor: base meter = 700M × $0.005 = $3,500,000 (1-yr, no term discount).
|
||||
QuoteBreakdown q = pricing.price(cfg(100_000_000, 7, "cloud", 1, "standard"));
|
||||
assertThat(lineAmount(q, "usage")).isEqualTo(350_000_000L); // $3,500,000
|
||||
assertThat(q.annualNetMinor()).isEqualTo(350_000_000L);
|
||||
}
|
||||
|
||||
@Test
|
||||
void postureDrivesThePrice() {
|
||||
// Same PDFs, three postures — the meter scales with runs, so Regulated > Governed >
|
||||
// Essentials. (This is what the retired flat-per-PDF model could not express.)
|
||||
long essentials = pricing.price(cfg(6_000_000, 2, "cloud", 3, "standard")).annualNetMinor();
|
||||
long governed = pricing.price(cfg(6_000_000, 4, "cloud", 3, "standard")).annualNetMinor();
|
||||
long regulated = pricing.price(cfg(6_000_000, 7, "cloud", 3, "standard")).annualNetMinor();
|
||||
|
||||
assertThat(essentials).isLessThan(governed);
|
||||
assertThat(governed).isLessThan(regulated);
|
||||
assertThat(governed).isEqualTo(16_527_800L); // Governed is the Northwind anchor
|
||||
}
|
||||
|
||||
@Test
|
||||
void deploymentIsAFlatFeeNotAMultiplier() {
|
||||
long cloud = pricing.price(cfg(6_000_000, 4, "cloud", 3, "standard")).annualNetMinor();
|
||||
long selfhost =
|
||||
pricing.price(cfg(6_000_000, 4, "selfhost", 3, "standard")).annualNetMinor();
|
||||
long airgap = pricing.price(cfg(6_000_000, 4, "airgap", 3, "standard")).annualNetMinor();
|
||||
|
||||
assertThat(selfhost - cloud).isEqualTo(1_200_000L); // +$12,000 flat
|
||||
assertThat(airgap - cloud).isEqualTo(3_600_000L); // +$36,000 flat
|
||||
}
|
||||
|
||||
@Test
|
||||
void standardAndPriorityIncludedDedicatedIsFlat() {
|
||||
long standard = pricing.price(cfg(6_000_000, 4, "cloud", 3, "standard")).annualNetMinor();
|
||||
long priority = pricing.price(cfg(6_000_000, 4, "cloud", 3, "priority")).annualNetMinor();
|
||||
long dedicated = pricing.price(cfg(6_000_000, 4, "cloud", 3, "dedicated")).annualNetMinor();
|
||||
|
||||
assertThat(priority).isEqualTo(standard); // both included, no uplift
|
||||
assertThat(dedicated - standard).isEqualTo(3_000_000L); // dedicated SE/CSM +$30,000 flat
|
||||
}
|
||||
|
||||
@Test
|
||||
void termDiscountsTheMeterOnly() {
|
||||
long oneYear = pricing.price(cfg(6_000_000, 4, "cloud", 1, "standard")).annualNetMinor();
|
||||
long twoYear = pricing.price(cfg(6_000_000, 4, "cloud", 2, "standard")).annualNetMinor();
|
||||
// 2-yr is 3% off the meter (discounted on the raw meter, then rounded to whole dollars).
|
||||
assertThat(oneYear).isEqualTo(17_397_700L); // no discount
|
||||
assertThat(twoYear).isEqualTo(16_875_700L); // −3% on the meter
|
||||
assertThat(twoYear).isLessThan(oneYear);
|
||||
}
|
||||
|
||||
@Test
|
||||
void indemnificationIsFivePercentOfTheMeter() {
|
||||
long base = pricing.price(cfg(6_000_000, 4, "cloud", 3, "standard")).annualNetMinor();
|
||||
QuoteConfig c =
|
||||
new QuoteConfig(
|
||||
6_000_000, 0, 4, "cloud", 3, "standard", true, false, false, false, "USD");
|
||||
QuoteBreakdown q = pricing.price(c);
|
||||
assertThat(lineAmount(q, "indemnification")).isEqualTo(Math.round(base * 0.05));
|
||||
}
|
||||
|
||||
@Test
|
||||
void trainingIsOneTimeOutsideTheAnnual() {
|
||||
QuoteConfig withTraining =
|
||||
new QuoteConfig(
|
||||
6_000_000, 0, 4, "cloud", 3, "standard", false, true, false, false, "USD");
|
||||
QuoteBreakdown q = pricing.price(withTraining);
|
||||
long baseAnnual = pricing.price(cfg(6_000_000, 4, "cloud", 3, "standard")).annualNetMinor();
|
||||
|
||||
assertThat(q.annualNetMinor()).isEqualTo(baseAnnual); // one-time never touches the annual
|
||||
assertThat(q.tcvMinor()).isEqualTo(baseAnnual * 3 + 750_000L); // + $7,500 once
|
||||
assertThat(q.lineItems())
|
||||
.anyMatch(l -> l.key().equals("training") && l.kind() == Kind.ONE_TIME);
|
||||
}
|
||||
|
||||
@Test
|
||||
void unsetPostureDefaultsToGoverned() {
|
||||
long defaulted = pricing.price(cfg(6_000_000, 0, "cloud", 3, "standard")).annualNetMinor();
|
||||
long governed = pricing.price(cfg(6_000_000, 4, "cloud", 3, "standard")).annualNetMinor();
|
||||
assertThat(defaulted).isEqualTo(governed);
|
||||
}
|
||||
|
||||
@Test
|
||||
void ssoIsAlwaysAnIncludedZeroLine() {
|
||||
QuoteBreakdown q = pricing.price(cfg(6_000_000, 4, "cloud", 3, "standard"));
|
||||
assertThat(q.lineItems())
|
||||
.anyMatch(l -> l.key().equals("seats") && l.kind() == Kind.INCLUDED);
|
||||
}
|
||||
|
||||
@Test
|
||||
void volumeEstimateFromSeats() {
|
||||
assertThat(pricing.estimateAnnualVolume(100)).isEqualTo(201_250L);
|
||||
assertThat(pricing.estimateAnnualVolume(0)).isZero();
|
||||
}
|
||||
|
||||
private static long lineAmount(QuoteBreakdown q, String key) {
|
||||
return q.lineItems().stream()
|
||||
.filter(l -> l.key().equals(key))
|
||||
.mapToLong(QuoteLineItem::amountMinor)
|
||||
.findFirst()
|
||||
.orElseThrow();
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -91,7 +91,7 @@ springBoot {
|
||||
|
||||
allprojects {
|
||||
group = 'stirling.software'
|
||||
version = '2.14.0'
|
||||
version = '2.14.1'
|
||||
|
||||
configurations.configureEach {
|
||||
exclude group: "org.springframework.boot", module: "spring-boot-starter-tomcat"
|
||||
|
||||
@@ -42,6 +42,9 @@ COPY . .
|
||||
ARG PROTOTYPES_BUILD=false
|
||||
ARG STIRLING_FLAVOR=proprietary
|
||||
ENV STIRLING_FLAVOR=${STIRLING_FLAVOR}
|
||||
# Embed the admin portal app at /portal. Set true by the deploy workflow when the
|
||||
# portal or AI layers change; defaults false so normal builds skip the extra app.
|
||||
ARG BUILD_PORTAL=false
|
||||
|
||||
# Bundle only the JPDFium native for this image's target arch.
|
||||
ARG TARGETARCH
|
||||
@@ -49,6 +52,7 @@ RUN JPDFIUM_PLATFORM="$([ "$TARGETARCH" = arm64 ] && echo linux-arm64 || echo li
|
||||
STIRLING_FLAVOR=${STIRLING_FLAVOR} \
|
||||
gradle clean build \
|
||||
-PbuildWithFrontend=true \
|
||||
-PbuildWithPortal=${BUILD_PORTAL} \
|
||||
-PjpdfiumPlatforms="$JPDFIUM_PLATFORM" \
|
||||
-PprototypesMode=${PROTOTYPES_BUILD} \
|
||||
-x spotlessApply -x spotlessCheck -x test -x sonarqube \
|
||||
|
||||
@@ -40,12 +40,15 @@ RUN gradle dependencies --no-daemon || true
|
||||
|
||||
COPY . .
|
||||
|
||||
# Embed the admin portal app at /portal when the deploy workflow flags it.
|
||||
ARG BUILD_PORTAL=false
|
||||
# Bundle only the JPDFium native for this image's target arch.
|
||||
ARG TARGETARCH
|
||||
RUN JPDFIUM_PLATFORM="$([ "$TARGETARCH" = arm64 ] && echo linux-arm64 || echo linux-x64)" && \
|
||||
DISABLE_ADDITIONAL_FEATURES=false \
|
||||
gradle clean build \
|
||||
-PbuildWithFrontend=true \
|
||||
-PbuildWithPortal=${BUILD_PORTAL} \
|
||||
-PjpdfiumPlatforms="$JPDFIUM_PLATFORM" \
|
||||
-x spotlessApply -x spotlessCheck -x test -x sonarqube \
|
||||
--no-daemon
|
||||
|
||||
@@ -40,12 +40,15 @@ RUN ./gradlew dependencies --no-daemon || true
|
||||
COPY . .
|
||||
|
||||
# Build ultra-lite JAR with embedded frontend (minimal features).
|
||||
# Embed the admin portal app at /portal when the deploy workflow flags it.
|
||||
ARG BUILD_PORTAL=false
|
||||
# Bundle only the JPDFium native for this image's target arch.
|
||||
ARG TARGETARCH
|
||||
RUN JPDFIUM_PLATFORM="$([ "$TARGETARCH" = arm64 ] && echo linux-arm64 || echo linux-x64)" && \
|
||||
DISABLE_ADDITIONAL_FEATURES=true \
|
||||
./gradlew clean build \
|
||||
-PbuildWithFrontend=true \
|
||||
-PbuildWithPortal=${BUILD_PORTAL} \
|
||||
-PjpdfiumPlatforms="$JPDFIUM_PLATFORM" \
|
||||
-x spotlessApply -x spotlessCheck -x test -x sonarqube \
|
||||
--no-daemon
|
||||
|
||||
@@ -59,6 +59,36 @@ class ToolDiscovery:
|
||||
"/api/v1/convert/",
|
||||
)
|
||||
|
||||
# Endpoints under the allowed prefixes that are NOT edit-agent operations. A listed
|
||||
# path and everything nested under it is dropped. Several kinds live here:
|
||||
EXCLUDED_PATHS = (
|
||||
# 1. Cert-signing family: needs certificate/key files the agent can't supply, plus
|
||||
# interactive session and hardware-token management. The whole subtree is dropped.
|
||||
"/api/v1/security/cert-sign",
|
||||
# 2. Interactive PDF text-editor endpoints, not one-shot operations.
|
||||
"/api/v1/convert/pdf/text-editor",
|
||||
"/api/v1/convert/text-editor/pdf",
|
||||
# 3. Introspection / query endpoints that return metadata, a listing, or a
|
||||
# verification verdict rather than a transformed document, so they belong to
|
||||
# the question path, not the edit agent. (decompress is a dev-only stream op.)
|
||||
"/api/v1/security/get-info-on-pdf",
|
||||
"/api/v1/security/verify-pdf",
|
||||
"/api/v1/security/validate-signature",
|
||||
"/api/v1/misc/list-attachments",
|
||||
"/api/v1/misc/show-javascript",
|
||||
"/api/v1/misc/decompress-pdf",
|
||||
"/api/v1/general/extract-bookmarks",
|
||||
# 4. Require a secondary file (image, overlay PDF, attachments) on top of the input
|
||||
# PDF. The agent only ever supplies the input PDF(s), so these can never run.
|
||||
# (add-stamp / add-watermark stay: their text mode needs no extra file.)
|
||||
"/api/v1/misc/add-image",
|
||||
"/api/v1/misc/add-attachments",
|
||||
"/api/v1/general/overlay-pdfs",
|
||||
)
|
||||
|
||||
def _is_excluded(self, path: str) -> bool:
|
||||
return any(path == p or path.startswith(p + "/") for p in self.EXCLUDED_PATHS)
|
||||
|
||||
def __init__(self, spec: dict[str, Any]):
|
||||
resource = Resource.from_contents(spec, default_specification=DRAFT202012)
|
||||
self.resolver = Registry().with_resource("", resource).resolver()
|
||||
@@ -73,17 +103,15 @@ class ToolDiscovery:
|
||||
for path, path_item in sorted(self.spec.get("paths", {}).items()):
|
||||
if "{" in path or not any(path.startswith(p) for p in self.ALLOWED_PATH_PREFIXES):
|
||||
continue
|
||||
if self._is_excluded(path):
|
||||
continue
|
||||
body_schema = self._get_request_body_schema(path_item) or {}
|
||||
query_props = self._get_query_parameters(path_item)
|
||||
body_props = body_schema.get("properties") or {}
|
||||
# Body properties win on name collision — body is the canonical param source
|
||||
# for the existing tools; query params are additive.
|
||||
properties = {**query_props, **body_props}
|
||||
if not properties:
|
||||
continue
|
||||
clean_props = self._filter_properties(properties)
|
||||
if not clean_props:
|
||||
continue
|
||||
|
||||
enum_name = _deduplicate(_path_to_enum_name(path), used_enum)
|
||||
class_name = _deduplicate(_path_to_class_name(path), used_class)
|
||||
|
||||
@@ -15,7 +15,6 @@ from stirling.agents.pdf_review import PdfReviewAgent
|
||||
from stirling.agents.user_spec import UserSpecAgent
|
||||
from stirling.contracts import (
|
||||
AgentDraftWorkflowResponse,
|
||||
ConvertMarkdownResponse,
|
||||
ExtractedTextArtifact,
|
||||
OrchestratorRequest,
|
||||
OrchestratorResponse,
|
||||
@@ -48,7 +47,7 @@ class OrchestratorAgent:
|
||||
ToolOutput(
|
||||
self.delegate_pdf_edit,
|
||||
name="delegate_pdf_edit",
|
||||
description="Delegate requests for PDF modifications and return the PDF edit result.",
|
||||
description="Delegate requests to modify or convert PDFs and return the PDF edit result.",
|
||||
),
|
||||
ToolOutput(
|
||||
self.delegate_pdf_question,
|
||||
@@ -71,13 +70,6 @@ class OrchestratorAgent:
|
||||
" feedback')."
|
||||
),
|
||||
),
|
||||
ToolOutput(
|
||||
self.delegate_pdf_ingest,
|
||||
name="delegate_pdf_ingest",
|
||||
description=(
|
||||
"Delegate requests to convert a PDF to Markdown or extract its content as readable text."
|
||||
),
|
||||
),
|
||||
ToolOutput(
|
||||
self.delegate_pdf_create,
|
||||
name="delegate_pdf_create",
|
||||
@@ -98,7 +90,7 @@ class OrchestratorAgent:
|
||||
system_prompt=(
|
||||
"You are the top-level orchestrator. "
|
||||
"Choose exactly one output function that best handles the request. "
|
||||
"Use delegate_pdf_edit for any requested modification of one or more PDFs. "
|
||||
"Use delegate_pdf_edit for any request to modify or convert one or more PDFs. "
|
||||
"Use delegate_pdf_question for questions about the contents of the attached PDFs. "
|
||||
"Use delegate_user_spec for requests to create or define an agent spec. "
|
||||
"Use delegate_pdf_review when the user wants the PDF returned with review"
|
||||
@@ -106,8 +98,6 @@ class OrchestratorAgent:
|
||||
" 'leave feedback on the PDF'. "
|
||||
"Use delegate_pdf_create when the user wants to generate a new document from"
|
||||
" scratch with no input file — invoices, reports, letters, contracts, etc. "
|
||||
"Use delegate_pdf_ingest for any request to convert a PDF to Markdown "
|
||||
"or extract its content as readable text. "
|
||||
"Use unsupported_capability when the user asks about the assistant itself "
|
||||
"or when none of the other outputs fit; supply a helpful message."
|
||||
),
|
||||
@@ -177,13 +167,6 @@ class OrchestratorAgent:
|
||||
async def _run_agent_draft(self, request: OrchestratorRequest) -> AgentDraftWorkflowResponse:
|
||||
return await UserSpecAgent(self.runtime).orchestrate(request)
|
||||
|
||||
async def delegate_pdf_ingest(self, ctx: RunContext[OrchestratorDeps]) -> ConvertMarkdownResponse:
|
||||
request = ctx.deps.request
|
||||
return ConvertMarkdownResponse(
|
||||
reason="PDF to Markdown requested — Java converts deterministically.",
|
||||
files_to_ingest=request.files,
|
||||
)
|
||||
|
||||
async def delegate_pdf_review(self, ctx: RunContext[OrchestratorDeps]) -> PdfReviewOrchestrateResponse:
|
||||
return await self._run_pdf_review(ctx.deps.request)
|
||||
|
||||
|
||||
@@ -13,7 +13,6 @@ from .common import (
|
||||
AiFile,
|
||||
ArtifactKind,
|
||||
ConversationMessage,
|
||||
ConvertMarkdownResponse,
|
||||
ExtractedFileText,
|
||||
GenerateFileResponse,
|
||||
MathAuditorToolReportArtifact,
|
||||
@@ -163,7 +162,6 @@ __all__ = [
|
||||
"NeedContentFileRequest",
|
||||
"NeedContentResponse",
|
||||
"NeedIngestResponse",
|
||||
"ConvertMarkdownResponse",
|
||||
"NextExecutionAction",
|
||||
"OrchestratorRequest",
|
||||
"OrchestratorResponse",
|
||||
|
||||
@@ -62,7 +62,6 @@ class WorkflowOutcome(StrEnum):
|
||||
CANNOT_CONTINUE = "cannot_continue"
|
||||
UNSUPPORTED_CAPABILITY = "unsupported_capability"
|
||||
GENERATE_FILE = "generate_file"
|
||||
CONVERT_MARKDOWN = "convert_markdown"
|
||||
|
||||
|
||||
class ArtifactKind(StrEnum):
|
||||
@@ -184,19 +183,6 @@ class NeedIngestResponse(ApiModel):
|
||||
content_types: list[PdfContentType] = Field(default_factory=list)
|
||||
|
||||
|
||||
class ConvertMarkdownResponse(ApiModel):
|
||||
"""Terminal signal: convert the listed files to Markdown deterministically.
|
||||
|
||||
This is a deterministic, non-AI conversion. Java runs the PDF→Markdown converter
|
||||
(``PdfMarkdownConverter``) on each file and returns the resulting ``.md`` file(s) as a
|
||||
completed result. There is no resume turn — the conversion output is the final answer.
|
||||
"""
|
||||
|
||||
outcome: Literal[WorkflowOutcome.CONVERT_MARKDOWN] = WorkflowOutcome.CONVERT_MARKDOWN
|
||||
reason: str
|
||||
files_to_ingest: list[AiFile]
|
||||
|
||||
|
||||
class ToolOperationStep(ApiModel):
|
||||
kind: Literal[StepKind.TOOL] = StepKind.TOOL
|
||||
tool: AnyToolId
|
||||
|
||||
@@ -11,7 +11,6 @@ from .common import (
|
||||
AiFile,
|
||||
ArtifactKind,
|
||||
ConversationMessage,
|
||||
ConvertMarkdownResponse,
|
||||
ExtractedFileText,
|
||||
GenerateFileResponse,
|
||||
NeedContentResponse,
|
||||
@@ -61,7 +60,6 @@ type OrchestratorResponse = Annotated[
|
||||
| GenerateFileResponse
|
||||
| NeedContentResponse
|
||||
| NeedIngestResponse
|
||||
| ConvertMarkdownResponse
|
||||
| AgentDraftResponse
|
||||
| NextExecutionAction
|
||||
| UnsupportedCapabilityResponse,
|
||||
|
||||
@@ -11,13 +11,6 @@ from pydantic import Field, RootModel, SecretStr
|
||||
from stirling.models.base import ApiModel
|
||||
|
||||
|
||||
class AddAttachmentsParams(ApiModel):
|
||||
attachments: list[bytes] = Field(..., description="The image file to be overlaid onto the PDF.")
|
||||
convert_to_pdf_a3b: bool = Field(
|
||||
False, description="Convert the resulting PDF to PDF/A-3b format after adding attachments"
|
||||
)
|
||||
|
||||
|
||||
class AddCommentsParams(ApiModel):
|
||||
comments: str = Field(
|
||||
...,
|
||||
@@ -28,12 +21,6 @@ class AddCommentsParams(ApiModel):
|
||||
)
|
||||
|
||||
|
||||
class AddImageParams(ApiModel):
|
||||
every_page: bool = Field(False, description="Whether to overlay the image onto every page of the PDF.")
|
||||
x: float = Field(0, description="The x-coordinate at which to place the top-left corner of the image.")
|
||||
y: float = Field(0, description="The y-coordinate at which to place the top-left corner of the image.")
|
||||
|
||||
|
||||
class CustomMargin(StrEnum):
|
||||
"""
|
||||
Custom margin: small/medium/large/x-large
|
||||
@@ -107,7 +94,7 @@ class KeyLength(IntEnum):
|
||||
|
||||
|
||||
class AddPasswordParams(ApiModel):
|
||||
key_length: KeyLength = Field(..., description="The length of the encryption key")
|
||||
key_length: KeyLength = Field(KeyLength.integer_256, description="The length of the encryption key")
|
||||
owner_password: SecretStr | None = Field(
|
||||
None,
|
||||
description="The owner password to be added to the PDF file (Restricts what can be done with the document once it is opened)",
|
||||
@@ -312,50 +299,6 @@ class CbzToPdfParams(ApiModel):
|
||||
optimize_for_ebook: bool = Field(False, description="Optimize the output PDF for ebook reading using Ghostscript")
|
||||
|
||||
|
||||
class CertType(StrEnum):
|
||||
"""
|
||||
The type of the digital certificate. WINDOWS_STORE and PKCS11 are hardware-backed and only available in the desktop app.
|
||||
"""
|
||||
|
||||
pem = "PEM"
|
||||
pkcs12 = "PKCS12"
|
||||
pfx = "PFX"
|
||||
jks = "JKS"
|
||||
server = "SERVER"
|
||||
windows_store = "WINDOWS_STORE"
|
||||
pkcs11 = "PKCS11"
|
||||
|
||||
|
||||
class CertSignParams(ApiModel):
|
||||
alias: str | None = Field(
|
||||
None,
|
||||
description="The alias of the certificate to sign with. Required for WINDOWS_STORE and recommended for PKCS11 tokens holding multiple certificates.",
|
||||
)
|
||||
cert_type: CertType = Field(
|
||||
...,
|
||||
description="The type of the digital certificate. WINDOWS_STORE and PKCS11 are hardware-backed and only available in the desktop app.",
|
||||
)
|
||||
location: str = Field("SPDF", description="The location where the PDF is signed")
|
||||
name: str = Field("SPDF", description="The name of the signer")
|
||||
page_number: int = Field(
|
||||
1,
|
||||
description="The page number where the signature should be visible. This is required if showSignature is set to true",
|
||||
)
|
||||
password: SecretStr | None = Field(
|
||||
None, description="The password for the keystore / private key, or the token PIN for PKCS11"
|
||||
)
|
||||
pkcs11_library_path: str | None = Field(
|
||||
None,
|
||||
description="Absolute path to the PKCS#11 driver library (required for PKCS11 type). Must be an allowed driver - a detected one or configured via STIRLING_PKCS11_LIBRARIES.",
|
||||
)
|
||||
pkcs11_slot: int | None = Field(
|
||||
None, description="Optional PKCS#11 slot index. When omitted the first slot with a token is used."
|
||||
)
|
||||
reason: str = Field("Signed by SPDF", description="The reason for signing the PDF")
|
||||
show_logo: bool = Field(True, description="Whether to visually show a signature logo along with the signature")
|
||||
show_signature: bool = Field(False, description="Whether to visually show the signature in the PDF file")
|
||||
|
||||
|
||||
class LineArtEdgeLevel(IntEnum):
|
||||
"""
|
||||
Edge detection strength to use for line art conversion (1-3). This maps to ImageMagick's -edge radius.
|
||||
@@ -525,6 +468,10 @@ class EmlToPdfParams(ApiModel):
|
||||
)
|
||||
|
||||
|
||||
class ExtractAttachmentsParams(ApiModel):
|
||||
pass
|
||||
|
||||
|
||||
class ExtractImageScansParams(ApiModel):
|
||||
angle_threshold: int = Field(5, description="The angle threshold for the image scan extraction")
|
||||
border_size: int = Field(1, description="The border size for the image scan extraction")
|
||||
@@ -547,6 +494,10 @@ class ExtractImagesParams(ApiModel):
|
||||
format: Format = Field(Format.png, description="The output image format e.g., 'png', 'jpeg', or 'gif'")
|
||||
|
||||
|
||||
class FileToPdfParams(ApiModel):
|
||||
pass
|
||||
|
||||
|
||||
class FlattenParams(ApiModel):
|
||||
flatten_only_forms: bool = Field(
|
||||
False, description="True to flatten only the forms, false to flatten full PDF (Convert page to image)"
|
||||
@@ -602,6 +553,10 @@ class ImgToPdfParams(ApiModel):
|
||||
)
|
||||
|
||||
|
||||
class MarkdownToPdfParams(ApiModel):
|
||||
pass
|
||||
|
||||
|
||||
class SortType(StrEnum):
|
||||
"""
|
||||
The type of sorting to be applied on the input files before merging.
|
||||
@@ -750,41 +705,6 @@ class OcrPdfParams(ApiModel):
|
||||
sidecar: bool | None = Field(None, description="Include OCR text in a sidecar text file if set to true")
|
||||
|
||||
|
||||
class OverlayMode(StrEnum):
|
||||
"""
|
||||
The mode of overlaying: 'SequentialOverlay' for sequential application, 'InterleavedOverlay' for round-robin application, 'FixedRepeatOverlay' for fixed repetition based on provided counts
|
||||
"""
|
||||
|
||||
sequential_overlay = "SequentialOverlay"
|
||||
interleaved_overlay = "InterleavedOverlay"
|
||||
fixed_repeat_overlay = "FixedRepeatOverlay"
|
||||
|
||||
|
||||
class OverlayPosition(Enum):
|
||||
"""
|
||||
Overlay position 0 is Foregound, 1 is Background
|
||||
"""
|
||||
|
||||
number_0 = 0
|
||||
number_1 = 1
|
||||
|
||||
|
||||
class OverlayPdfsParams(ApiModel):
|
||||
counts: list[int] | None = Field(
|
||||
None,
|
||||
description="An array of integers specifying the number of times each corresponding overlay file should be applied in the 'FixedRepeatOverlay' mode. This should match the length of the overlayFiles array.",
|
||||
)
|
||||
overlay_files: list[bytes] = Field(
|
||||
...,
|
||||
description="An array of PDF files to be used as overlays on the base PDF. The order in these files is applied based on the selected mode.",
|
||||
)
|
||||
overlay_mode: OverlayMode = Field(
|
||||
...,
|
||||
description="The mode of overlaying: 'SequentialOverlay' for sequential application, 'InterleavedOverlay' for round-robin application, 'FixedRepeatOverlay' for fixed repetition based on provided counts",
|
||||
)
|
||||
overlay_position: OverlayPosition = Field(..., description="Overlay position 0 is Foregound, 1 is Background")
|
||||
|
||||
|
||||
class PdfToCbrParams(ApiModel):
|
||||
dpi: int = Field(..., description="The DPI (Dots Per Inch) for rendering PDF pages as images", examples=[150])
|
||||
|
||||
@@ -841,6 +761,12 @@ class PdfToEpubParams(ApiModel):
|
||||
)
|
||||
|
||||
|
||||
class PdfToHtmlParams(ApiModel):
|
||||
"""
|
||||
Either upload a file or provide a server-side file ID
|
||||
"""
|
||||
|
||||
|
||||
class ImageFormat(StrEnum):
|
||||
"""
|
||||
The output image format
|
||||
@@ -877,6 +803,12 @@ class PdfToImgParams(ApiModel):
|
||||
)
|
||||
|
||||
|
||||
class PdfToMarkdownParams(ApiModel):
|
||||
"""
|
||||
Either upload a file or provide a server-side file ID
|
||||
"""
|
||||
|
||||
|
||||
class OutputFormat1(StrEnum):
|
||||
"""
|
||||
The output format type (PDF/A or PDF/X)
|
||||
@@ -912,13 +844,11 @@ class PdfToPresentationParams(ApiModel):
|
||||
output_format: OutputFormat2 = Field(..., description="The output Presentation format")
|
||||
|
||||
|
||||
class PdfToTextEditorParams(ApiModel):
|
||||
class PdfToSinglePageParams(ApiModel):
|
||||
"""
|
||||
Either upload a file or provide a server-side file ID
|
||||
"""
|
||||
|
||||
lightweight: bool = False
|
||||
|
||||
|
||||
class OutputFormat3(StrEnum):
|
||||
"""
|
||||
@@ -979,10 +909,10 @@ class PdfToXlsxParams(ApiModel):
|
||||
)
|
||||
|
||||
|
||||
class Pkcs11CertificatesParams(ApiModel):
|
||||
library_path: str | None = None
|
||||
pin: str | None = None
|
||||
slot: int | None = None
|
||||
class PdfToXmlParams(ApiModel):
|
||||
"""
|
||||
Either upload a file or provide a server-side file ID
|
||||
"""
|
||||
|
||||
|
||||
class CustomMode(StrEnum):
|
||||
@@ -1061,6 +991,18 @@ class RemoveBlanksParams(ApiModel):
|
||||
)
|
||||
|
||||
|
||||
class RemoveCertSignParams(ApiModel):
|
||||
"""
|
||||
Either upload a file or provide a server-side file ID
|
||||
"""
|
||||
|
||||
|
||||
class RemoveImagePdfParams(ApiModel):
|
||||
"""
|
||||
Either upload a file or provide a server-side file ID
|
||||
"""
|
||||
|
||||
|
||||
class RemovePagesParams(ApiModel):
|
||||
page_numbers: str = Field(
|
||||
"all",
|
||||
@@ -1077,6 +1019,12 @@ class RenameAttachmentParams(ApiModel):
|
||||
new_name: str = Field(..., description="The new name for the attachment")
|
||||
|
||||
|
||||
class RepairParams(ApiModel):
|
||||
"""
|
||||
Either upload a file or provide a server-side file ID
|
||||
"""
|
||||
|
||||
|
||||
class HighContrastColorCombination(StrEnum):
|
||||
"""
|
||||
If HIGH_CONTRAST_COLOR option selected, then pick the default color option for text and background.
|
||||
@@ -1237,27 +1185,6 @@ class ScannerEffectParams(ApiModel):
|
||||
yellowish: bool | None = Field(None, description="Simulate yellowed paper", examples=[False])
|
||||
|
||||
|
||||
class WorkflowType(StrEnum):
|
||||
signing = "SIGNING"
|
||||
review = "REVIEW"
|
||||
approval = "APPROVAL"
|
||||
|
||||
|
||||
class Request(ApiModel):
|
||||
document_name: str | None = None
|
||||
due_date: str | None = None
|
||||
message: str | None = None
|
||||
owner_email: str | None = None
|
||||
participant_emails: list[str] | None = None
|
||||
participant_user_ids: list[int] | None = None
|
||||
workflow_metadata: str | None = None
|
||||
workflow_type: WorkflowType | None = None
|
||||
|
||||
|
||||
class SessionsParams(ApiModel):
|
||||
request: Request | None = None
|
||||
|
||||
|
||||
class SplitBySizeOrCountParams(ApiModel):
|
||||
split_type: int = Field(
|
||||
0, description="Determines the type of split: 0 for size, 1 for page count, 2 for document count"
|
||||
@@ -1283,8 +1210,8 @@ class PageSize1(StrEnum):
|
||||
class SplitForPosterPrintParams(ApiModel):
|
||||
page_size: PageSize1 = Field(..., description="Target page size for output chunks (e.g., 'A4', 'Letter', 'A3')")
|
||||
right_to_left: bool = Field(False, description="Split right-to-left instead of left-to-right")
|
||||
xfactor: int | None = None
|
||||
yfactor: int | None = None
|
||||
x_factor: int = Field(2, description="Horizontal decimation factor (how many columns to split into)", ge=1, le=10)
|
||||
y_factor: int = Field(2, description="Vertical decimation factor (how many rows to split into)", ge=1, le=10)
|
||||
|
||||
|
||||
class SplitPagesParams(ApiModel):
|
||||
@@ -1360,6 +1287,12 @@ class TimestampPdfParams(ApiModel):
|
||||
)
|
||||
|
||||
|
||||
class UnlockPdfFormsParams(ApiModel):
|
||||
"""
|
||||
Either upload a file or provide a server-side file ID
|
||||
"""
|
||||
|
||||
|
||||
class Trapped(StrEnum):
|
||||
"""
|
||||
The trapped status of the document
|
||||
@@ -1399,11 +1332,6 @@ class UrlToPdfParams(ApiModel):
|
||||
url_input: str = Field(..., description="The input URL to be converted to a PDF file")
|
||||
|
||||
|
||||
class ValidateCertificateParams(ApiModel):
|
||||
cert_type: str | None = None
|
||||
password: str | None = None
|
||||
|
||||
|
||||
class OutputFormat6(StrEnum):
|
||||
"""
|
||||
Target vector format extension
|
||||
@@ -1462,20 +1390,24 @@ class Model(
|
||||
| CbzToPdfParams
|
||||
| EbookToPdfParams
|
||||
| EmlToPdfParams
|
||||
| FileToPdfParams
|
||||
| HtmlToPdfParams
|
||||
| ImgToPdfParams
|
||||
| MarkdownToPdfParams
|
||||
| PdfToCbrParams
|
||||
| PdfToCbzParams
|
||||
| PdfToCsvParams
|
||||
| PdfToEpubParams
|
||||
| PdfToHtmlParams
|
||||
| PdfToImgParams
|
||||
| PdfToMarkdownParams
|
||||
| PdfToPdfaParams
|
||||
| PdfToPresentationParams
|
||||
| PdfToTextParams
|
||||
| PdfToTextEditorParams
|
||||
| PdfToVectorParams
|
||||
| PdfToWordParams
|
||||
| PdfToXlsxParams
|
||||
| PdfToXmlParams
|
||||
| SvgToPdfParams
|
||||
| UrlToPdfParams
|
||||
| VectorToPdfParams
|
||||
@@ -1485,8 +1417,9 @@ class Model(
|
||||
| EditTextParams
|
||||
| MergePdfsParams
|
||||
| MultiPageLayoutParams
|
||||
| OverlayPdfsParams
|
||||
| PdfToSinglePageParams
|
||||
| RearrangePagesParams
|
||||
| RemoveImagePdfParams
|
||||
| RemovePagesParams
|
||||
| RotatePdfParams
|
||||
| ScalePagesParams
|
||||
@@ -1495,33 +1428,31 @@ class Model(
|
||||
| SplitPagesParams
|
||||
| SplitPdfByChaptersParams
|
||||
| SplitPdfBySectionsParams
|
||||
| AddAttachmentsParams
|
||||
| AddCommentsParams
|
||||
| AddImageParams
|
||||
| AddPageNumbersParams
|
||||
| AddStampParams
|
||||
| AutoRenameParams
|
||||
| AutoSplitPdfParams
|
||||
| CompressPdfParams
|
||||
| DeleteAttachmentParams
|
||||
| ExtractAttachmentsParams
|
||||
| ExtractImageScansParams
|
||||
| ExtractImagesParams
|
||||
| FlattenParams
|
||||
| OcrPdfParams
|
||||
| RemoveBlanksParams
|
||||
| RenameAttachmentParams
|
||||
| RepairParams
|
||||
| ReplaceInvertPdfParams
|
||||
| ScannerEffectParams
|
||||
| UnlockPdfFormsParams
|
||||
| UpdateMetadataParams
|
||||
| AddPasswordParams
|
||||
| AddWatermarkParams
|
||||
| AutoRedactParams
|
||||
| CertSignParams
|
||||
| Pkcs11CertificatesParams
|
||||
| SessionsParams
|
||||
| ValidateCertificateParams
|
||||
| RedactParams
|
||||
| RedactExecuteParams
|
||||
| RemoveCertSignParams
|
||||
| RemovePasswordParams
|
||||
| SanitizePdfParams
|
||||
| TimestampPdfParams
|
||||
@@ -1532,20 +1463,24 @@ class Model(
|
||||
| CbzToPdfParams
|
||||
| EbookToPdfParams
|
||||
| EmlToPdfParams
|
||||
| FileToPdfParams
|
||||
| HtmlToPdfParams
|
||||
| ImgToPdfParams
|
||||
| MarkdownToPdfParams
|
||||
| PdfToCbrParams
|
||||
| PdfToCbzParams
|
||||
| PdfToCsvParams
|
||||
| PdfToEpubParams
|
||||
| PdfToHtmlParams
|
||||
| PdfToImgParams
|
||||
| PdfToMarkdownParams
|
||||
| PdfToPdfaParams
|
||||
| PdfToPresentationParams
|
||||
| PdfToTextParams
|
||||
| PdfToTextEditorParams
|
||||
| PdfToVectorParams
|
||||
| PdfToWordParams
|
||||
| PdfToXlsxParams
|
||||
| PdfToXmlParams
|
||||
| SvgToPdfParams
|
||||
| UrlToPdfParams
|
||||
| VectorToPdfParams
|
||||
@@ -1555,8 +1490,9 @@ class Model(
|
||||
| EditTextParams
|
||||
| MergePdfsParams
|
||||
| MultiPageLayoutParams
|
||||
| OverlayPdfsParams
|
||||
| PdfToSinglePageParams
|
||||
| RearrangePagesParams
|
||||
| RemoveImagePdfParams
|
||||
| RemovePagesParams
|
||||
| RotatePdfParams
|
||||
| ScalePagesParams
|
||||
@@ -1565,33 +1501,31 @@ class Model(
|
||||
| SplitPagesParams
|
||||
| SplitPdfByChaptersParams
|
||||
| SplitPdfBySectionsParams
|
||||
| AddAttachmentsParams
|
||||
| AddCommentsParams
|
||||
| AddImageParams
|
||||
| AddPageNumbersParams
|
||||
| AddStampParams
|
||||
| AutoRenameParams
|
||||
| AutoSplitPdfParams
|
||||
| CompressPdfParams
|
||||
| DeleteAttachmentParams
|
||||
| ExtractAttachmentsParams
|
||||
| ExtractImageScansParams
|
||||
| ExtractImagesParams
|
||||
| FlattenParams
|
||||
| OcrPdfParams
|
||||
| RemoveBlanksParams
|
||||
| RenameAttachmentParams
|
||||
| RepairParams
|
||||
| ReplaceInvertPdfParams
|
||||
| ScannerEffectParams
|
||||
| UnlockPdfFormsParams
|
||||
| UpdateMetadataParams
|
||||
| AddPasswordParams
|
||||
| AddWatermarkParams
|
||||
| AutoRedactParams
|
||||
| CertSignParams
|
||||
| Pkcs11CertificatesParams
|
||||
| SessionsParams
|
||||
| ValidateCertificateParams
|
||||
| RedactParams
|
||||
| RedactExecuteParams
|
||||
| RemoveCertSignParams
|
||||
| RemovePasswordParams
|
||||
| SanitizePdfParams
|
||||
| TimestampPdfParams
|
||||
@@ -1603,20 +1537,24 @@ type ParamToolModel = (
|
||||
| CbzToPdfParams
|
||||
| EbookToPdfParams
|
||||
| EmlToPdfParams
|
||||
| FileToPdfParams
|
||||
| HtmlToPdfParams
|
||||
| ImgToPdfParams
|
||||
| MarkdownToPdfParams
|
||||
| PdfToCbrParams
|
||||
| PdfToCbzParams
|
||||
| PdfToCsvParams
|
||||
| PdfToEpubParams
|
||||
| PdfToHtmlParams
|
||||
| PdfToImgParams
|
||||
| PdfToMarkdownParams
|
||||
| PdfToPdfaParams
|
||||
| PdfToPresentationParams
|
||||
| PdfToTextParams
|
||||
| PdfToTextEditorParams
|
||||
| PdfToVectorParams
|
||||
| PdfToWordParams
|
||||
| PdfToXlsxParams
|
||||
| PdfToXmlParams
|
||||
| SvgToPdfParams
|
||||
| UrlToPdfParams
|
||||
| VectorToPdfParams
|
||||
@@ -1626,8 +1564,9 @@ type ParamToolModel = (
|
||||
| EditTextParams
|
||||
| MergePdfsParams
|
||||
| MultiPageLayoutParams
|
||||
| OverlayPdfsParams
|
||||
| PdfToSinglePageParams
|
||||
| RearrangePagesParams
|
||||
| RemoveImagePdfParams
|
||||
| RemovePagesParams
|
||||
| RotatePdfParams
|
||||
| ScalePagesParams
|
||||
@@ -1636,33 +1575,31 @@ type ParamToolModel = (
|
||||
| SplitPagesParams
|
||||
| SplitPdfByChaptersParams
|
||||
| SplitPdfBySectionsParams
|
||||
| AddAttachmentsParams
|
||||
| AddCommentsParams
|
||||
| AddImageParams
|
||||
| AddPageNumbersParams
|
||||
| AddStampParams
|
||||
| AutoRenameParams
|
||||
| AutoSplitPdfParams
|
||||
| CompressPdfParams
|
||||
| DeleteAttachmentParams
|
||||
| ExtractAttachmentsParams
|
||||
| ExtractImageScansParams
|
||||
| ExtractImagesParams
|
||||
| FlattenParams
|
||||
| OcrPdfParams
|
||||
| RemoveBlanksParams
|
||||
| RenameAttachmentParams
|
||||
| RepairParams
|
||||
| ReplaceInvertPdfParams
|
||||
| ScannerEffectParams
|
||||
| UnlockPdfFormsParams
|
||||
| UpdateMetadataParams
|
||||
| AddPasswordParams
|
||||
| AddWatermarkParams
|
||||
| AutoRedactParams
|
||||
| CertSignParams
|
||||
| Pkcs11CertificatesParams
|
||||
| SessionsParams
|
||||
| ValidateCertificateParams
|
||||
| RedactParams
|
||||
| RedactExecuteParams
|
||||
| RemoveCertSignParams
|
||||
| RemovePasswordParams
|
||||
| SanitizePdfParams
|
||||
| TimestampPdfParams
|
||||
@@ -1675,20 +1612,24 @@ class ToolEndpoint(StrEnum):
|
||||
CBZ_TO_PDF = "/api/v1/convert/cbz/pdf"
|
||||
EBOOK_TO_PDF = "/api/v1/convert/ebook/pdf"
|
||||
EML_TO_PDF = "/api/v1/convert/eml/pdf"
|
||||
FILE_TO_PDF = "/api/v1/convert/file/pdf"
|
||||
HTML_TO_PDF = "/api/v1/convert/html/pdf"
|
||||
IMG_TO_PDF = "/api/v1/convert/img/pdf"
|
||||
MARKDOWN_TO_PDF = "/api/v1/convert/markdown/pdf"
|
||||
PDF_TO_CBR = "/api/v1/convert/pdf/cbr"
|
||||
PDF_TO_CBZ = "/api/v1/convert/pdf/cbz"
|
||||
PDF_TO_CSV = "/api/v1/convert/pdf/csv"
|
||||
PDF_TO_EPUB = "/api/v1/convert/pdf/epub"
|
||||
PDF_TO_HTML = "/api/v1/convert/pdf/html"
|
||||
PDF_TO_IMG = "/api/v1/convert/pdf/img"
|
||||
PDF_TO_MARKDOWN = "/api/v1/convert/pdf/markdown"
|
||||
PDF_TO_PDFA = "/api/v1/convert/pdf/pdfa"
|
||||
PDF_TO_PRESENTATION = "/api/v1/convert/pdf/presentation"
|
||||
PDF_TO_TEXT = "/api/v1/convert/pdf/text"
|
||||
PDF_TO_TEXT_EDITOR = "/api/v1/convert/pdf/text-editor"
|
||||
PDF_TO_VECTOR = "/api/v1/convert/pdf/vector"
|
||||
PDF_TO_WORD = "/api/v1/convert/pdf/word"
|
||||
PDF_TO_XLSX = "/api/v1/convert/pdf/xlsx"
|
||||
PDF_TO_XML = "/api/v1/convert/pdf/xml"
|
||||
SVG_TO_PDF = "/api/v1/convert/svg/pdf"
|
||||
URL_TO_PDF = "/api/v1/convert/url/pdf"
|
||||
VECTOR_TO_PDF = "/api/v1/convert/vector/pdf"
|
||||
@@ -1698,8 +1639,9 @@ class ToolEndpoint(StrEnum):
|
||||
EDIT_TEXT = "/api/v1/general/edit-text"
|
||||
MERGE_PDFS = "/api/v1/general/merge-pdfs"
|
||||
MULTI_PAGE_LAYOUT = "/api/v1/general/multi-page-layout"
|
||||
OVERLAY_PDFS = "/api/v1/general/overlay-pdfs"
|
||||
PDF_TO_SINGLE_PAGE = "/api/v1/general/pdf-to-single-page"
|
||||
REARRANGE_PAGES = "/api/v1/general/rearrange-pages"
|
||||
REMOVE_IMAGE_PDF = "/api/v1/general/remove-image-pdf"
|
||||
REMOVE_PAGES = "/api/v1/general/remove-pages"
|
||||
ROTATE_PDF = "/api/v1/general/rotate-pdf"
|
||||
SCALE_PAGES = "/api/v1/general/scale-pages"
|
||||
@@ -1708,33 +1650,31 @@ class ToolEndpoint(StrEnum):
|
||||
SPLIT_PAGES = "/api/v1/general/split-pages"
|
||||
SPLIT_PDF_BY_CHAPTERS = "/api/v1/general/split-pdf-by-chapters"
|
||||
SPLIT_PDF_BY_SECTIONS = "/api/v1/general/split-pdf-by-sections"
|
||||
ADD_ATTACHMENTS = "/api/v1/misc/add-attachments"
|
||||
ADD_COMMENTS = "/api/v1/misc/add-comments"
|
||||
ADD_IMAGE = "/api/v1/misc/add-image"
|
||||
ADD_PAGE_NUMBERS = "/api/v1/misc/add-page-numbers"
|
||||
ADD_STAMP = "/api/v1/misc/add-stamp"
|
||||
AUTO_RENAME = "/api/v1/misc/auto-rename"
|
||||
AUTO_SPLIT_PDF = "/api/v1/misc/auto-split-pdf"
|
||||
COMPRESS_PDF = "/api/v1/misc/compress-pdf"
|
||||
DELETE_ATTACHMENT = "/api/v1/misc/delete-attachment"
|
||||
EXTRACT_ATTACHMENTS = "/api/v1/misc/extract-attachments"
|
||||
EXTRACT_IMAGE_SCANS = "/api/v1/misc/extract-image-scans"
|
||||
EXTRACT_IMAGES = "/api/v1/misc/extract-images"
|
||||
FLATTEN = "/api/v1/misc/flatten"
|
||||
OCR_PDF = "/api/v1/misc/ocr-pdf"
|
||||
REMOVE_BLANKS = "/api/v1/misc/remove-blanks"
|
||||
RENAME_ATTACHMENT = "/api/v1/misc/rename-attachment"
|
||||
REPAIR = "/api/v1/misc/repair"
|
||||
REPLACE_INVERT_PDF = "/api/v1/misc/replace-invert-pdf"
|
||||
SCANNER_EFFECT = "/api/v1/misc/scanner-effect"
|
||||
UNLOCK_PDF_FORMS = "/api/v1/misc/unlock-pdf-forms"
|
||||
UPDATE_METADATA = "/api/v1/misc/update-metadata"
|
||||
ADD_PASSWORD = "/api/v1/security/add-password"
|
||||
ADD_WATERMARK = "/api/v1/security/add-watermark"
|
||||
AUTO_REDACT = "/api/v1/security/auto-redact"
|
||||
CERT_SIGN = "/api/v1/security/cert-sign"
|
||||
PKCS11_CERTIFICATES = "/api/v1/security/cert-sign/hardware/pkcs11-certificates"
|
||||
SESSIONS = "/api/v1/security/cert-sign/sessions"
|
||||
VALIDATE_CERTIFICATE = "/api/v1/security/cert-sign/validate-certificate"
|
||||
REDACT = "/api/v1/security/redact"
|
||||
REDACT_EXECUTE = "/api/v1/security/redact-execute"
|
||||
REMOVE_CERT_SIGN = "/api/v1/security/remove-cert-sign"
|
||||
REMOVE_PASSWORD = "/api/v1/security/remove-password"
|
||||
SANITIZE_PDF = "/api/v1/security/sanitize-pdf"
|
||||
TIMESTAMP_PDF = "/api/v1/security/timestamp-pdf"
|
||||
@@ -1745,20 +1685,24 @@ OPERATIONS: dict[ToolEndpoint, ParamToolModelType] = {
|
||||
ToolEndpoint.CBZ_TO_PDF: CbzToPdfParams,
|
||||
ToolEndpoint.EBOOK_TO_PDF: EbookToPdfParams,
|
||||
ToolEndpoint.EML_TO_PDF: EmlToPdfParams,
|
||||
ToolEndpoint.FILE_TO_PDF: FileToPdfParams,
|
||||
ToolEndpoint.HTML_TO_PDF: HtmlToPdfParams,
|
||||
ToolEndpoint.IMG_TO_PDF: ImgToPdfParams,
|
||||
ToolEndpoint.MARKDOWN_TO_PDF: MarkdownToPdfParams,
|
||||
ToolEndpoint.PDF_TO_CBR: PdfToCbrParams,
|
||||
ToolEndpoint.PDF_TO_CBZ: PdfToCbzParams,
|
||||
ToolEndpoint.PDF_TO_CSV: PdfToCsvParams,
|
||||
ToolEndpoint.PDF_TO_EPUB: PdfToEpubParams,
|
||||
ToolEndpoint.PDF_TO_HTML: PdfToHtmlParams,
|
||||
ToolEndpoint.PDF_TO_IMG: PdfToImgParams,
|
||||
ToolEndpoint.PDF_TO_MARKDOWN: PdfToMarkdownParams,
|
||||
ToolEndpoint.PDF_TO_PDFA: PdfToPdfaParams,
|
||||
ToolEndpoint.PDF_TO_PRESENTATION: PdfToPresentationParams,
|
||||
ToolEndpoint.PDF_TO_TEXT: PdfToTextParams,
|
||||
ToolEndpoint.PDF_TO_TEXT_EDITOR: PdfToTextEditorParams,
|
||||
ToolEndpoint.PDF_TO_VECTOR: PdfToVectorParams,
|
||||
ToolEndpoint.PDF_TO_WORD: PdfToWordParams,
|
||||
ToolEndpoint.PDF_TO_XLSX: PdfToXlsxParams,
|
||||
ToolEndpoint.PDF_TO_XML: PdfToXmlParams,
|
||||
ToolEndpoint.SVG_TO_PDF: SvgToPdfParams,
|
||||
ToolEndpoint.URL_TO_PDF: UrlToPdfParams,
|
||||
ToolEndpoint.VECTOR_TO_PDF: VectorToPdfParams,
|
||||
@@ -1768,8 +1712,9 @@ OPERATIONS: dict[ToolEndpoint, ParamToolModelType] = {
|
||||
ToolEndpoint.EDIT_TEXT: EditTextParams,
|
||||
ToolEndpoint.MERGE_PDFS: MergePdfsParams,
|
||||
ToolEndpoint.MULTI_PAGE_LAYOUT: MultiPageLayoutParams,
|
||||
ToolEndpoint.OVERLAY_PDFS: OverlayPdfsParams,
|
||||
ToolEndpoint.PDF_TO_SINGLE_PAGE: PdfToSinglePageParams,
|
||||
ToolEndpoint.REARRANGE_PAGES: RearrangePagesParams,
|
||||
ToolEndpoint.REMOVE_IMAGE_PDF: RemoveImagePdfParams,
|
||||
ToolEndpoint.REMOVE_PAGES: RemovePagesParams,
|
||||
ToolEndpoint.ROTATE_PDF: RotatePdfParams,
|
||||
ToolEndpoint.SCALE_PAGES: ScalePagesParams,
|
||||
@@ -1778,33 +1723,31 @@ OPERATIONS: dict[ToolEndpoint, ParamToolModelType] = {
|
||||
ToolEndpoint.SPLIT_PAGES: SplitPagesParams,
|
||||
ToolEndpoint.SPLIT_PDF_BY_CHAPTERS: SplitPdfByChaptersParams,
|
||||
ToolEndpoint.SPLIT_PDF_BY_SECTIONS: SplitPdfBySectionsParams,
|
||||
ToolEndpoint.ADD_ATTACHMENTS: AddAttachmentsParams,
|
||||
ToolEndpoint.ADD_COMMENTS: AddCommentsParams,
|
||||
ToolEndpoint.ADD_IMAGE: AddImageParams,
|
||||
ToolEndpoint.ADD_PAGE_NUMBERS: AddPageNumbersParams,
|
||||
ToolEndpoint.ADD_STAMP: AddStampParams,
|
||||
ToolEndpoint.AUTO_RENAME: AutoRenameParams,
|
||||
ToolEndpoint.AUTO_SPLIT_PDF: AutoSplitPdfParams,
|
||||
ToolEndpoint.COMPRESS_PDF: CompressPdfParams,
|
||||
ToolEndpoint.DELETE_ATTACHMENT: DeleteAttachmentParams,
|
||||
ToolEndpoint.EXTRACT_ATTACHMENTS: ExtractAttachmentsParams,
|
||||
ToolEndpoint.EXTRACT_IMAGE_SCANS: ExtractImageScansParams,
|
||||
ToolEndpoint.EXTRACT_IMAGES: ExtractImagesParams,
|
||||
ToolEndpoint.FLATTEN: FlattenParams,
|
||||
ToolEndpoint.OCR_PDF: OcrPdfParams,
|
||||
ToolEndpoint.REMOVE_BLANKS: RemoveBlanksParams,
|
||||
ToolEndpoint.RENAME_ATTACHMENT: RenameAttachmentParams,
|
||||
ToolEndpoint.REPAIR: RepairParams,
|
||||
ToolEndpoint.REPLACE_INVERT_PDF: ReplaceInvertPdfParams,
|
||||
ToolEndpoint.SCANNER_EFFECT: ScannerEffectParams,
|
||||
ToolEndpoint.UNLOCK_PDF_FORMS: UnlockPdfFormsParams,
|
||||
ToolEndpoint.UPDATE_METADATA: UpdateMetadataParams,
|
||||
ToolEndpoint.ADD_PASSWORD: AddPasswordParams,
|
||||
ToolEndpoint.ADD_WATERMARK: AddWatermarkParams,
|
||||
ToolEndpoint.AUTO_REDACT: AutoRedactParams,
|
||||
ToolEndpoint.CERT_SIGN: CertSignParams,
|
||||
ToolEndpoint.PKCS11_CERTIFICATES: Pkcs11CertificatesParams,
|
||||
ToolEndpoint.SESSIONS: SessionsParams,
|
||||
ToolEndpoint.VALIDATE_CERTIFICATE: ValidateCertificateParams,
|
||||
ToolEndpoint.REDACT: RedactParams,
|
||||
ToolEndpoint.REDACT_EXECUTE: RedactExecuteParams,
|
||||
ToolEndpoint.REMOVE_CERT_SIGN: RemoveCertSignParams,
|
||||
ToolEndpoint.REMOVE_PASSWORD: RemovePasswordParams,
|
||||
ToolEndpoint.SANITIZE_PDF: SanitizePdfParams,
|
||||
ToolEndpoint.TIMESTAMP_PDF: TimestampPdfParams,
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
# production
|
||||
/build
|
||||
/dist
|
||||
/dist-portal
|
||||
/storybook-static
|
||||
|
||||
/editor/build
|
||||
|
||||
+1
@@ -0,0 +1 @@
|
||||
declare module "*.css" {}
|
||||
@@ -61,6 +61,11 @@ const config: StorybookConfig = {
|
||||
config.define = {
|
||||
...(config.define ?? {}),
|
||||
"import.meta.env.VITE_SAAS_API_URL": JSON.stringify("http://saas.mock"),
|
||||
// Keep the Supabase auth env empty so ensureSaasSupabase() is a no-op and
|
||||
// never replaces the mock SaaS client stubbed in preview.tsx.
|
||||
"import.meta.env.VITE_SUPABASE_URL": JSON.stringify(""),
|
||||
"import.meta.env.VITE_SUPABASE_PUBLISHABLE_DEFAULT_KEY":
|
||||
JSON.stringify(""),
|
||||
};
|
||||
return config;
|
||||
},
|
||||
|
||||
@@ -2,12 +2,11 @@
|
||||
// the decorators below transpiles to React.createElement and needs React in
|
||||
// scope. (The app + story files use the automatic runtime via the portal vite
|
||||
// config; this import is specifically for the preview config file.)
|
||||
import React, { useEffect } from "react";
|
||||
import React, { Suspense, useEffect } from "react";
|
||||
import type { Decorator, Preview } from "@storybook/react-vite";
|
||||
import { initialize, mswLoader } from "msw-storybook-addon";
|
||||
import { MemoryRouter } from "react-router-dom";
|
||||
import { withThemeByDataAttribute } from "@storybook/addon-themes";
|
||||
import { MantineProvider } from "@mantine/core";
|
||||
|
||||
// Reference React so the import isn't dropped as unused by the bundler — the
|
||||
// classic runtime needs it present even though it's not named in the JSX.
|
||||
@@ -15,9 +14,9 @@ void React;
|
||||
|
||||
import { TierProvider, type Tier } from "@portal/contexts/TierContext";
|
||||
import { LinkProvider, type LinkState } from "@portal/contexts/LinkContext";
|
||||
import { ThemeProvider } from "@portal/contexts/ThemeContext";
|
||||
import { ThemeProvider, useTheme } from "@portal/contexts/ThemeContext";
|
||||
import { UIProvider } from "@portal/contexts/UIContext";
|
||||
import { mantineTheme } from "@portal/theme/mantineTheme";
|
||||
import { SuiProvider } from "@portal/theme/SuiProvider";
|
||||
import { handlers } from "@portal/mocks/handlers";
|
||||
import { configureSupabase } from "@proprietary/auth/supabase/supabaseClient";
|
||||
|
||||
@@ -30,10 +29,10 @@ initialize({ onUnhandledRequest: "bypass" }, handlers);
|
||||
|
||||
// Storybook-only: stub a SaaS session so apiClient.saas reads (invoices, payment
|
||||
// method, wallet) clear the session check and reach the MSW handlers instead of
|
||||
// failing with "No SaaS session". VITE_SAAS_SUPABASE_URL/KEY are intentionally
|
||||
// unset, so ensureSaasSupabase() is a no-op and never replaces this client; only
|
||||
// VITE_SAAS_API_URL (a mock origin MSW matches) is configured — injected via
|
||||
// .storybook/main.ts's viteFinal define, not a frontend/.env file.
|
||||
// failing with "No SaaS session". VITE_SUPABASE_URL/KEY are defined empty (see
|
||||
// .storybook/main.ts), so ensureSaasSupabase() is a no-op and never replaces this
|
||||
// client; only VITE_SAAS_API_URL (a mock origin MSW matches) is configured —
|
||||
// injected via .storybook/main.ts's viteFinal define, not a frontend/.env file.
|
||||
const saasStub = configureSupabase({
|
||||
url: "http://saas.mock",
|
||||
key: "storybook-anon-key",
|
||||
@@ -79,13 +78,21 @@ function TierKey({
|
||||
);
|
||||
}
|
||||
|
||||
/** Keeps useTheme() and the data-theme attribute in sync. */
|
||||
function ThemeWatcher() {
|
||||
/**
|
||||
* Makes the Storybook toolbar the SINGLE source of truth for the theme.
|
||||
*/
|
||||
function ThemeBridge({
|
||||
theme,
|
||||
children,
|
||||
}: {
|
||||
theme: "light" | "dark";
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const { setTheme } = useTheme();
|
||||
useEffect(() => {
|
||||
// The addon-themes decorator already sets data-theme on <html>.
|
||||
// We just read it on mount so ThemeProvider picks it up.
|
||||
}, []);
|
||||
return null;
|
||||
setTheme(theme);
|
||||
}, [theme, setTheme]);
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
const withProviders: Decorator = (Story, context) => {
|
||||
@@ -102,18 +109,21 @@ const withProviders: Decorator = (Story, context) => {
|
||||
return (
|
||||
<MemoryRouter initialEntries={["/"]}>
|
||||
<ThemeProvider>
|
||||
<MantineProvider theme={mantineTheme} forceColorScheme={colorScheme}>
|
||||
{/* LinkProvider must wrap TierProvider: TierContext derives its tier
|
||||
from useLink() (matches App.tsx's nesting). */}
|
||||
<LinkProvider key={linkState} initialState={linkState}>
|
||||
<TierKey tier={tier}>
|
||||
<UIProvider>
|
||||
<ThemeWatcher />
|
||||
<Story />
|
||||
</UIProvider>
|
||||
</TierKey>
|
||||
</LinkProvider>
|
||||
</MantineProvider>
|
||||
<ThemeBridge theme={colorScheme}>
|
||||
<SuiProvider colorScheme={colorScheme}>
|
||||
{/* LinkProvider must wrap TierProvider: TierContext derives its tier
|
||||
from useLink() (matches App.tsx's nesting). */}
|
||||
<LinkProvider key={linkState} initialState={linkState}>
|
||||
<TierKey tier={tier}>
|
||||
<UIProvider>
|
||||
<Suspense fallback={null}>
|
||||
<Story />
|
||||
</Suspense>
|
||||
</UIProvider>
|
||||
</TierKey>
|
||||
</LinkProvider>
|
||||
</SuiProvider>
|
||||
</ThemeBridge>
|
||||
</ThemeProvider>
|
||||
</MemoryRouter>
|
||||
);
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user