Merge branch 'main' into fix_admin_settings_hydration_20260627
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.0
|
||||
|
||||
- name: Install Task
|
||||
uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0
|
||||
|
||||
- name: Regenerate tool models
|
||||
run: task engine:tool-models
|
||||
|
||||
- name: Verify tool models are up to date
|
||||
id: tool-models-check
|
||||
continue-on-error: true
|
||||
run: git diff --exit-code engine/src/stirling/models/tool_models.py
|
||||
|
||||
- name: Comment on tool models check failure
|
||||
# Only post a comment on PRs. github-script's PR helpers need an
|
||||
# issue/PR number, which doesn't exist on merge_group runs.
|
||||
if: steps.tool-models-check.outcome == 'failure' && github.event_name == 'pull_request'
|
||||
continue-on-error: true
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||
with:
|
||||
script: |
|
||||
const marker = '<!-- tool-models-check -->';
|
||||
const body = [
|
||||
marker,
|
||||
'### Tool Models Check Failed',
|
||||
'',
|
||||
'The generated `engine/src/stirling/models/tool_models.py` is out of date with the Java OpenAPI spec and will need to be regenerated before it can be merged in.',
|
||||
'',
|
||||
'Run `task engine:tool-models` to regenerate, then commit the updated file.',
|
||||
].join('\n');
|
||||
const { data: comments } = await github.rest.issues.listComments({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: context.issue.number,
|
||||
});
|
||||
const existing = comments.find(c => c.body.includes(marker));
|
||||
if (existing) {
|
||||
await github.rest.issues.updateComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
comment_id: existing.id,
|
||||
body,
|
||||
});
|
||||
} else {
|
||||
await github.rest.issues.createComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: context.issue.number,
|
||||
body,
|
||||
});
|
||||
}
|
||||
|
||||
- name: Fail if tool models check failed
|
||||
if: steps.tool-models-check.outcome == 'failure'
|
||||
run: |
|
||||
echo "============================================"
|
||||
echo " Tool Models Check Failed"
|
||||
echo "============================================"
|
||||
echo ""
|
||||
echo "The generated engine/src/stirling/models/tool_models.py"
|
||||
echo "is out of date with the Java OpenAPI spec and will"
|
||||
echo "need to be regenerated before it can be merged in."
|
||||
echo ""
|
||||
echo "Run 'task engine:tool-models' to regenerate, then"
|
||||
echo "commit the updated file."
|
||||
echo "============================================"
|
||||
exit 1
|
||||
|
||||
- name: Remove tool models check comment on success
|
||||
if: steps.tool-models-check.outcome == 'success' && github.event_name == 'pull_request'
|
||||
continue-on-error: true
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||
with:
|
||||
script: |
|
||||
const marker = '<!-- tool-models-check -->';
|
||||
const { data: comments } = await github.rest.issues.listComments({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: context.issue.number,
|
||||
});
|
||||
const existing = comments.find(c => c.body.includes(marker));
|
||||
if (existing) {
|
||||
await github.rest.issues.deleteComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
comment_id: existing.id,
|
||||
});
|
||||
}
|
||||
|
||||
- name: Quality-check engine
|
||||
id: engine-check
|
||||
run: task engine:check
|
||||
|
||||
@@ -2,7 +2,7 @@ name: Enterprise E2E (Playwright)
|
||||
|
||||
# Enterprise Playwright suite — exercises premium-key gated features (audit,
|
||||
# teams, analytics) plus full OAuth + SAML logins via the Keycloak compose
|
||||
# stacks under testing/compose. Slow and secret-gated, so it runs in three
|
||||
# stacks under testing/compose. Slow and secret-gated, so it runs in four
|
||||
# situations:
|
||||
#
|
||||
# - PRs that touch proprietary / premium / SSO compose / enterprise tests
|
||||
@@ -12,8 +12,6 @@ name: Enterprise E2E (Playwright)
|
||||
# - on a nightly cron schedule (catches Keycloak image drift, license
|
||||
# expiry, upstream proprietary changes),
|
||||
# - manual workflow_dispatch.
|
||||
#
|
||||
# Auto-skipped when secrets.PREMIUM_KEY_ENTERPRISE is missing (forks, dependabot).
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
@@ -52,6 +50,10 @@ jobs:
|
||||
|
||||
playwright-e2e-enterprise:
|
||||
needs: pick
|
||||
# Skip on fork PRs / untrusted authors: they have no PREMIUM_KEY_ENTERPRISE
|
||||
# (nor DEPOT_TOKEN), so the suite can't boot premium and would fail. See the
|
||||
# header comment. GitHub reports the skipped reusable workflow as success.
|
||||
if: needs.pick.outputs.is_fork != 'true'
|
||||
runs-on: ${{ needs.pick.outputs.is_fork == 'true' && 'ubuntu-latest' || format('depot-ubuntu-24.04-{0}', inputs.depot_cores || '8') }}
|
||||
timeout-minutes: 45
|
||||
env:
|
||||
@@ -165,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()
|
||||
@@ -238,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()
|
||||
@@ -268,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()
|
||||
@@ -280,10 +288,23 @@ jobs:
|
||||
run: |
|
||||
source /tmp/helpers.sh
|
||||
stop_backend
|
||||
- name: Flag flaky tests
|
||||
# Runs regardless of the test outcomes: a flaky test (passed on retry)
|
||||
# leaves its step green, so this is the only place it surfaces. Merges
|
||||
# all three phase reports (some may be absent if an earlier phase hard-
|
||||
# failed and skipped the rest). Emits ::warning:: annotations + a job
|
||||
# summary; never fails the job.
|
||||
if: always()
|
||||
working-directory: frontend
|
||||
run: >
|
||||
npx tsx editor/scripts/report-flaky-tests.mts
|
||||
"${{ github.workspace }}/frontend/playwright-report/results-oauth.json"
|
||||
"${{ github.workspace }}/frontend/playwright-report/results-saml.json"
|
||||
"${{ github.workspace }}/frontend/playwright-report/results-feature.json"
|
||||
- name: Upload Playwright report
|
||||
if: always()
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: playwright-report-enterprise-${{ github.run_id }}
|
||||
path: frontend/editor/playwright-report/
|
||||
path: frontend/playwright-report/
|
||||
retention-days: 7
|
||||
|
||||
@@ -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:
|
||||
@@ -202,6 +217,9 @@ jobs:
|
||||
contents: read
|
||||
uses: ./.github/workflows/coverage-aggregate.yml
|
||||
secrets: inherit
|
||||
with:
|
||||
frontend-validation-result: ${{ needs.frontend-validation.result }}
|
||||
playwright-e2e-live-result: ${{ needs.playwright-e2e-live.result }}
|
||||
|
||||
# Single status check that branch protection should mark as required.
|
||||
# Succeeds when every upstream job is either `success` or `skipped` (path-
|
||||
@@ -225,6 +243,7 @@ jobs:
|
||||
- test-build-docker-images
|
||||
- tauri-build
|
||||
- ai-engine
|
||||
- generated-models
|
||||
- pre-commit
|
||||
- dependency-review
|
||||
runs-on: ubuntu-latest
|
||||
@@ -250,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,
|
||||
});
|
||||
}
|
||||
@@ -13,6 +13,17 @@ name: Aggregate backend coverage
|
||||
# producers themselves
|
||||
on:
|
||||
workflow_call:
|
||||
inputs:
|
||||
frontend-validation-result:
|
||||
description: Result of the frontend-validation producer job
|
||||
required: false
|
||||
type: string
|
||||
default: skipped
|
||||
playwright-e2e-live-result:
|
||||
description: Result of the playwright-e2e-live producer job
|
||||
required: false
|
||||
type: string
|
||||
default: skipped
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
@@ -196,9 +207,9 @@ jobs:
|
||||
# --------------------------------------------------------------
|
||||
- name: Download vitest coverage artifact
|
||||
# frontend-validation uploads as `frontend-coverage`. Tolerate
|
||||
# absence so a backend-only PR still produces the matrix with
|
||||
# just backend rows populated.
|
||||
if: always()
|
||||
# absence on backend-only runs by skipping the download entirely
|
||||
# when the producer job was not part of this workflow run.
|
||||
if: inputs.frontend-validation-result == 'success'
|
||||
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v6.0.0
|
||||
with:
|
||||
name: frontend-coverage
|
||||
@@ -206,12 +217,12 @@ jobs:
|
||||
continue-on-error: true
|
||||
|
||||
- name: Download Playwright frontend coverage artifact
|
||||
# e2e-live uploads as `playwright-frontend-coverage-<run_id>`.
|
||||
# Same tolerance as vitest - matrix script handles missing inputs.
|
||||
if: always()
|
||||
# e2e-live uploads the artifact with a stable name. Skip the
|
||||
# download entirely when the producer job did not run.
|
||||
if: inputs.playwright-e2e-live-result == 'success'
|
||||
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v6.0.0
|
||||
with:
|
||||
name: playwright-frontend-coverage-${{ github.run_id }}
|
||||
name: playwright-frontend-coverage
|
||||
path: matrix-inputs/playwright/
|
||||
continue-on-error: true
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -169,7 +179,7 @@ jobs:
|
||||
if: always() && steps.pw-frontend-coverage.outputs.summary == 'true'
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: playwright-frontend-coverage-${{ github.run_id }}
|
||||
name: playwright-frontend-coverage
|
||||
path: |
|
||||
.test-state/playwright/coverage-pw-summary/
|
||||
.test-state/playwright/coverage-pw/
|
||||
|
||||
@@ -44,11 +44,22 @@ jobs:
|
||||
VITE_BUILD_FOR_PREVIEW: "1"
|
||||
run: task frontend:build
|
||||
- name: Run stubbed E2E tests (chromium)
|
||||
env:
|
||||
PLAYWRIGHT_JSON_OUTPUT_FILE: ${{ github.workspace }}/frontend/playwright-report/results.json
|
||||
run: task e2e:stubbed -- --workers=3
|
||||
- name: Flag flaky tests
|
||||
# Runs regardless of the test outcome: a flaky test (passed on retry)
|
||||
# leaves the step green, so this is the only place it surfaces. Emits
|
||||
# ::warning:: annotations + a job summary; never fails the job.
|
||||
if: always()
|
||||
working-directory: frontend
|
||||
run: npx tsx editor/scripts/report-flaky-tests.mts "$PLAYWRIGHT_JSON_OUTPUT_FILE"
|
||||
env:
|
||||
PLAYWRIGHT_JSON_OUTPUT_FILE: ${{ github.workspace }}/frontend/playwright-report/results.json
|
||||
- name: Upload Playwright report
|
||||
if: always()
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: playwright-report-stubbed-${{ github.run_id }}
|
||||
path: frontend/editor/playwright-report/
|
||||
path: frontend/playwright-report/
|
||||
retention-days: 7
|
||||
|
||||
@@ -98,6 +98,13 @@ jobs:
|
||||
|
||||
- name: Install Task
|
||||
uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0
|
||||
|
||||
- name: Generate frontend license report (Push only)
|
||||
if: github.event_name == 'push'
|
||||
env:
|
||||
PR_IS_FORK: "false"
|
||||
run: task frontend:licenses:generate
|
||||
|
||||
- name: Generate frontend license report (internal PR)
|
||||
if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork == false
|
||||
env:
|
||||
@@ -353,6 +360,7 @@ jobs:
|
||||
|
||||
- name: Install Task
|
||||
uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0
|
||||
|
||||
- name: Check licenses and generate report
|
||||
id: license-check
|
||||
run: task backend:licenses:generate || echo "LICENSE_CHECK_FAILED=true" >> $GITHUB_ENV
|
||||
|
||||
@@ -53,8 +53,8 @@ jobs:
|
||||
if: always()
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: playwright-nightly-${{ github.run_id }}
|
||||
path: frontend/editor/playwright-report/
|
||||
name: playwright-report-nightly-${{ github.run_id }}
|
||||
path: frontend/playwright-report/
|
||||
retention-days: 14
|
||||
|
||||
# Builds all desktop platforms on a schedule so the Rust dependency cache is
|
||||
|
||||
@@ -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]
|
||||
|
||||
+3
-3
@@ -92,7 +92,7 @@ Visit the [Lombok website](https://projectlombok.org/setup/) for installation in
|
||||
|
||||
5. Add environment variable
|
||||
For local testing, you should generally be testing the full 'Security' version of Stirling PDF. To do this, you must add the environment flag DISABLE_ADDITIONAL_FEATURES=false to your system and/or IDE build/run step.
|
||||
5. **Frontend Setup (Required for Stirling 2.0)**
|
||||
6. **Frontend Setup (Required for Stirling 2.0)**
|
||||
Navigate to the frontend directory and install dependencies using npm.
|
||||
|
||||
### Verify Setup
|
||||
@@ -275,7 +275,7 @@ Stirling-PDF uses different Docker images for various configurations. The build
|
||||
1. Set the security environment variable:
|
||||
|
||||
```bash
|
||||
export DISABLE_ADDITIONAL_FEATURES=true # or false for to enable login and security features for builds
|
||||
export DISABLE_ADDITIONAL_FEATURES=true # or false to enable login and security features for builds
|
||||
```
|
||||
|
||||
2. Build the project:
|
||||
@@ -305,7 +305,7 @@ Stirling-PDF uses different Docker images for various configurations. The build
|
||||
docker build --no-cache --pull --build-arg VERSION_TAG=alpha -t stirlingtools/stirling-pdf:latest-fat -f ./Dockerfile.fat .
|
||||
```
|
||||
|
||||
Note: The `--no-cache` and `--pull` flags ensure that the build process uses the latest base images and doesn't use cached layers, which is useful for testing and ensuring reproducible builds. however to improve build times these can often be removed depending on your usecase
|
||||
Note: The `--no-cache` and `--pull` flags ensure that the build process uses the latest base images and doesn't use cached layers, which is useful for testing and ensuring reproducible builds. However, to improve build times these can often be removed depending on your use case
|
||||
|
||||
## 7. Testing
|
||||
|
||||
|
||||
@@ -53,8 +53,8 @@ For full installation options (including desktop and Kubernetes), see our [Docum
|
||||
|
||||
## Support
|
||||
|
||||
- **Community** [Discord](https://discord.gg/HYmhKj45pU)
|
||||
- **Bug Reports**: [Github issues](https://github.com/Stirling-Tools/Stirling-PDF/issues)
|
||||
- **Community**: [Discord](https://discord.gg/HYmhKj45pU)
|
||||
- **Bug Reports**: [GitHub Issues](https://github.com/Stirling-Tools/Stirling-PDF/issues)
|
||||
|
||||
## Contributing
|
||||
|
||||
|
||||
@@ -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
|
||||
# ============================================================
|
||||
|
||||
@@ -80,10 +80,18 @@
|
||||
"moduleName": ".*",
|
||||
"moduleLicense": "Apache License Version 2.0"
|
||||
},
|
||||
{
|
||||
"moduleName": ".*",
|
||||
"moduleLicense": "Apache License version 2.0"
|
||||
},
|
||||
{
|
||||
"moduleName": ".*",
|
||||
"moduleLicense": "Apache License, Version 2.0"
|
||||
},
|
||||
{
|
||||
"moduleName": ".*",
|
||||
"moduleLicense": "Apache License, version 2.0"
|
||||
},
|
||||
{
|
||||
"moduleName": ".*",
|
||||
"moduleLicense": "The Apache License, Version 2.0"
|
||||
@@ -108,6 +116,10 @@
|
||||
"moduleName": ".*",
|
||||
"moduleLicense": "Mozilla Public License 2.0 (MPL-2.0)"
|
||||
},
|
||||
{
|
||||
"moduleName": ".*",
|
||||
"moduleLicense": "Mozilla Public License Version 2.0"
|
||||
},
|
||||
{
|
||||
"moduleName": ".*",
|
||||
"moduleLicense": "CDDL+GPL License"
|
||||
@@ -172,6 +184,14 @@
|
||||
"moduleName": ".*",
|
||||
"moduleLicense": "Eclipse Public License, Version 2.0"
|
||||
},
|
||||
{
|
||||
"moduleName": ".*",
|
||||
"moduleLicense": "EPL-2.0"
|
||||
},
|
||||
{
|
||||
"moduleName": ".*",
|
||||
"moduleLicense": "LGPL-2.1-only"
|
||||
},
|
||||
{
|
||||
"moduleName": ".*",
|
||||
"moduleLicense": "Ubuntu Font Licence 1.0"
|
||||
|
||||
@@ -132,7 +132,7 @@ public class AppConfig {
|
||||
return true;
|
||||
}
|
||||
Path mountInfo = Path.of("/proc/1/mountinfo");
|
||||
// this should always exist, if not some unknown usecase
|
||||
// this should always exist, if not some unknown use case
|
||||
if (!Files.exists(mountInfo)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
|
||||
+113
-15
@@ -6,6 +6,7 @@ import java.net.http.HttpClient;
|
||||
import java.net.http.HttpRequest;
|
||||
import java.net.http.HttpResponse;
|
||||
import java.time.Duration;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||
@@ -14,25 +15,31 @@ import org.springframework.stereotype.Service;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.proprietary.billing.UnitCalcPolicy;
|
||||
|
||||
import tools.jackson.databind.JsonNode;
|
||||
import tools.jackson.databind.ObjectMapper;
|
||||
import tools.jackson.databind.node.ObjectNode;
|
||||
|
||||
/**
|
||||
* Outbound calls from a self-hosted instance to its linked SaaS backend (combined-billing "Mode
|
||||
* A").
|
||||
*
|
||||
* <p>Two calls:
|
||||
* <p>Calls:
|
||||
*
|
||||
* <ul>
|
||||
* <li>{@link #register} — relays the admin's short-lived Supabase JWT to {@code POST
|
||||
* /api/v1/account-link/register}; the SaaS side mints + returns a device credential.
|
||||
* <li>{@link #fetchEntitlement} — authenticates with the stored device credential against {@code
|
||||
* GET /api/v1/instance/entitlement}; what the local gate consults.
|
||||
* <li>{@link #reportUsage} — daily usage sync ({@code POST /api/v1/instance/sync}); reports
|
||||
* cumulative units and returns the refreshed entitlement.
|
||||
* <li>{@link #revokeSelf} — self-revokes the credential on local unlink ({@code POST
|
||||
* /api/v1/instance/revoke-self}).
|
||||
* </ul>
|
||||
*
|
||||
* <p>Uses {@code java.net.http.HttpClient} (the established self-hosted outbound pattern, see
|
||||
* {@code AiEngineClient}). The base URL + client are injectable so tests can stub the SaaS
|
||||
* endpoint.
|
||||
* <p>Uses {@code java.net.http.HttpClient} (the established self-hosted outbound pattern; see
|
||||
* {@code AiEngineClient}); base URL + client are injectable so tests can stub SaaS.
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
@@ -86,11 +93,9 @@ public class AccountLinkClient {
|
||||
}
|
||||
|
||||
/**
|
||||
* Authoritative deny (401/403) from the entitlement endpoint — the device credential is revoked
|
||||
* or invalid. Distinct from a transport/server failure (which returns {@code null} and fails
|
||||
* open): the cache must BLOCK billable work on this rather than serve a stale entitled
|
||||
* snapshot. Unchecked so it propagates cleanly through {@link #fetchEntitlement}'s transport
|
||||
* try/catch.
|
||||
* Authoritative deny (401/403) — the device credential is revoked or invalid. Unlike a
|
||||
* transport/server failure (which returns {@code null} and fails open), the cache must BLOCK on
|
||||
* this. Unchecked so it propagates through {@link #fetchEntitlement}'s transport try/catch.
|
||||
*/
|
||||
public static final class RevokedException extends RuntimeException {
|
||||
private final int status;
|
||||
@@ -142,11 +147,9 @@ public class AccountLinkClient {
|
||||
}
|
||||
|
||||
/**
|
||||
* Revokes this instance's own credential on the SaaS side ({@code POST
|
||||
* /api/v1/instance/revoke-self}), authenticated by the device credential — a credential is
|
||||
* allowed to revoke its own identity. Best-effort: returns {@code false} if SaaS is unreachable
|
||||
* or rejects the call, so the caller (local unlink) can still clear locally and log the orphan
|
||||
* row for follow-up. Idempotent on SaaS (already-revoked → still 204).
|
||||
* Revokes this instance's own credential on the SaaS side, authenticated by that credential.
|
||||
* Best-effort: returns {@code false} if SaaS is unreachable or rejects, so the caller (local
|
||||
* unlink) can still clear locally and log the orphan for follow-up. Idempotent on SaaS.
|
||||
*/
|
||||
public boolean revokeSelf(String deviceId, String deviceSecret) {
|
||||
try {
|
||||
@@ -218,6 +221,63 @@ public class AccountLinkClient {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reports the period's cumulative per-category units to {@code POST /api/v1/instance/sync} and
|
||||
* returns the fresh entitlement in the same reply — one round-trip both reports and refreshes.
|
||||
* SaaS bills the delta against its last-seen cumulative, so resending the same totals is
|
||||
* idempotent. Same three outcomes as {@link #fetchEntitlement}; on {@code null} the caller must
|
||||
* not advance its last-synced markers so the usage retries next sync.
|
||||
*/
|
||||
public InstanceEntitlement reportUsage(
|
||||
String deviceId,
|
||||
String deviceSecret,
|
||||
long syncSeq,
|
||||
LocalDateTime periodStart,
|
||||
long apiUnits,
|
||||
long aiUnits,
|
||||
long automationUnits) {
|
||||
HttpResponse<String> response;
|
||||
try {
|
||||
ObjectNode root = mapper.createObjectNode();
|
||||
root.put("syncSeq", syncSeq);
|
||||
// Explicit ISO-8601 string so it round-trips regardless of the mapper's time config.
|
||||
root.put("periodStart", periodStart.toString());
|
||||
ObjectNode units = root.putObject("cumulativeUnits");
|
||||
units.put("api", apiUnits);
|
||||
units.put("ai", aiUnits);
|
||||
units.put("automation", automationUnits);
|
||||
String body = mapper.writeValueAsString(root);
|
||||
HttpRequest request =
|
||||
HttpRequest.newBuilder()
|
||||
.uri(uri("/api/v1/instance/sync"))
|
||||
.header(HEADER_DEVICE_ID, deviceId)
|
||||
.header(HEADER_DEVICE_SECRET, deviceSecret)
|
||||
.header("Content-Type", "application/json")
|
||||
.header("Accept", "application/json")
|
||||
.timeout(timeout())
|
||||
.POST(HttpRequest.BodyPublishers.ofString(body))
|
||||
.build();
|
||||
response = send(request);
|
||||
} catch (Exception e) {
|
||||
log.debug("Usage sync failed: {}", e.getMessage());
|
||||
return null;
|
||||
}
|
||||
int status = response.statusCode();
|
||||
if (status == 401 || status == 403) {
|
||||
throw new RevokedException(status);
|
||||
}
|
||||
if (status / 100 != 2) {
|
||||
log.debug("Usage sync returned HTTP {}", status);
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return parseEntitlement(response.body());
|
||||
} catch (IOException e) {
|
||||
log.debug("Usage sync parse failed: {}", e.getMessage());
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private InstanceEntitlement parseEntitlement(String body) throws IOException {
|
||||
JsonNode root = mapper.readTree(body);
|
||||
boolean subscribed = root.path("subscribed").asBoolean(false);
|
||||
@@ -226,7 +286,45 @@ public class AccountLinkClient {
|
||||
Long periodCap =
|
||||
root.hasNonNull("periodCapUnits") ? root.get("periodCapUnits").asLong() : null;
|
||||
EntitlementState state = mapState(root.path("state").asText(null));
|
||||
return new InstanceEntitlement(subscribed, freeRemaining, periodSpend, periodCap, state);
|
||||
return new InstanceEntitlement(
|
||||
subscribed,
|
||||
freeRemaining,
|
||||
periodSpend,
|
||||
periodCap,
|
||||
state,
|
||||
parseUnitCalcPolicy(root),
|
||||
parseDateTime(root, "periodStart"),
|
||||
parseDateTime(root, "periodEnd"));
|
||||
}
|
||||
|
||||
/** Parses the nested unit-calc policy; null if absent or any knob is invalid (e.g. zero). */
|
||||
private static UnitCalcPolicy parseUnitCalcPolicy(JsonNode root) {
|
||||
if (!root.hasNonNull("unitCalcPolicy")) {
|
||||
return null;
|
||||
}
|
||||
JsonNode node = root.get("unitCalcPolicy");
|
||||
try {
|
||||
return new UnitCalcPolicy(
|
||||
node.path("docPagesPerUnit").asInt(),
|
||||
node.path("docBytesPerUnit").asLong(),
|
||||
node.path("minChargeUnits").asInt(),
|
||||
node.path("fileUnitCap").asInt());
|
||||
} catch (RuntimeException e) {
|
||||
// Malformed policy → degrade to "none" rather than fail the whole entitlement parse.
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** ISO date-time field → LocalDateTime; null if absent or unparseable. */
|
||||
private static LocalDateTime parseDateTime(JsonNode root, String field) {
|
||||
if (!root.hasNonNull(field)) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return LocalDateTime.parse(root.get(field).asText(null));
|
||||
} catch (RuntimeException e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Maps the SaaS state string to our coarse enum; unrecognised → UNKNOWN. */
|
||||
|
||||
+38
-2
@@ -2,6 +2,7 @@ package stirling.software.proprietary.accountlink;
|
||||
|
||||
import java.io.IOException;
|
||||
|
||||
import org.springframework.beans.factory.ObjectProvider;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||
import org.springframework.context.annotation.Profile;
|
||||
import org.springframework.http.HttpStatus;
|
||||
@@ -23,7 +24,9 @@ import lombok.extern.slf4j.Slf4j;
|
||||
* <p>The portal (served from this same origin, admin authenticated by the existing self-hosted
|
||||
* security chain) calls these. {@code POST /link} relays the admin's Supabase JWT to the SaaS
|
||||
* backend, which mints + returns a device credential we store locally. {@code GET /status} backs
|
||||
* the portal's link card.
|
||||
* the portal's link card; {@code GET /usage} exposes locally-accrued unsynced usage the portal adds
|
||||
* to SaaS-synced spend; {@code POST /sync-now} forces an immediate usage sync (ops "reconcile now"
|
||||
* / test aid).
|
||||
*
|
||||
* <p>Admin-only, {@code @Profile("!saas")}, gated behind {@code
|
||||
* stirling.billing.account-link.enabled} — off → bean absent → 404.
|
||||
@@ -38,9 +41,17 @@ import lombok.extern.slf4j.Slf4j;
|
||||
public class AccountLinkController {
|
||||
|
||||
private final AccountLinkService service;
|
||||
private final LocalUsageService localUsageService;
|
||||
// Present only when metering is on (its own flag); absent → /sync-now reports 409.
|
||||
private final ObjectProvider<UsageSyncService> syncServiceProvider;
|
||||
|
||||
public AccountLinkController(AccountLinkService service) {
|
||||
public AccountLinkController(
|
||||
AccountLinkService service,
|
||||
LocalUsageService localUsageService,
|
||||
ObjectProvider<UsageSyncService> syncServiceProvider) {
|
||||
this.service = service;
|
||||
this.localUsageService = localUsageService;
|
||||
this.syncServiceProvider = syncServiceProvider;
|
||||
}
|
||||
|
||||
/** {@code supabaseJwt} is the admin's short-lived token the portal already holds. */
|
||||
@@ -85,4 +96,29 @@ public class AccountLinkController {
|
||||
service.unlink();
|
||||
return ResponseEntity.noContent().build();
|
||||
}
|
||||
|
||||
/**
|
||||
* Locally accrued usage not yet reported to SaaS — the portal adds it to the SaaS-synced spend
|
||||
* so "current usage" includes work done since the last daily sync.
|
||||
*/
|
||||
@GetMapping("/usage")
|
||||
public ResponseEntity<LocalUsageService.LocalUsage> usage() {
|
||||
return ResponseEntity.ok(localUsageService.currentPeriodUnsynced());
|
||||
}
|
||||
|
||||
/**
|
||||
* Forces an immediate usage sync to SaaS — the same work the daily scheduler does. An admin
|
||||
* "reconcile now" action (and a test aid so you don't wait on the scheduler). Idempotent:
|
||||
* re-reports the current cumulative, so a repeat trigger bills nothing. {@code 204} once run;
|
||||
* {@code 409} when metering is off (the sync bean is absent).
|
||||
*/
|
||||
@PostMapping("/sync-now")
|
||||
public ResponseEntity<Void> syncNow() {
|
||||
UsageSyncService sync = syncServiceProvider.getIfAvailable();
|
||||
if (sync == null) {
|
||||
return ResponseEntity.status(HttpStatus.CONFLICT).build();
|
||||
}
|
||||
sync.syncNow();
|
||||
return ResponseEntity.noContent().build();
|
||||
}
|
||||
}
|
||||
|
||||
+37
@@ -1,5 +1,7 @@
|
||||
package stirling.software.proprietary.accountlink;
|
||||
|
||||
import java.time.Duration;
|
||||
|
||||
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
@@ -36,4 +38,39 @@ public class AccountLinkProperties {
|
||||
|
||||
/** Connect/read timeout for the outbound SaaS calls. */
|
||||
private int requestTimeoutSeconds = 10;
|
||||
|
||||
/** Phase 2 usage metering + daily sync. Keyed under {@code …account-link.metering.*}. */
|
||||
private final Metering metering = new Metering();
|
||||
|
||||
/**
|
||||
* Dedicated billing switch, <b>separate</b> from {@link #enabled} so the link plumbing can be
|
||||
* enabled (e.g. to test linking) without ever turning on real usage metering, reporting, or cap
|
||||
* enforcement. Both default off; metering requires the master flag too. This is the production
|
||||
* safety key — flipping it on is what actually bills linked instances.
|
||||
*/
|
||||
@Getter
|
||||
@Setter
|
||||
public static class Metering {
|
||||
|
||||
/** Turns on usage metering, the daily sync, and cap enforcement. Default off. */
|
||||
private boolean enabled = false;
|
||||
|
||||
/**
|
||||
* How often the instance syncs usage + refreshes entitlement (matches the licence sync).
|
||||
*/
|
||||
private int syncIntervalHours = 24;
|
||||
|
||||
/**
|
||||
* Block billable work after this many days with no successful sync (fail-open → closed).
|
||||
*/
|
||||
private int graceDays = 3;
|
||||
|
||||
/**
|
||||
* Dedup window for identical input sets. A re-run of the same inputs within this window is
|
||||
* treated as workflow chaining and not re-charged; the same inputs run again after it are
|
||||
* billed afresh. Mirrors the cloud's {@code payg.lineage.workflow-window} so the same op
|
||||
* costs the same on the instance and in the cloud.
|
||||
*/
|
||||
private Duration workflowWindow = Duration.ofMinutes(5);
|
||||
}
|
||||
}
|
||||
|
||||
+46
@@ -0,0 +1,46 @@
|
||||
package stirling.software.proprietary.accountlink;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
import jakarta.persistence.Column;
|
||||
import jakarta.persistence.Entity;
|
||||
import jakarta.persistence.Id;
|
||||
import jakarta.persistence.Table;
|
||||
|
||||
import lombok.Getter;
|
||||
import lombok.NoArgsConstructor;
|
||||
import lombok.Setter;
|
||||
|
||||
/**
|
||||
* Singleton row holding this instance's daily-sync bookkeeping (combined-billing "Mode A").
|
||||
*
|
||||
* <p>{@link #lastSyncSeq} is reserved (incremented + persisted) <em>before</em> each report so it
|
||||
* is strictly monotonic across restarts and partial failures — SaaS dedups replays by comparing it,
|
||||
* so a never-decreasing seq is the contract. {@link #lastSuccessAt} is the wall-clock of the last
|
||||
* sync SaaS accepted and drives the fail-open→closed grace window.
|
||||
*
|
||||
* <p>Auto-created by Hibernate ({@code ddl-auto=update}); written only by the flag-gated sync.
|
||||
*/
|
||||
@Entity
|
||||
@Table(name = "account_link_sync_state")
|
||||
@Getter
|
||||
@Setter
|
||||
@NoArgsConstructor
|
||||
public class AccountLinkSyncState {
|
||||
|
||||
/** One instance links to one team → one bookkeeping row. */
|
||||
public static final long SINGLETON_ID = 1L;
|
||||
|
||||
@Id private Long id;
|
||||
|
||||
// columnDefinition default keeps the ddl-auto ADD COLUMN safe on a populated external Postgres.
|
||||
@Column(
|
||||
name = "last_sync_seq",
|
||||
nullable = false,
|
||||
columnDefinition = "bigint not null default 0")
|
||||
private long lastSyncSeq;
|
||||
|
||||
/** Null until the first sync SaaS accepts. */
|
||||
@Column(name = "last_success_at")
|
||||
private LocalDateTime lastSuccessAt;
|
||||
}
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
package stirling.software.proprietary.accountlink;
|
||||
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
|
||||
/** Persistence for the singleton {@link AccountLinkSyncState} (combined-billing "Mode A"). */
|
||||
public interface AccountLinkSyncStateRepository extends JpaRepository<AccountLinkSyncState, Long> {}
|
||||
+28
-10
@@ -3,14 +3,26 @@ package stirling.software.proprietary.accountlink;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
|
||||
import stirling.software.common.service.InternalApiClient;
|
||||
import stirling.software.proprietary.billing.BillingCategory;
|
||||
import stirling.software.proprietary.billing.BillingCategoryClassifier;
|
||||
|
||||
/**
|
||||
* Classifies a request as <b>billable</b> (AI / automation) or free (a manual tool).
|
||||
* Buckets a request into a {@link BillingCategory} for the account-link gate + meter, using only
|
||||
* HTTP-level signals (no dependency on the saas module):
|
||||
*
|
||||
* <p>Mirrors the saas billing categorisation at a coarse level, without depending on the saas
|
||||
* module: billable = the AI surface ({@code /api/v1/ai/**}) or any request carrying the automation
|
||||
* marker header ({@link InternalApiClient#AUTOMATION_HEADER}, set on pipeline / workflow / policy
|
||||
* sub-steps). Everything else — interactive manual PDF tools — is always free.
|
||||
* <ul>
|
||||
* <li><b>AUTOMATION</b> — the automation marker header ({@link
|
||||
* InternalApiClient#AUTOMATION_HEADER}, set on pipeline / workflow / policy sub-steps);
|
||||
* <li><b>AI</b> — the AI surface ({@code /api/v1/ai/**});
|
||||
* <li><b>API</b> — an API-key authenticated tool call;
|
||||
* <li><b>BYPASSED</b> — a manual interactive tool call, never billed.
|
||||
* </ul>
|
||||
*
|
||||
* <p>Same precedence as the SaaS classifier (AUTOMATION → AI → API → BYPASSED) via the shared
|
||||
* {@link BillingCategoryClassifier}; the AI signal is resolved by path prefix rather than the
|
||||
* saas-only {@code @RequiresFeature} annotation. The {@code apiKey} signal is supplied by the
|
||||
* caller (resolved from the security context), so this class stays free of any security-type
|
||||
* dependency.
|
||||
*/
|
||||
public final class BillableOperationClassifier {
|
||||
|
||||
@@ -18,16 +30,22 @@ public final class BillableOperationClassifier {
|
||||
|
||||
private BillableOperationClassifier() {}
|
||||
|
||||
public static boolean isBillable(HttpServletRequest request) {
|
||||
if (request.getHeader(InternalApiClient.AUTOMATION_HEADER) != null) {
|
||||
return true;
|
||||
}
|
||||
/**
|
||||
* @param apiKey whether the request authenticated via an API key (an {@code
|
||||
* ApiKeyAuthenticationToken} principal), resolved by the caller from the security context.
|
||||
*/
|
||||
public static BillingCategory categorize(HttpServletRequest request, boolean apiKey) {
|
||||
boolean automation = request.getHeader(InternalApiClient.AUTOMATION_HEADER) != null;
|
||||
return BillingCategoryClassifier.classify(automation, isAiSurface(request), apiKey);
|
||||
}
|
||||
|
||||
private static boolean isAiSurface(HttpServletRequest request) {
|
||||
String uri = request.getRequestURI();
|
||||
if (uri == null) {
|
||||
return false;
|
||||
}
|
||||
// Prefix-match the AI surface (not a loose substring contains), stripping a deployment
|
||||
// context path so /<ctx>/api/v1/ai/** still classifies as billable.
|
||||
// context path so /<ctx>/api/v1/ai/** still classifies as AI.
|
||||
String ctx = request.getContextPath();
|
||||
String path =
|
||||
ctx != null && !ctx.isEmpty() && uri.startsWith(ctx)
|
||||
|
||||
+27
-24
@@ -12,18 +12,13 @@ import org.springframework.stereotype.Service;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
/**
|
||||
* Caches the linked team's entitlement so the request-time gate does not call the SaaS backend on
|
||||
* every billable request. Single-slot (one instance = one linked team), TTL-based.
|
||||
* Caches the linked team's entitlement so the request-time gate needn't call SaaS on every billable
|
||||
* request. Single-slot (one instance = one linked team), TTL-based.
|
||||
*
|
||||
* <p>Fail-open friendly for TRANSPORT failures: {@link #current()} returns the freshest snapshot it
|
||||
* has, even if a refresh just failed; it returns {@link Optional#empty()} only when nothing has
|
||||
* ever been fetched <i>and</i> the latest refresh failed (the gate treats empty as "unknown →
|
||||
* allow").
|
||||
*
|
||||
* <p>But an AUTHORITATIVE deny (revoked/invalid credential → {@link
|
||||
* AccountLinkClient.RevokedException}) is NOT a transport failure: the snapshot is replaced with a
|
||||
* {@link EntitlementState#REVOKED} blocked entitlement so the gate stops billable work immediately
|
||||
* rather than serving a stale entitled snapshot.
|
||||
* <p>A transport failure fails open — {@link #current()} keeps serving the freshest snapshot it has
|
||||
* and returns {@link Optional#empty()} ("unknown → allow") only when nothing was ever fetched. An
|
||||
* authoritative deny ({@link AccountLinkClient.RevokedException}) does not: the snapshot is
|
||||
* replaced with a {@link EntitlementState#REVOKED} entitlement so the gate blocks immediately.
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
@@ -63,9 +58,8 @@ public class EntitlementCache {
|
||||
* not linked or the SaaS side is unreachable and we have no prior snapshot.
|
||||
*/
|
||||
public Optional<InstanceEntitlement> current() {
|
||||
// Single-flight: when stale, exactly one thread refreshes (blocking on the SaaS
|
||||
// call) while concurrent callers serve the last snapshot — no thundering herd of
|
||||
// synchronous round-trips on the billable hot path. Safe because the gate fails open.
|
||||
// Single-flight: when stale, exactly one thread refreshes while concurrent callers serve
|
||||
// the last snapshot — no thundering herd of round-trips on the billable hot path.
|
||||
if (isStale(snapshot) && refreshing.compareAndSet(false, true)) {
|
||||
try {
|
||||
refresh();
|
||||
@@ -77,16 +71,15 @@ public class EntitlementCache {
|
||||
}
|
||||
|
||||
private boolean isStale(Snapshot snap) {
|
||||
// fetchedAt is the last *attempt* time (stamped on success AND failure), so a failed
|
||||
// fetch backs off for a full TTL instead of every billable request re-triggering a
|
||||
// blocking round-trip against a dead/slow SaaS endpoint.
|
||||
// fetchedAt is the last *attempt* time (stamped on success and failure), so a failed fetch
|
||||
// backs off a full TTL instead of every request re-triggering a round-trip to a dead SaaS.
|
||||
return Duration.between(snap.fetchedAt(), Instant.now()).compareTo(ttl) >= 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pulls a fresh snapshot. Keeps the previous entitlement on a TRANSPORT failure (fail-open) but
|
||||
* still stamps the attempt time so re-fetches throttle to the TTL; on an AUTHORITATIVE deny
|
||||
* (revoked credential) replaces it with a blocked snapshot so the gate stops billable work.
|
||||
* Pulls a fresh snapshot. On a transport failure keeps the previous entitlement but stamps the
|
||||
* attempt time so re-fetches throttle to the TTL; on an authoritative deny replaces it with a
|
||||
* blocked snapshot.
|
||||
*/
|
||||
void refresh() {
|
||||
Optional<DeviceCredential> cred = credentialStore.get();
|
||||
@@ -101,15 +94,15 @@ public class EntitlementCache {
|
||||
if (fresh != null) {
|
||||
snapshot = new Snapshot(fresh, Instant.now());
|
||||
} else {
|
||||
// Unreachable / server error: keep the last known entitlement (may be null) but
|
||||
// stamp the attempt so we don't hammer SaaS; the gate fails open in the meantime.
|
||||
// Unreachable / server error: keep the last known entitlement but stamp the attempt
|
||||
// so we don't hammer SaaS; the gate fails open meanwhile.
|
||||
log.debug(
|
||||
"Entitlement refresh failed; reusing last known snapshot, backing off a TTL");
|
||||
snapshot = new Snapshot(snapshot.entitlement(), Instant.now());
|
||||
}
|
||||
} catch (AccountLinkClient.RevokedException e) {
|
||||
// Authoritative deny — credential revoked/invalid. Do NOT fail open: block immediately
|
||||
// rather than serving the stale entitled snapshot until the next unlink.
|
||||
// Authoritative deny — block immediately rather than serving the stale entitled
|
||||
// snapshot.
|
||||
log.info(
|
||||
"Entitlement denied (HTTP {}); blocking billable work for the revoked credential",
|
||||
e.status());
|
||||
@@ -121,4 +114,14 @@ public class EntitlementCache {
|
||||
public void invalidate() {
|
||||
snapshot = new Snapshot(snapshot.entitlement(), Instant.EPOCH);
|
||||
}
|
||||
|
||||
/**
|
||||
* Seeds the cache with an entitlement obtained out-of-band (the sync reply carries a fresh
|
||||
* one), saving a redundant fetch. No-op on null.
|
||||
*/
|
||||
public void accept(InstanceEntitlement fresh) {
|
||||
if (fresh != null) {
|
||||
snapshot = new Snapshot(fresh, Instant.now());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+5
@@ -16,6 +16,11 @@ public record GateDecision(boolean allowed, Reason reason) {
|
||||
ENTITLED,
|
||||
/** Entitlement source unreachable — fail open, allow. */
|
||||
FAIL_OPEN,
|
||||
/**
|
||||
* Linked + metering, but SaaS has been unreachable past the grace window — block (the
|
||||
* fail-open backstop expired) so unbounded free/unbilled billable work can't continue.
|
||||
*/
|
||||
GRACE_EXPIRED,
|
||||
/** Not linked — block billable work; FE should prompt to link. */
|
||||
NOT_LINKED,
|
||||
/** Linked but over the limit / no subscription — block billable work. */
|
||||
|
||||
+38
-4
@@ -1,19 +1,53 @@
|
||||
package stirling.software.proprietary.accountlink;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
import stirling.software.proprietary.billing.UnitCalcPolicy;
|
||||
|
||||
/**
|
||||
* Cached, proprietary-local view of the SaaS {@code GET /api/v1/instance/entitlement} response —
|
||||
* just the fields the gate needs. Mirrors the saas {@code EntitlementResponse} shape but carries no
|
||||
* saas types.
|
||||
* Cached, proprietary-local view of the SaaS {@code GET /api/v1/instance/entitlement} response.
|
||||
* Mirrors the saas {@code EntitlementResponse} shape but carries no saas types.
|
||||
*
|
||||
* <p>The first five fields are what the <b>gate</b> enforces against; the trailing three are the
|
||||
* metering inputs (Phase 2) the instance uses to cost + bucket its own usage and reset its
|
||||
* per-period counters. The 5-arg constructor builds a gate-only view (metering fields null) for the
|
||||
* revoked sentinel and unit tests that don't exercise metering.
|
||||
*
|
||||
* @param subscribed team has an active subscription
|
||||
* @param freeRemainingUnits remaining free-pool units (>0 means free work is available)
|
||||
* @param periodSpendUnits paid units spent this period
|
||||
* @param periodCapUnits paid cap for the period; {@code null} = uncapped
|
||||
* @param state coarse state classification (see {@link EntitlementState})
|
||||
* @param unitCalcPolicy doc-unit pricing knobs for local unit computation; {@code null} if not
|
||||
* supplied (older SaaS / gate-only sentinel)
|
||||
* @param periodStart inclusive start of the current billing period; {@code null} if not supplied
|
||||
* @param periodEnd exclusive end of the current billing period; {@code null} if not supplied
|
||||
*/
|
||||
public record InstanceEntitlement(
|
||||
boolean subscribed,
|
||||
long freeRemainingUnits,
|
||||
long periodSpendUnits,
|
||||
Long periodCapUnits,
|
||||
EntitlementState state) {}
|
||||
EntitlementState state,
|
||||
UnitCalcPolicy unitCalcPolicy,
|
||||
LocalDateTime periodStart,
|
||||
LocalDateTime periodEnd) {
|
||||
|
||||
/** Gate-only view with no metering config — used by the revoked sentinel and gate tests. */
|
||||
public InstanceEntitlement(
|
||||
boolean subscribed,
|
||||
long freeRemainingUnits,
|
||||
long periodSpendUnits,
|
||||
Long periodCapUnits,
|
||||
EntitlementState state) {
|
||||
this(
|
||||
subscribed,
|
||||
freeRemainingUnits,
|
||||
periodSpendUnits,
|
||||
periodCapUnits,
|
||||
state,
|
||||
null,
|
||||
null,
|
||||
null);
|
||||
}
|
||||
}
|
||||
|
||||
+86
-16
@@ -1,5 +1,6 @@
|
||||
package stirling.software.proprietary.accountlink;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.Optional;
|
||||
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||
@@ -15,14 +16,17 @@ import org.springframework.stereotype.Service;
|
||||
* <li>Flag off → always allow (feature inert).
|
||||
* <li>Manual tool → always allow (manual tools are free, never metered).
|
||||
* <li>Billable + not linked → block with {@code NOT_LINKED} ("link to activate").
|
||||
* <li>Billable + linked + entitlement unknown (unreachable) → <b>fail open</b>, allow.
|
||||
* <li>Billable + linked + entitlement unknown (unreachable) → <b>fail open</b>, allow — unless
|
||||
* metering is on and SaaS has been unreachable past the grace window, then block with {@code
|
||||
* GRACE_EXPIRED} so the fail-open can't grant unbounded free/unbilled work forever.
|
||||
* <li>Billable + linked + entitled → allow.
|
||||
* <li>Billable + linked + credential revoked → block with {@code REVOKED}.
|
||||
* <li>Billable + linked + over limit → block with {@code OVER_LIMIT}.
|
||||
* </ol>
|
||||
*
|
||||
* <p>The decision logic is the pure static {@link #decide}; the Spring wrapper just supplies the
|
||||
* live flag / linked-state / entitlement. This is the unit-tested core.
|
||||
* <p>The decision logic is the pure static {@link #decide}; the Spring wrapper supplies the live
|
||||
* flag / linked-state / entitlement and computes whether the grace window has expired. This is the
|
||||
* unit-tested core.
|
||||
*/
|
||||
@Service
|
||||
@Profile("!saas")
|
||||
@@ -32,14 +36,20 @@ public class InstanceEntitlementGate {
|
||||
private final AccountLinkProperties properties;
|
||||
private final DeviceCredentialStore credentialStore;
|
||||
private final EntitlementCache entitlementCache;
|
||||
private final AccountLinkSyncStateRepository syncStateRepository;
|
||||
private final LocalUsageService localUsageService;
|
||||
|
||||
public InstanceEntitlementGate(
|
||||
AccountLinkProperties properties,
|
||||
DeviceCredentialStore credentialStore,
|
||||
EntitlementCache entitlementCache) {
|
||||
EntitlementCache entitlementCache,
|
||||
AccountLinkSyncStateRepository syncStateRepository,
|
||||
LocalUsageService localUsageService) {
|
||||
this.properties = properties;
|
||||
this.credentialStore = credentialStore;
|
||||
this.entitlementCache = entitlementCache;
|
||||
this.syncStateRepository = syncStateRepository;
|
||||
this.localUsageService = localUsageService;
|
||||
}
|
||||
|
||||
/** Evaluates the gate for a request, resolving live state from the store + cache. */
|
||||
@@ -53,18 +63,39 @@ public class InstanceEntitlementGate {
|
||||
boolean linked = credentialStore.isLinked();
|
||||
Optional<InstanceEntitlement> entitlement =
|
||||
linked ? entitlementCache.current() : Optional.empty();
|
||||
return decide(true, true, linked, entitlement);
|
||||
boolean graceExpired = linked && entitlement.isEmpty() && isGraceExpired();
|
||||
// Deplete the applicable ceiling — free grant (unsubscribed) or spend cap (capped
|
||||
// subscription) — by local usage not yet synced, so the gate stops in real time instead of
|
||||
// overshooting until the next sync. An uncapped subscription has no ceiling to deplete → 0.
|
||||
long pendingUnsynced =
|
||||
entitlement.map(InstanceEntitlementGate::depletesCeiling).orElse(false)
|
||||
? localUsageService.currentPeriodUnsynced().totalUnsyncedUnits()
|
||||
: 0L;
|
||||
return decide(true, true, linked, entitlement, graceExpired, pendingUnsynced);
|
||||
}
|
||||
|
||||
/** Whether local unsynced usage pushes against a real ceiling (free grant or a spend cap). */
|
||||
private static boolean depletesCeiling(InstanceEntitlement e) {
|
||||
return !e.subscribed() || e.periodCapUnits() != null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pure decision function — no Spring, no I/O. {@code entitlement} empty means "unknown"
|
||||
* (unreachable): when linked, that fails open.
|
||||
* (unreachable): when linked, that fails open unless {@code graceExpired} (the metering grace
|
||||
* window elapsed with no authoritative contact), in which case it blocks.
|
||||
*
|
||||
* @param pendingUnsyncedUnits billable units accrued locally since the last sync — depletes the
|
||||
* free grant (unsubscribed) or the spend cap (capped subscription) in real time so the gate
|
||||
* stops without waiting for the next sync (0 for uncapped-subscribed / unknown-entitlement
|
||||
* cases, where it has no effect).
|
||||
*/
|
||||
public static GateDecision decide(
|
||||
boolean flagEnabled,
|
||||
boolean billable,
|
||||
boolean linked,
|
||||
Optional<InstanceEntitlement> entitlement) {
|
||||
Optional<InstanceEntitlement> entitlement,
|
||||
boolean graceExpired,
|
||||
long pendingUnsyncedUnits) {
|
||||
if (!flagEnabled) {
|
||||
return GateDecision.allow(GateDecision.Reason.FLAG_OFF);
|
||||
}
|
||||
@@ -75,30 +106,69 @@ public class InstanceEntitlementGate {
|
||||
return GateDecision.block(GateDecision.Reason.NOT_LINKED);
|
||||
}
|
||||
if (entitlement.isEmpty()) {
|
||||
// Linked but entitlement source unreachable — never hard-block billable work on our
|
||||
// inability to reach billing.
|
||||
return GateDecision.allow(GateDecision.Reason.FAIL_OPEN);
|
||||
// Linked but entitlement unreachable: fail open, unless the grace window has expired
|
||||
// (so
|
||||
// the fail-open can't grant unbounded unbilled work forever).
|
||||
return graceExpired
|
||||
? GateDecision.block(GateDecision.Reason.GRACE_EXPIRED)
|
||||
: GateDecision.allow(GateDecision.Reason.FAIL_OPEN);
|
||||
}
|
||||
InstanceEntitlement e = entitlement.get();
|
||||
if (e.state() == EntitlementState.REVOKED) {
|
||||
// Credential revoked/invalid (authoritative deny) — block, distinct from over-limit.
|
||||
return GateDecision.block(GateDecision.Reason.REVOKED);
|
||||
}
|
||||
return entitled(e)
|
||||
return entitled(e, pendingUnsyncedUnits)
|
||||
? GateDecision.allow(GateDecision.Reason.ENTITLED)
|
||||
: GateDecision.block(GateDecision.Reason.OVER_LIMIT);
|
||||
}
|
||||
|
||||
/**
|
||||
* True when metering is on and it's been {@code graceDays} since the last authoritative contact
|
||||
* (last successful sync, or link time if never synced). {@code graceDays <= 0} or metering off
|
||||
* disables the backstop.
|
||||
*/
|
||||
private boolean isGraceExpired() {
|
||||
AccountLinkProperties.Metering metering = properties.getMetering();
|
||||
if (!metering.isEnabled() || metering.getGraceDays() <= 0) {
|
||||
return false;
|
||||
}
|
||||
LocalDateTime reference = lastAuthoritativeContact();
|
||||
if (reference == null) {
|
||||
return false; // can't determine elapsed time → fail open
|
||||
}
|
||||
return reference.plusDays(metering.getGraceDays()).isBefore(LocalDateTime.now());
|
||||
}
|
||||
|
||||
private LocalDateTime lastAuthoritativeContact() {
|
||||
LocalDateTime lastSuccess =
|
||||
syncStateRepository
|
||||
.findById(AccountLinkSyncState.SINGLETON_ID)
|
||||
.map(AccountLinkSyncState::getLastSuccessAt)
|
||||
.orElse(null);
|
||||
if (lastSuccess != null) {
|
||||
return lastSuccess;
|
||||
}
|
||||
return credentialStore.get().map(DeviceCredential::getLinkedAt).orElse(null);
|
||||
}
|
||||
|
||||
/** True when the snapshot permits billable work (subscribed, free pool left, or within cap). */
|
||||
private static boolean entitled(InstanceEntitlement e) {
|
||||
private static boolean entitled(InstanceEntitlement e, long pendingUnsyncedUnits) {
|
||||
if (e.state() == EntitlementState.OVER_LIMIT || e.state() == EntitlementState.REVOKED) {
|
||||
return false;
|
||||
}
|
||||
if (e.subscribed()) {
|
||||
// Subscribed: allowed unless a period cap is set and exceeded.
|
||||
return e.periodCapUnits() == null || e.periodSpendUnits() < e.periodCapUnits();
|
||||
if (e.periodCapUnits() == null) {
|
||||
return true; // uncapped subscription
|
||||
}
|
||||
// Project the cap the way the grant is projected: synced paid spend plus the paid part
|
||||
// of local usage not yet synced (free grant is consumed first, so only the excess
|
||||
// bills) — stops at the cap in real time instead of overshooting until the next sync.
|
||||
long pendingPaid = Math.max(0, pendingUnsyncedUnits - e.freeRemainingUnits());
|
||||
return e.periodSpendUnits() + pendingPaid < e.periodCapUnits();
|
||||
}
|
||||
// Unsubscribed: only the free pool covers billable work.
|
||||
return e.freeRemainingUnits() > 0;
|
||||
// Unsubscribed: free pool must cover SaaS-charged usage (in freeRemainingUnits) plus local
|
||||
// usage not yet synced — deplete by the pending delta so we stop at the grant in real time.
|
||||
return e.freeRemainingUnits() - pendingUnsyncedUnits > 0;
|
||||
}
|
||||
}
|
||||
|
||||
+190
-12
@@ -1,27 +1,51 @@
|
||||
package stirling.software.proprietary.accountlink;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.security.DigestOutputStream;
|
||||
import java.security.MessageDigest;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
|
||||
import org.springframework.beans.factory.ObjectProvider;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||
import org.springframework.context.annotation.Profile;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.security.core.context.SecurityContextHolder;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
import org.springframework.web.multipart.MultipartHttpServletRequest;
|
||||
import org.springframework.web.servlet.HandlerInterceptor;
|
||||
import org.springframework.web.util.WebUtils;
|
||||
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.common.util.TempFile;
|
||||
import stirling.software.common.util.TempFileManager;
|
||||
import stirling.software.jpdfium.PdfDocument;
|
||||
import stirling.software.proprietary.billing.BillingCategory;
|
||||
import stirling.software.proprietary.billing.ContentHasher;
|
||||
import stirling.software.proprietary.billing.DocumentUnitCalculator;
|
||||
import stirling.software.proprietary.billing.DocumentUnitCalculator.FileSize;
|
||||
import stirling.software.proprietary.billing.UnitCalcPolicy;
|
||||
import stirling.software.proprietary.security.model.ApiKeyAuthenticationToken;
|
||||
|
||||
/**
|
||||
* Request-time gate for combined-billing "Mode A". Runs before billable (AI / automation) work and
|
||||
* blocks it when the instance is unlinked or over its limit; manual tools pass straight through.
|
||||
* Request-time gate + meter for combined-billing "Mode A". {@code preHandle} blocks billable (API /
|
||||
* AI / automation) work when the instance is unlinked or over its limit; manual tools pass through.
|
||||
* {@code afterCompletion} meters a successful billable op into the per-period cumulative counter.
|
||||
*
|
||||
* <p>Blocking responds {@code 402 Payment Required} with a small machine-readable body — {@code
|
||||
* {"error":"ACCOUNT_LINK_REQUIRED","reason":"NOT_LINKED"}} — that the FE maps to a "link to
|
||||
* activate" prompt (the same DownstreamEntitlementError-style envelope already used for saas limit
|
||||
* responses). Fail-open and flag-off both let the request continue.
|
||||
*
|
||||
* <p>Gated + {@code @Profile("!saas")}; when the flag is off the bean is absent and the {@link
|
||||
* AccountLinkWebMvcConfig} never registers it, so there is no per-request cost.
|
||||
* <p>Blocking responds {@code 402} with a machine-readable body the FE maps to a "link to activate"
|
||||
* prompt; fail-open and flag-off both let the request continue. Metering is separately gated behind
|
||||
* {@code …metering.enabled} via {@link ObjectProvider} — switch off means the {@link
|
||||
* UsageMeterService} bean is absent and nothing accrues, while the gate still works.
|
||||
*/
|
||||
@Slf4j
|
||||
@Component
|
||||
@@ -29,10 +53,23 @@ import lombok.extern.slf4j.Slf4j;
|
||||
@ConditionalOnProperty(name = "stirling.billing.account-link.enabled", havingValue = "true")
|
||||
public class InstanceEntitlementInterceptor implements HandlerInterceptor {
|
||||
|
||||
private final InstanceEntitlementGate gate;
|
||||
private static final String ATTR_CATEGORY =
|
||||
InstanceEntitlementInterceptor.class.getName() + ".category";
|
||||
|
||||
public InstanceEntitlementInterceptor(InstanceEntitlementGate gate) {
|
||||
private final InstanceEntitlementGate gate;
|
||||
private final EntitlementCache entitlementCache;
|
||||
private final ObjectProvider<UsageMeterService> meterProvider;
|
||||
private final TempFileManager tempFileManager;
|
||||
|
||||
public InstanceEntitlementInterceptor(
|
||||
InstanceEntitlementGate gate,
|
||||
EntitlementCache entitlementCache,
|
||||
ObjectProvider<UsageMeterService> meterProvider,
|
||||
TempFileManager tempFileManager) {
|
||||
this.gate = gate;
|
||||
this.entitlementCache = entitlementCache;
|
||||
this.meterProvider = meterProvider;
|
||||
this.tempFileManager = tempFileManager;
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -41,7 +78,13 @@ public class InstanceEntitlementInterceptor implements HandlerInterceptor {
|
||||
throws Exception {
|
||||
GateDecision decision;
|
||||
try {
|
||||
decision = gate.evaluate(BillableOperationClassifier.isBillable(request));
|
||||
// API-key tool calls are billable (category API); stash the category for the meter.
|
||||
boolean apiKey =
|
||||
SecurityContextHolder.getContext().getAuthentication()
|
||||
instanceof ApiKeyAuthenticationToken;
|
||||
BillingCategory category = BillableOperationClassifier.categorize(request, apiKey);
|
||||
request.setAttribute(ATTR_CATEGORY, category);
|
||||
decision = gate.evaluate(category != BillingCategory.BYPASSED);
|
||||
} catch (RuntimeException e) {
|
||||
// Fail open: an inability to resolve entitlement (e.g. a DB or SaaS blip) must never
|
||||
// turn into a hard block on billable work.
|
||||
@@ -62,4 +105,139 @@ public class InstanceEntitlementInterceptor implements HandlerInterceptor {
|
||||
+ "\"}");
|
||||
return false;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void afterCompletion(
|
||||
HttpServletRequest request,
|
||||
HttpServletResponse response,
|
||||
Object handler,
|
||||
Exception ex) {
|
||||
// Meter successful billable ops only.
|
||||
if (ex != null || response.getStatus() >= 400) {
|
||||
return;
|
||||
}
|
||||
UsageMeterService meter = meterProvider.getIfAvailable();
|
||||
if (meter == null) {
|
||||
return; // metering switch off
|
||||
}
|
||||
if (!(request.getAttribute(ATTR_CATEGORY) instanceof BillingCategory category)
|
||||
|| category == BillingCategory.BYPASSED) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
InstanceEntitlement ent = entitlementCache.current().orElse(null);
|
||||
if (ent == null || ent.unitCalcPolicy() == null || ent.periodStart() == null) {
|
||||
// Not yet synced (no policy/period) — can't compute units; skip until next sync.
|
||||
return;
|
||||
}
|
||||
meterRequest(request, category, ent, meter);
|
||||
} catch (RuntimeException e) {
|
||||
// Metering must never affect the response that already completed.
|
||||
log.debug("Usage metering failed for {}", request.getRequestURI(), e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Computes doc-units (page + byte axes) and the input-set signature, then accrues. The instance
|
||||
* is authoritative for units (SaaS bills the delta and never sees the file), so a page-heavy
|
||||
* but small PDF must be page-counted or it under-bills. A fileless op has no input identity —
|
||||
* null signature (no dedup), billed the 1-unit floor each time.
|
||||
*/
|
||||
private void meterRequest(
|
||||
HttpServletRequest request,
|
||||
BillingCategory category,
|
||||
InstanceEntitlement ent,
|
||||
UsageMeterService meter) {
|
||||
UnitCalcPolicy policy = ent.unitCalcPolicy();
|
||||
MultipartHttpServletRequest mreq =
|
||||
WebUtils.getNativeRequest(request, MultipartHttpServletRequest.class);
|
||||
if (mreq == null) {
|
||||
long fileless = DocumentUnitCalculator.unitsForFile(0, 0, policy);
|
||||
meter.accrue(ent.periodStart(), category, fileless, null);
|
||||
return;
|
||||
}
|
||||
List<TempFile> temps = new ArrayList<>();
|
||||
try {
|
||||
List<FileSize> sizes = new ArrayList<>();
|
||||
List<String> hashes = new ArrayList<>();
|
||||
int fileCount = 0;
|
||||
for (List<MultipartFile> files : mreq.getMultiFileMap().values()) {
|
||||
for (MultipartFile f : files) {
|
||||
fileCount++;
|
||||
try {
|
||||
TempFile temp = tempFileManager.createManagedTempFile(".bin");
|
||||
temps.add(temp);
|
||||
// Hash in the same pass that writes the temp file — one read of the upload,
|
||||
// not a second full read just to fingerprint it.
|
||||
MessageDigest digest = ContentHasher.newSha256();
|
||||
try (InputStream in = f.getInputStream();
|
||||
DigestOutputStream out =
|
||||
new DigestOutputStream(
|
||||
Files.newOutputStream(temp.getPath()), digest)) {
|
||||
in.transferTo(out);
|
||||
}
|
||||
sizes.add(new FileSize(pageCount(temp.getPath(), f), f.getSize()));
|
||||
hashes.add(ContentHasher.toHex(digest.digest()));
|
||||
} catch (IOException | RuntimeException perFile) {
|
||||
// Couldn't materialise/hash this input — bill on bytes only and, by leaving
|
||||
// it out of `hashes`, drop dedup for the whole op rather than risk a
|
||||
// mismatch.
|
||||
log.debug(
|
||||
"Metering materialise/hash failed for {}; bytes-only",
|
||||
f.getOriginalFilename());
|
||||
sizes.add(new FileSize(0, f.getSize()));
|
||||
}
|
||||
}
|
||||
}
|
||||
long units =
|
||||
sizes.isEmpty()
|
||||
? DocumentUnitCalculator.unitsForFile(0, 0, policy)
|
||||
: DocumentUnitCalculator.unitsForGroup(sizes, policy);
|
||||
// Only dedup when every input hashed; a partial signature could collide with a
|
||||
// different input set, so fall back to no-dedup (bill it) if any file failed.
|
||||
String opSignature =
|
||||
fileCount > 0 && hashes.size() == fileCount ? opSignature(hashes) : null;
|
||||
meter.accrue(ent.periodStart(), category, units, opSignature);
|
||||
} finally {
|
||||
for (TempFile temp : temps) {
|
||||
try {
|
||||
temp.close();
|
||||
} catch (RuntimeException cleanup) {
|
||||
log.debug("Temp file cleanup failed: {}", cleanup.getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Page count via jpdfium (parser-identical to SaaS); 0 for non-PDF / unreadable inputs. */
|
||||
private static int pageCount(Path path, MultipartFile file) {
|
||||
if (!isPdf(file)) {
|
||||
return 0;
|
||||
}
|
||||
try (PdfDocument doc = PdfDocument.open(path)) {
|
||||
return doc.pageCount();
|
||||
} catch (RuntimeException e) {
|
||||
// Malformed / encrypted → byte axis only, matching the SaaS classifier.
|
||||
log.debug(
|
||||
"Page count unavailable for {}; metering on bytes only",
|
||||
file.getOriginalFilename());
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
/** Order-independent signature of the input set: sorted per-file hashes, hashed together. */
|
||||
private static String opSignature(List<String> hashes) {
|
||||
List<String> sorted = new ArrayList<>(hashes);
|
||||
Collections.sort(sorted);
|
||||
return ContentHasher.sha256(String.join("\n", sorted).getBytes(StandardCharsets.UTF_8));
|
||||
}
|
||||
|
||||
private static boolean isPdf(MultipartFile file) {
|
||||
String contentType = file.getContentType();
|
||||
if (contentType != null && contentType.toLowerCase().contains("pdf")) {
|
||||
return true;
|
||||
}
|
||||
String name = file.getOriginalFilename();
|
||||
return name != null && name.toLowerCase().endsWith(".pdf");
|
||||
}
|
||||
}
|
||||
|
||||
+59
@@ -0,0 +1,59 @@
|
||||
package stirling.software.proprietary.accountlink;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.EnumMap;
|
||||
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||
import org.springframework.context.annotation.Profile;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import stirling.software.proprietary.billing.BillingCategory;
|
||||
|
||||
/**
|
||||
* Reads this instance's locally accrued but not-yet-synced usage for the current period. The portal
|
||||
* adds this on top of SaaS-synced spend so "current usage" reflects work done since the last sync.
|
||||
*
|
||||
* <p>Unsynced per category = {@code cumulativeUnits − lastSyncedUnits} (floored at 0), scoped to
|
||||
* the current period so prior-period leftovers don't inflate it. Zeros when the period is unknown
|
||||
* or metering is off.
|
||||
*/
|
||||
@Service
|
||||
@Profile("!saas")
|
||||
@ConditionalOnProperty(name = "stirling.billing.account-link.enabled", havingValue = "true")
|
||||
public class LocalUsageService {
|
||||
|
||||
private final UsageCounterRepository counters;
|
||||
private final EntitlementCache entitlementCache;
|
||||
|
||||
public LocalUsageService(UsageCounterRepository counters, EntitlementCache entitlementCache) {
|
||||
this.counters = counters;
|
||||
this.entitlementCache = entitlementCache;
|
||||
}
|
||||
|
||||
/** Per-category unsynced units for the current period; {@code periodStart} null = unknown. */
|
||||
public record LocalUsage(
|
||||
LocalDateTime periodStart,
|
||||
long apiUnsyncedUnits,
|
||||
long aiUnsyncedUnits,
|
||||
long automationUnsyncedUnits,
|
||||
long totalUnsyncedUnits) {}
|
||||
|
||||
public LocalUsage currentPeriodUnsynced() {
|
||||
LocalDateTime period =
|
||||
entitlementCache.current().map(InstanceEntitlement::periodStart).orElse(null);
|
||||
if (period == null) {
|
||||
return new LocalUsage(null, 0, 0, 0, 0);
|
||||
}
|
||||
EnumMap<BillingCategory, Long> unsynced = new EnumMap<>(BillingCategory.class);
|
||||
for (UsageCounter c : counters.findByPeriodStart(period)) {
|
||||
BillingCategory cat = c.billingCategory();
|
||||
if (cat != null && cat != BillingCategory.BYPASSED) {
|
||||
unsynced.merge(cat, c.unsyncedUnits(), Long::sum);
|
||||
}
|
||||
}
|
||||
long api = unsynced.getOrDefault(BillingCategory.API, 0L);
|
||||
long ai = unsynced.getOrDefault(BillingCategory.AI, 0L);
|
||||
long automation = unsynced.getOrDefault(BillingCategory.AUTOMATION, 0L);
|
||||
return new LocalUsage(period, api, ai, automation, api + ai + automation);
|
||||
}
|
||||
}
|
||||
+73
@@ -0,0 +1,73 @@
|
||||
package stirling.software.proprietary.accountlink;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
import jakarta.persistence.Column;
|
||||
import jakarta.persistence.Entity;
|
||||
import jakarta.persistence.GeneratedValue;
|
||||
import jakarta.persistence.GenerationType;
|
||||
import jakarta.persistence.Id;
|
||||
import jakarta.persistence.Table;
|
||||
import jakarta.persistence.UniqueConstraint;
|
||||
|
||||
import lombok.AccessLevel;
|
||||
import lombok.Getter;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
/**
|
||||
* The last time the instance metered a given input set this period — the local equivalent of the
|
||||
* cloud's lineage join (combined-billing "Mode A"). The meter dedups on a rolling <b>workflow
|
||||
* window</b>: an identical input set re-submitted within the window (see {@link
|
||||
* AccountLinkProperties.Metering}) is treated as workflow chaining and not re-charged, while the
|
||||
* same inputs run again after the window are billed afresh — matching the cloud's 5-minute open-job
|
||||
* window so the same operation costs the same on the instance and in the cloud.
|
||||
*
|
||||
* <p>{@code lastMeteredAt} is refreshed on every sighting (the window slides, as recording a cloud
|
||||
* artifact touches its job). One row per {@code (period, signature)}; the unique constraint also
|
||||
* makes the first-sighting insert an atomic claim under concurrency.
|
||||
*
|
||||
* <p>Auto-created by Hibernate ({@code ddl-auto=update}); written only by the flag-gated meter.
|
||||
*/
|
||||
@Entity
|
||||
@Table(
|
||||
name = "account_link_metered_signature",
|
||||
uniqueConstraints =
|
||||
@UniqueConstraint(
|
||||
name = "uk_account_link_metered_signature",
|
||||
columnNames = {"period_start", "signature"}))
|
||||
@Getter
|
||||
@NoArgsConstructor(access = AccessLevel.PROTECTED)
|
||||
public class MeteredInputSignature {
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
private Long id;
|
||||
|
||||
@Column(name = "period_start", nullable = false)
|
||||
private LocalDateTime periodStart;
|
||||
|
||||
/** SHA-256 hex of the op's input set (64 chars); the dedup key within a period. */
|
||||
@Column(name = "signature", nullable = false, length = 64)
|
||||
private String signature;
|
||||
|
||||
@Column(name = "created_at", nullable = false)
|
||||
private LocalDateTime createdAt;
|
||||
|
||||
/**
|
||||
* When this input set was last metered — the anchor the workflow-window dedup compares against.
|
||||
*/
|
||||
@Column(name = "last_metered_at")
|
||||
private LocalDateTime lastMeteredAt;
|
||||
|
||||
public MeteredInputSignature(LocalDateTime periodStart, String signature, LocalDateTime at) {
|
||||
this.periodStart = periodStart;
|
||||
this.signature = signature;
|
||||
this.createdAt = at;
|
||||
this.lastMeteredAt = at;
|
||||
}
|
||||
|
||||
/** Slides the window forward — the input set was seen again. */
|
||||
public void touch(LocalDateTime at) {
|
||||
this.lastMeteredAt = at;
|
||||
}
|
||||
}
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
package stirling.software.proprietary.accountlink;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.Optional;
|
||||
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
|
||||
/** Persistence for the per-period metered input-set signatures (combined-billing "Mode A"). */
|
||||
public interface MeteredInputSignatureRepository
|
||||
extends JpaRepository<MeteredInputSignature, Long> {
|
||||
|
||||
/** The existing row for a seen input set, so the meter can apply the workflow-window check. */
|
||||
Optional<MeteredInputSignature> findByPeriodStartAndSignature(
|
||||
LocalDateTime periodStart, String signature);
|
||||
}
|
||||
+105
@@ -0,0 +1,105 @@
|
||||
package stirling.software.proprietary.accountlink;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
import jakarta.persistence.Column;
|
||||
import jakarta.persistence.Entity;
|
||||
import jakarta.persistence.GeneratedValue;
|
||||
import jakarta.persistence.GenerationType;
|
||||
import jakarta.persistence.Id;
|
||||
import jakarta.persistence.Table;
|
||||
import jakarta.persistence.UniqueConstraint;
|
||||
|
||||
import lombok.AccessLevel;
|
||||
import lombok.Getter;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
import stirling.software.proprietary.billing.BillingCategory;
|
||||
|
||||
/**
|
||||
* Durable per-(billing period, category) cumulative usage counter for combined-billing "Mode A".
|
||||
* Each successful billable op increments its row; the daily sync reports the cumulative totals and
|
||||
* SaaS bills the delta since the last sync. The cumulative model is idempotent (a resend bills
|
||||
* nothing) and tamper-evident (a counter that drops is a signal). One row per {@code (period_start,
|
||||
* category)}, auto-created by Hibernate; only the flag-gated {@link UsageMeterService} writes it.
|
||||
*/
|
||||
@Entity
|
||||
@Table(
|
||||
name = "account_link_usage_counter",
|
||||
uniqueConstraints =
|
||||
@UniqueConstraint(
|
||||
name = "uk_usage_counter_period_category",
|
||||
columnNames = {"period_start", "category"}))
|
||||
@Getter
|
||||
@NoArgsConstructor(access = AccessLevel.PROTECTED)
|
||||
public class UsageCounter {
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
private Long id;
|
||||
|
||||
/**
|
||||
* Inclusive start of the billing period this counter belongs to (from the entitlement sync).
|
||||
*/
|
||||
@Column(name = "period_start", nullable = false)
|
||||
private LocalDateTime periodStart;
|
||||
|
||||
/** {@code BillingCategory} name — API / AI / AUTOMATION (never BYPASSED). */
|
||||
@Column(name = "category", nullable = false, length = 32)
|
||||
private String category;
|
||||
|
||||
/** Running total of metered units in this period+category. */
|
||||
@Column(name = "cumulative_units", nullable = false)
|
||||
private long cumulativeUnits;
|
||||
|
||||
/**
|
||||
* {@link #cumulativeUnits} as of the last sync SaaS accepted; the difference is the unreported
|
||||
* usage the portal shows on top of SaaS-synced spend. The {@code columnDefinition} default
|
||||
* keeps the {@code ddl-auto=update} ADD COLUMN safe against a table an earlier build already
|
||||
* populated (NOT NULL with no default would fail the ALTER).
|
||||
*/
|
||||
@Column(
|
||||
name = "last_synced_units",
|
||||
nullable = false,
|
||||
columnDefinition = "bigint not null default 0")
|
||||
private long lastSyncedUnits;
|
||||
|
||||
@Column(name = "updated_at", nullable = false)
|
||||
private LocalDateTime updatedAt;
|
||||
|
||||
/** Fresh-accrual row: nothing synced yet. */
|
||||
public UsageCounter(
|
||||
LocalDateTime periodStart,
|
||||
String category,
|
||||
long cumulativeUnits,
|
||||
LocalDateTime updatedAt) {
|
||||
this(periodStart, category, cumulativeUnits, 0L, updatedAt);
|
||||
}
|
||||
|
||||
public UsageCounter(
|
||||
LocalDateTime periodStart,
|
||||
String category,
|
||||
long cumulativeUnits,
|
||||
long lastSyncedUnits,
|
||||
LocalDateTime updatedAt) {
|
||||
this.periodStart = periodStart;
|
||||
this.category = category;
|
||||
this.cumulativeUnits = cumulativeUnits;
|
||||
this.lastSyncedUnits = lastSyncedUnits;
|
||||
this.updatedAt = updatedAt;
|
||||
}
|
||||
|
||||
/** This row's category as the enum, or {@code null} for an unrecognised stored value. */
|
||||
public BillingCategory billingCategory() {
|
||||
try {
|
||||
return BillingCategory.valueOf(category);
|
||||
} catch (IllegalArgumentException unknown) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Units accrued but not yet accepted by SaaS (floored at 0). */
|
||||
public long unsyncedUnits() {
|
||||
return Math.max(0, cumulativeUnits - lastSyncedUnits);
|
||||
}
|
||||
}
|
||||
+57
@@ -0,0 +1,57 @@
|
||||
package stirling.software.proprietary.accountlink;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.data.jpa.repository.Modifying;
|
||||
import org.springframework.data.jpa.repository.Query;
|
||||
import org.springframework.data.repository.query.Param;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
/** Persistence for the per-period/per-category usage counters (combined-billing "Mode A"). */
|
||||
public interface UsageCounterRepository extends JpaRepository<UsageCounter, Long> {
|
||||
|
||||
/**
|
||||
* Atomically adds {@code delta} to an existing counter row. Returns the number of rows updated
|
||||
* (0 when the row doesn't exist yet — the caller then inserts). Doing the add in SQL avoids a
|
||||
* read-modify-write race between concurrent billable requests.
|
||||
*/
|
||||
@Modifying
|
||||
@Transactional
|
||||
@Query(
|
||||
"UPDATE UsageCounter c SET c.cumulativeUnits = c.cumulativeUnits + :delta,"
|
||||
+ " c.updatedAt = :now"
|
||||
+ " WHERE c.periodStart = :periodStart AND c.category = :category")
|
||||
int increment(
|
||||
@Param("periodStart") LocalDateTime periodStart,
|
||||
@Param("category") String category,
|
||||
@Param("delta") long delta,
|
||||
@Param("now") LocalDateTime now);
|
||||
|
||||
/** All counters for a period — the daily sync reads these to report cumulative totals. */
|
||||
List<UsageCounter> findByPeriodStart(LocalDateTime periodStart);
|
||||
|
||||
/**
|
||||
* Periods (oldest first) that still hold usage not yet accepted by SaaS. The sync reports each
|
||||
* so end-of-period usage isn't stranded when the billing period rolls over between syncs.
|
||||
*/
|
||||
@Query(
|
||||
"SELECT DISTINCT c.periodStart FROM UsageCounter c"
|
||||
+ " WHERE c.cumulativeUnits > c.lastSyncedUnits ORDER BY c.periodStart")
|
||||
List<LocalDateTime> findPeriodsWithUnsyncedUsage();
|
||||
|
||||
/**
|
||||
* Marks a counter synced up to {@code syncedUnits} (the cumulative value just accepted by
|
||||
* SaaS), not the live cumulative — concurrent accruals during the sync stay correctly unsynced.
|
||||
*/
|
||||
@Modifying
|
||||
@Transactional
|
||||
@Query(
|
||||
"UPDATE UsageCounter c SET c.lastSyncedUnits = :syncedUnits"
|
||||
+ " WHERE c.periodStart = :periodStart AND c.category = :category")
|
||||
int markSynced(
|
||||
@Param("periodStart") LocalDateTime periodStart,
|
||||
@Param("category") String category,
|
||||
@Param("syncedUnits") long syncedUnits);
|
||||
}
|
||||
+115
@@ -0,0 +1,115 @@
|
||||
package stirling.software.proprietary.accountlink;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||
import org.springframework.context.annotation.Profile;
|
||||
import org.springframework.dao.DataIntegrityViolationException;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.proprietary.billing.BillingCategory;
|
||||
|
||||
/**
|
||||
* Accrues metered usage into the durable per-(period, category) {@link UsageCounter}; the daily
|
||||
* sync later reports the cumulative totals to SaaS.
|
||||
*
|
||||
* <p>Workflow-window dedup: an identical input set re-submitted within {@code metering.workflow-
|
||||
* window} is treated as chaining and not re-charged; the same inputs run again after the window are
|
||||
* billed afresh — matching the cloud's open-job lineage window so the same op costs the same on the
|
||||
* instance and in the cloud. Fileless ops pass a null signature and always accrue. {@link #accrue}
|
||||
* is best-effort: callers need not handle persistence errors.
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
@Profile("!saas")
|
||||
@ConditionalOnProperty(
|
||||
name = "stirling.billing.account-link.metering.enabled",
|
||||
havingValue = "true")
|
||||
public class UsageMeterService {
|
||||
|
||||
private final UsageCounterRepository repo;
|
||||
private final MeteredInputSignatureRepository signatureRepo;
|
||||
private final Duration workflowWindow;
|
||||
|
||||
public UsageMeterService(
|
||||
UsageCounterRepository repo,
|
||||
MeteredInputSignatureRepository signatureRepo,
|
||||
AccountLinkProperties properties) {
|
||||
this.repo = repo;
|
||||
this.signatureRepo = signatureRepo;
|
||||
this.workflowWindow = properties.getMetering().getWorkflowWindow();
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds {@code units} to the {@code (periodStart, category)} counter (creating the row on first
|
||||
* use), unless {@code opSignature} was already metered this period. No-ops for non-billable
|
||||
* categories, non-positive units, or a missing period.
|
||||
*/
|
||||
public void accrue(
|
||||
LocalDateTime periodStart, BillingCategory category, long units, String opSignature) {
|
||||
if (periodStart == null
|
||||
|| category == null
|
||||
|| category == BillingCategory.BYPASSED
|
||||
|| units <= 0) {
|
||||
return;
|
||||
}
|
||||
if (opSignature != null && !shouldCharge(periodStart, opSignature)) {
|
||||
return; // identical inputs seen within the workflow window — chaining, already billed
|
||||
}
|
||||
incrementOrInsert(periodStart, category.name(), units);
|
||||
}
|
||||
|
||||
/**
|
||||
* True when this input set should be charged: unseen this period, or last seen outside the
|
||||
* workflow window. Records a first sighting (an atomic insert-as-claim under concurrency) and
|
||||
* slides the window on a repeat. Fails toward charging so a store hiccup never drops a charge.
|
||||
*/
|
||||
private boolean shouldCharge(LocalDateTime periodStart, String opSignature) {
|
||||
LocalDateTime now = LocalDateTime.now();
|
||||
MeteredInputSignature seen =
|
||||
signatureRepo.findByPeriodStartAndSignature(periodStart, opSignature).orElse(null);
|
||||
if (seen == null) {
|
||||
try {
|
||||
signatureRepo.saveAndFlush(
|
||||
new MeteredInputSignature(periodStart, opSignature, now));
|
||||
return true; // first sighting this period
|
||||
} catch (DataIntegrityViolationException raced) {
|
||||
return false; // a concurrent op just claimed it — within window → chaining
|
||||
} catch (RuntimeException e) {
|
||||
log.debug("Signature claim failed for {}: {}", periodStart, e.getMessage());
|
||||
return true;
|
||||
}
|
||||
}
|
||||
LocalDateTime last = seen.getLastMeteredAt() != null ? seen.getLastMeteredAt() : now;
|
||||
boolean withinWindow = last.isAfter(now.minus(workflowWindow));
|
||||
try {
|
||||
seen.touch(now);
|
||||
signatureRepo.save(seen);
|
||||
} catch (RuntimeException e) {
|
||||
log.debug("Signature touch failed for {}: {}", periodStart, e.getMessage());
|
||||
}
|
||||
return !withinWindow;
|
||||
}
|
||||
|
||||
private void incrementOrInsert(LocalDateTime periodStart, String category, long units) {
|
||||
LocalDateTime now = LocalDateTime.now();
|
||||
try {
|
||||
if (repo.increment(periodStart, category, units, now) > 0) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
repo.saveAndFlush(new UsageCounter(periodStart, category, units, now));
|
||||
} catch (DataIntegrityViolationException raceLostInsert) {
|
||||
// A concurrent request inserted the row first — increment the now-existing row.
|
||||
repo.increment(periodStart, category, units, now);
|
||||
}
|
||||
} catch (RuntimeException e) {
|
||||
// Metering must never break the request it rode in on; a lost accrual self-heals on the
|
||||
// next increment and the daily sync reports the cumulative total either way.
|
||||
log.debug("Usage accrual failed for {}/{}: {}", periodStart, category, e.getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
+189
@@ -0,0 +1,189 @@
|
||||
package stirling.software.proprietary.accountlink;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.EnumMap;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||
import org.springframework.context.annotation.Profile;
|
||||
import org.springframework.scheduling.annotation.SchedulingConfigurer;
|
||||
import org.springframework.scheduling.config.FixedDelayTask;
|
||||
import org.springframework.scheduling.config.ScheduledTaskRegistrar;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.proprietary.billing.BillingCategory;
|
||||
|
||||
/**
|
||||
* Daily usage sender for combined-billing "Mode A". Reports each period's cumulative per-category
|
||||
* usage to SaaS, which bills the delta against its own last-seen totals.
|
||||
*
|
||||
* <p>Resilience: the sync seq is persisted before the report so it never regresses across
|
||||
* restarts/failures; a transport failure leaves the {@code lastSyncedUnits} markers untouched so
|
||||
* usage rolls into the next sync; and reporting the same cumulative twice bills nothing. All
|
||||
* periods with unsynced usage are reported so nothing is stranded when the period rolls over
|
||||
* between syncs.
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
@Profile("!saas")
|
||||
@ConditionalOnProperty(
|
||||
name = "stirling.billing.account-link.metering.enabled",
|
||||
havingValue = "true")
|
||||
public class UsageSyncService implements SchedulingConfigurer {
|
||||
|
||||
// First run waits out startup churn; then every interval.
|
||||
private static final Duration INITIAL_DELAY = Duration.ofMinutes(5);
|
||||
|
||||
private final UsageCounterRepository counters;
|
||||
private final AccountLinkSyncStateRepository syncState;
|
||||
private final DeviceCredentialStore credentialStore;
|
||||
private final AccountLinkClient client;
|
||||
private final EntitlementCache entitlementCache;
|
||||
private final AccountLinkProperties properties;
|
||||
|
||||
public UsageSyncService(
|
||||
UsageCounterRepository counters,
|
||||
AccountLinkSyncStateRepository syncState,
|
||||
DeviceCredentialStore credentialStore,
|
||||
AccountLinkClient client,
|
||||
EntitlementCache entitlementCache,
|
||||
AccountLinkProperties properties) {
|
||||
this.counters = counters;
|
||||
this.syncState = syncState;
|
||||
this.credentialStore = credentialStore;
|
||||
this.client = client;
|
||||
this.entitlementCache = entitlementCache;
|
||||
this.properties = properties;
|
||||
}
|
||||
|
||||
/**
|
||||
* Registers the daily sync, binding the interval from {@code metering.sync-interval-hours} in
|
||||
* code rather than a {@code @Scheduled} SpEL string so a bad interval fails at boot/test rather
|
||||
* than only on a flags-on run.
|
||||
*/
|
||||
@Override
|
||||
public void configureTasks(ScheduledTaskRegistrar registrar) {
|
||||
Duration interval = Duration.ofHours(properties.getMetering().getSyncIntervalHours());
|
||||
registrar.addFixedDelayTask(
|
||||
new FixedDelayTask(this::scheduledSync, interval, INITIAL_DELAY));
|
||||
}
|
||||
|
||||
public void scheduledSync() {
|
||||
try {
|
||||
syncNow();
|
||||
} catch (RuntimeException e) {
|
||||
log.debug("Scheduled usage sync failed", e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reports every period with unsynced usage and refreshes the cached entitlement from the reply.
|
||||
* Single daily caller (non-reentrant {@code fixedDelay}), so no internal locking. No-op when
|
||||
* unlinked or when nothing is pending.
|
||||
*/
|
||||
public void syncNow() {
|
||||
Optional<DeviceCredential> cred = credentialStore.get();
|
||||
if (cred.isEmpty()) {
|
||||
return; // not linked
|
||||
}
|
||||
List<LocalDateTime> periods = counters.findPeriodsWithUnsyncedUsage();
|
||||
if (periods.isEmpty()) {
|
||||
// Nothing to report, but a sync is also our cue to pick up an out-of-band entitlement
|
||||
// change (e.g. the admin just subscribed) that otherwise wouldn't surface until the
|
||||
// cache TTL lapses. Force an immediate refresh so the gate reflects the new plan now.
|
||||
entitlementCache.invalidate();
|
||||
entitlementCache.current();
|
||||
return;
|
||||
}
|
||||
InstanceEntitlement latest = null;
|
||||
try {
|
||||
for (LocalDateTime period : periods) {
|
||||
InstanceEntitlement fresh = syncPeriod(cred.get(), period);
|
||||
if (fresh != null) {
|
||||
latest = fresh;
|
||||
}
|
||||
}
|
||||
} catch (AccountLinkClient.RevokedException e) {
|
||||
// Authoritative deny — stop reporting; the entitlement cache blocks billable work on
|
||||
// its
|
||||
// own next refresh, so we don't synthesise the blocked state here.
|
||||
log.info(
|
||||
"Usage sync denied (HTTP {}); credential revoked/invalid — gate blocks on next"
|
||||
+ " refresh",
|
||||
e.status());
|
||||
return;
|
||||
}
|
||||
// Adopt the freshest entitlement the sync returned, saving the cache a redundant fetch.
|
||||
entitlementCache.accept(latest);
|
||||
}
|
||||
|
||||
/** Reports one period; returns the fresh entitlement, or null on a transport/server failure. */
|
||||
private InstanceEntitlement syncPeriod(DeviceCredential cred, LocalDateTime period) {
|
||||
EnumMap<BillingCategory, Long> cumulative = new EnumMap<>(BillingCategory.class);
|
||||
for (UsageCounter c : counters.findByPeriodStart(period)) {
|
||||
BillingCategory cat = c.billingCategory();
|
||||
if (cat != null && cat != BillingCategory.BYPASSED) {
|
||||
cumulative.merge(cat, c.getCumulativeUnits(), Long::sum);
|
||||
}
|
||||
}
|
||||
AccountLinkSyncState state = loadState();
|
||||
long seq = reserveNextSeq(state);
|
||||
InstanceEntitlement fresh =
|
||||
client.reportUsage(
|
||||
cred.getDeviceId(),
|
||||
cred.getDeviceSecret(),
|
||||
seq,
|
||||
period,
|
||||
cumulative.getOrDefault(BillingCategory.API, 0L),
|
||||
cumulative.getOrDefault(BillingCategory.AI, 0L),
|
||||
cumulative.getOrDefault(BillingCategory.AUTOMATION, 0L));
|
||||
if (fresh == null) {
|
||||
// Transport/server failure: leave the synced markers untouched. The burned seq is
|
||||
// harmless (seqs need only be monotonic) and the delta bills on the next successful
|
||||
// sync.
|
||||
return null;
|
||||
}
|
||||
recordSuccess(period, cumulative, state);
|
||||
return fresh;
|
||||
}
|
||||
|
||||
/** Reserves and persists the next strictly-increasing sequence before the report goes out. */
|
||||
private long reserveNextSeq(AccountLinkSyncState state) {
|
||||
long next = state.getLastSyncSeq() + 1;
|
||||
state.setLastSyncSeq(next);
|
||||
syncState.save(state);
|
||||
return next;
|
||||
}
|
||||
|
||||
/**
|
||||
* Advances the per-category synced markers to the reported totals + stamps the success time.
|
||||
*/
|
||||
private void recordSuccess(
|
||||
LocalDateTime period,
|
||||
EnumMap<BillingCategory, Long> cumulative,
|
||||
AccountLinkSyncState state) {
|
||||
cumulative.forEach(
|
||||
(category, units) -> {
|
||||
if (units > 0) {
|
||||
counters.markSynced(period, category.name(), units);
|
||||
}
|
||||
});
|
||||
state.setLastSuccessAt(LocalDateTime.now());
|
||||
syncState.save(state);
|
||||
}
|
||||
|
||||
private AccountLinkSyncState loadState() {
|
||||
return syncState
|
||||
.findById(AccountLinkSyncState.SINGLETON_ID)
|
||||
.orElseGet(
|
||||
() -> {
|
||||
AccountLinkSyncState s = new AccountLinkSyncState();
|
||||
s.setId(AccountLinkSyncState.SINGLETON_ID);
|
||||
return s;
|
||||
});
|
||||
}
|
||||
}
|
||||
+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()));
|
||||
}
|
||||
}
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
package stirling.software.proprietary.billing;
|
||||
|
||||
/**
|
||||
* The billing / analytics axis for a metered operation. PAYG runs on a single flat-priced meter, so
|
||||
* category is metadata only and never affects price.
|
||||
*
|
||||
* <p>Classification precedence is {@code AUTOMATION → AI → API → BYPASSED} (see {@link
|
||||
* BillingCategoryClassifier}); {@link #BYPASSED} is a manual interactive tool call that is never
|
||||
* billed.
|
||||
*
|
||||
* <p>Mirrors the value set of the SaaS {@code payg.model.BillingCategory}. A linked self-hosted
|
||||
* instance reports usage per category to SaaS as the lower-case names ({@code api} / {@code ai} /
|
||||
* {@code automation}) in the daily sync, and SaaS maps them back — so the two enums must keep the
|
||||
* same names. (We deliberately do not share one enum across the modules: that would drag the SaaS
|
||||
* billing enum through ~20 hot-path files for what is JSON-string metadata on the wire.)
|
||||
*/
|
||||
public enum BillingCategory {
|
||||
BYPASSED,
|
||||
API,
|
||||
AI,
|
||||
AUTOMATION
|
||||
}
|
||||
+29
@@ -0,0 +1,29 @@
|
||||
package stirling.software.proprietary.billing;
|
||||
|
||||
/**
|
||||
* Pure precedence for bucketing a request into a {@link BillingCategory}, so the SaaS engine and a
|
||||
* linked self-hosted instance classify identically. Each backend resolves the three signals from
|
||||
* its own types — the automation marker header; an AI-surface signal (a {@code @RequiresFeature}
|
||||
* annotation / route on SaaS, a path prefix on the instance); API-key authentication — and this
|
||||
* applies the order {@code AUTOMATION → AI → API → BYPASSED}.
|
||||
*
|
||||
* <p>An AI tool dispatched inside a pipeline / workflow therefore bills as {@code AUTOMATION} (the
|
||||
* automation header dominates), while a direct call to it bills as {@code AI}.
|
||||
*/
|
||||
public final class BillingCategoryClassifier {
|
||||
|
||||
private BillingCategoryClassifier() {}
|
||||
|
||||
public static BillingCategory classify(boolean automation, boolean ai, boolean apiKey) {
|
||||
if (automation) {
|
||||
return BillingCategory.AUTOMATION;
|
||||
}
|
||||
if (ai) {
|
||||
return BillingCategory.AI;
|
||||
}
|
||||
if (apiKey) {
|
||||
return BillingCategory.API;
|
||||
}
|
||||
return BillingCategory.BYPASSED;
|
||||
}
|
||||
}
|
||||
+68
@@ -0,0 +1,68 @@
|
||||
package stirling.software.proprietary.billing;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.security.DigestInputStream;
|
||||
import java.security.MessageDigest;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
import java.util.HexFormat;
|
||||
|
||||
/**
|
||||
* SHA-256 content fingerprint shared by the SaaS charge path and the linked self-hosted instance's
|
||||
* meter (combined-billing "Mode A"), so both derive an <em>identical</em> signature for the same
|
||||
* bytes — the basis for lineage dedup. Pure, no Spring: fixed 64 KiB buffer (allocation independent
|
||||
* of file size), hardware-accelerated by the JVM where available.
|
||||
*
|
||||
* <p>Lives in {@code :proprietary} (not {@code :common}) so it stays out of the community core
|
||||
* build yet is reachable from {@code :saas} (which depends on {@code :proprietary}).
|
||||
*/
|
||||
public final class ContentHasher {
|
||||
|
||||
private static final String ALGORITHM = "SHA-256";
|
||||
private static final int BUFFER_SIZE = 64 * 1024;
|
||||
|
||||
private ContentHasher() {}
|
||||
|
||||
/** Lower-case hex SHA-256 of the file's bytes. */
|
||||
public static String sha256(Path file) throws IOException {
|
||||
MessageDigest digest = newDigest();
|
||||
try (InputStream raw = Files.newInputStream(file);
|
||||
DigestInputStream in = new DigestInputStream(raw, digest)) {
|
||||
byte[] buf = new byte[BUFFER_SIZE];
|
||||
while (in.read(buf) != -1) {
|
||||
// drain through the digest; we only want the side effect
|
||||
}
|
||||
}
|
||||
return HexFormat.of().formatHex(digest.digest());
|
||||
}
|
||||
|
||||
/** Lower-case hex SHA-256 of the given bytes (e.g. to combine per-file hashes into one key). */
|
||||
public static String sha256(byte[] bytes) {
|
||||
return HexFormat.of().formatHex(newDigest().digest(bytes));
|
||||
}
|
||||
|
||||
/**
|
||||
* A fresh SHA-256 digest, for callers that stream bytes through a {@link
|
||||
* java.security.DigestOutputStream} to hash in the same pass that writes the file — avoiding a
|
||||
* second full read just to fingerprint it. Pair with {@link #toHex(byte[])}.
|
||||
*/
|
||||
public static MessageDigest newSha256() {
|
||||
return newDigest();
|
||||
}
|
||||
|
||||
/** Lower-case hex of a completed digest — the same format {@link #sha256(Path)} produces. */
|
||||
public static String toHex(byte[] digest) {
|
||||
return HexFormat.of().formatHex(digest);
|
||||
}
|
||||
|
||||
private static MessageDigest newDigest() {
|
||||
try {
|
||||
return MessageDigest.getInstance(ALGORITHM);
|
||||
} catch (NoSuchAlgorithmException e) {
|
||||
// SHA-256 is mandated by every JDK; unreachable in practice.
|
||||
throw new IllegalStateException(ALGORITHM + " unavailable — JDK is misconfigured", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
+76
@@ -0,0 +1,76 @@
|
||||
package stirling.software.proprietary.billing;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Pure doc-unit math shared by the SaaS billing engine and a linked self-hosted instance, so both
|
||||
* cost an operation identically. No Spring, no IO: callers supply page/byte facts (read however
|
||||
* their backend reads them — e.g. jpdfium for PDFs) plus a {@link UnitCalcPolicy}.
|
||||
*
|
||||
* <p>Raw units for one file = the larger of {@code ceil(pages / docPagesPerUnit)} and {@code
|
||||
* ceil(bytes / docBytesPerUnit)} (non-PDF inputs pass {@code pages = 0}, so only the bytes axis
|
||||
* contributes). A single file is clamped to {@code [1, fileUnitCap]}; a multi-file group is the
|
||||
* <em>raw</em> per-file sum clamped to {@code [1, fileUnitCap * file_count]} (summing raw, not
|
||||
* per-file-clamped, units so the group cap can actually bind).
|
||||
*
|
||||
* <p>{@link UnitCalcPolicy#minChargeUnits()} is applied by the charge layer, not here; this
|
||||
* enforces only an absolute floor of {@link #MIN_UNITS_PER_NONEMPTY_FILE} so callers can rely on
|
||||
* "non-empty input → at least 1 unit". Extracted verbatim from the SaaS {@code
|
||||
* DefaultDocumentClassifier} to preserve behaviour.
|
||||
*/
|
||||
public final class DocumentUnitCalculator {
|
||||
|
||||
/** Floor for non-empty input. Distinct from {@link UnitCalcPolicy#minChargeUnits()}. */
|
||||
public static final int MIN_UNITS_PER_NONEMPTY_FILE = 1;
|
||||
|
||||
private DocumentUnitCalculator() {}
|
||||
|
||||
/** One file's page count (0 for non-PDF / unreadable) and byte size. */
|
||||
public record FileSize(int pages, long bytes) {}
|
||||
|
||||
/** Raw (unclamped) units for one file. */
|
||||
public static long rawUnits(int pages, long bytes, UnitCalcPolicy policy) {
|
||||
long pageUnits = pages > 0 ? ceilDiv(pages, policy.docPagesPerUnit()) : 0L;
|
||||
long byteUnits = ceilDiv(bytes, policy.docBytesPerUnit());
|
||||
return Math.max(pageUnits, byteUnits);
|
||||
}
|
||||
|
||||
/** Units for a single file, clamped to {@code [1, fileUnitCap]}. */
|
||||
public static int unitsForFile(int pages, long bytes, UnitCalcPolicy policy) {
|
||||
long raw = rawUnits(pages, bytes, policy);
|
||||
// toIntExact: fail loud on overflow rather than silently wrapping a billing number.
|
||||
return Math.toIntExact(
|
||||
Math.max(MIN_UNITS_PER_NONEMPTY_FILE, Math.min(policy.fileUnitCap(), raw)));
|
||||
}
|
||||
|
||||
/**
|
||||
* Units for a multi-file group: raw per-file sum clamped to {@code [1, fileUnitCap * count]}.
|
||||
*/
|
||||
public static int unitsForGroup(List<FileSize> files, UnitCalcPolicy policy) {
|
||||
if (files.isEmpty()) {
|
||||
throw new IllegalArgumentException("files must not be empty");
|
||||
}
|
||||
long rawSum = 0;
|
||||
for (FileSize f : files) {
|
||||
rawSum = saturatedAdd(rawSum, rawUnits(f.pages(), f.bytes(), policy));
|
||||
}
|
||||
long groupCap = (long) policy.fileUnitCap() * files.size();
|
||||
return Math.toIntExact(
|
||||
Math.max((long) MIN_UNITS_PER_NONEMPTY_FILE, Math.min(groupCap, rawSum)));
|
||||
}
|
||||
|
||||
private static long ceilDiv(long numerator, long divisor) {
|
||||
if (numerator <= 0) {
|
||||
return 0;
|
||||
}
|
||||
return (numerator + divisor - 1) / divisor;
|
||||
}
|
||||
|
||||
private static long saturatedAdd(long a, long b) {
|
||||
try {
|
||||
return Math.addExact(a, b);
|
||||
} catch (ArithmeticException e) {
|
||||
return Long.MAX_VALUE;
|
||||
}
|
||||
}
|
||||
}
|
||||
+30
@@ -0,0 +1,30 @@
|
||||
package stirling.software.proprietary.billing;
|
||||
|
||||
/**
|
||||
* The four billing knobs the doc-unit math needs, split out of the SaaS {@code PricingPolicy} JPA
|
||||
* entity so the calculation ({@link DocumentUnitCalculator}) can live in {@code :proprietary} and
|
||||
* be shared by the SaaS billing engine and a linked self-hosted instance — both then cost an
|
||||
* operation identically.
|
||||
*
|
||||
* <p>The SaaS engine builds one from its persisted {@code PricingPolicy}; a linked instance
|
||||
* receives these values in the daily entitlement sync. {@code minChargeUnits} is carried here for
|
||||
* the charge layer; {@link DocumentUnitCalculator} itself does not apply it (see its docs).
|
||||
*/
|
||||
public record UnitCalcPolicy(
|
||||
int docPagesPerUnit, long docBytesPerUnit, int minChargeUnits, int fileUnitCap) {
|
||||
|
||||
public UnitCalcPolicy {
|
||||
if (docPagesPerUnit <= 0) {
|
||||
throw new IllegalArgumentException("docPagesPerUnit must be > 0");
|
||||
}
|
||||
if (docBytesPerUnit <= 0) {
|
||||
throw new IllegalArgumentException("docBytesPerUnit must be > 0");
|
||||
}
|
||||
if (minChargeUnits < 1) {
|
||||
throw new IllegalArgumentException("minChargeUnits must be >= 1");
|
||||
}
|
||||
if (fileUnitCap < 1) {
|
||||
throw new IllegalArgumentException("fileUnitCap must be >= 1");
|
||||
}
|
||||
}
|
||||
}
|
||||
+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;
|
||||
}
|
||||
}
|
||||
@@ -223,7 +223,7 @@ Use consistent event types throughout the application:
|
||||
- `FILE_DOWNLOAD` - When a file is downloaded
|
||||
- `PDF_PROCESS` - When a PDF is processed (split, merged, etc.)
|
||||
- `USER_CREATE` - When a user is created
|
||||
- `USER_UPDATE` - When a user details are updated
|
||||
- `USER_UPDATE` - When a user's details are updated
|
||||
- `PASSWORD_CHANGE` - When a password is changed
|
||||
- `PERMISSION_CHANGE` - When permissions are modified
|
||||
- `SETTINGS_CHANGE` - When system settings are changed
|
||||
|
||||
+70
@@ -12,6 +12,7 @@ import java.net.ConnectException;
|
||||
import java.net.http.HttpClient;
|
||||
import java.net.http.HttpRequest;
|
||||
import java.net.http.HttpResponse;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
@@ -189,4 +190,73 @@ class AccountLinkClientTest {
|
||||
.thenThrow(new ConnectException("refused"));
|
||||
assertEquals(false, client.revokeSelf("dev-1", "sec-1"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@SuppressWarnings("unchecked")
|
||||
void reportUsagePostsToSyncWithDeviceHeadersAndParsesFreshEntitlement() throws Exception {
|
||||
HttpResponse<String> resp =
|
||||
response(
|
||||
200,
|
||||
"{\"subscribed\":true,\"freeRemainingUnits\":0,\"periodSpendUnits\":42,\"periodCapUnits\":100,\"state\":\"OK\"}");
|
||||
ArgumentCaptor<HttpRequest> captor = ArgumentCaptor.forClass(HttpRequest.class);
|
||||
when(httpClient.send(captor.capture(), any(HttpResponse.BodyHandler.class)))
|
||||
.thenReturn(resp);
|
||||
|
||||
InstanceEntitlement e =
|
||||
client.reportUsage(
|
||||
"dev-1", "sec-1", 7L, LocalDateTime.of(2026, 6, 1, 0, 0), 12, 4, 8);
|
||||
|
||||
assertNotNull(e);
|
||||
assertEquals(42, e.periodSpendUnits());
|
||||
assertEquals(EntitlementState.OK, e.state());
|
||||
|
||||
HttpRequest sent = captor.getValue();
|
||||
assertEquals("https://saas.example.com/api/v1/instance/sync", sent.uri().toString());
|
||||
assertEquals("POST", sent.method());
|
||||
assertEquals("dev-1", sent.headers().firstValue("X-Device-Id").orElse(null));
|
||||
assertEquals("sec-1", sent.headers().firstValue("X-Device-Secret").orElse(null));
|
||||
}
|
||||
|
||||
@Test
|
||||
@SuppressWarnings("unchecked")
|
||||
void reportUsageThrowsRevokedOnDeny() throws Exception {
|
||||
for (int status : new int[] {401, 403}) {
|
||||
HttpResponse<String> resp = response(status, "{}");
|
||||
when(httpClient.send(any(), any(HttpResponse.BodyHandler.class))).thenReturn(resp);
|
||||
AccountLinkClient.RevokedException ex =
|
||||
assertThrows(
|
||||
AccountLinkClient.RevokedException.class,
|
||||
() ->
|
||||
client.reportUsage(
|
||||
"dev-1",
|
||||
"sec-1",
|
||||
1L,
|
||||
LocalDateTime.of(2026, 6, 1, 0, 0),
|
||||
1,
|
||||
0,
|
||||
0));
|
||||
assertEquals(status, ex.status());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@SuppressWarnings("unchecked")
|
||||
void reportUsageReturnsNullWhenUnreachable() throws Exception {
|
||||
when(httpClient.send(any(), any(HttpResponse.BodyHandler.class)))
|
||||
.thenThrow(new ConnectException("refused"));
|
||||
// Null = don't advance synced markers; the usage retries on the next sync.
|
||||
assertNull(
|
||||
client.reportUsage(
|
||||
"dev-1", "sec-1", 1L, LocalDateTime.of(2026, 6, 1, 0, 0), 1, 0, 0));
|
||||
}
|
||||
|
||||
@Test
|
||||
@SuppressWarnings("unchecked")
|
||||
void reportUsageReturnsNullOnServerError() throws Exception {
|
||||
HttpResponse<String> resp = response(503, "{}");
|
||||
when(httpClient.send(any(), any(HttpResponse.BodyHandler.class))).thenReturn(resp);
|
||||
assertNull(
|
||||
client.reportUsage(
|
||||
"dev-1", "sec-1", 1L, LocalDateTime.of(2026, 6, 1, 0, 0), 1, 0, 0));
|
||||
}
|
||||
}
|
||||
|
||||
+30
-1
@@ -2,12 +2,15 @@ package stirling.software.proprietary.accountlink;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import java.io.IOException;
|
||||
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.ObjectProvider;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
|
||||
@@ -21,12 +24,18 @@ import stirling.software.proprietary.accountlink.AccountLinkController.LinkReque
|
||||
class AccountLinkControllerTest {
|
||||
|
||||
private AccountLinkService service;
|
||||
private UsageSyncService syncService;
|
||||
private ObjectProvider<UsageSyncService> syncProvider;
|
||||
private AccountLinkController controller;
|
||||
|
||||
@BeforeEach
|
||||
@SuppressWarnings("unchecked")
|
||||
void setUp() {
|
||||
service = mock(AccountLinkService.class);
|
||||
controller = new AccountLinkController(service);
|
||||
syncService = mock(UsageSyncService.class);
|
||||
syncProvider = mock(ObjectProvider.class);
|
||||
controller =
|
||||
new AccountLinkController(service, mock(LocalUsageService.class), syncProvider);
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -65,4 +74,24 @@ class AccountLinkControllerTest {
|
||||
ResponseEntity<?> resp = controller.link(new LinkRequest("jwt", null));
|
||||
assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.BAD_GATEWAY);
|
||||
}
|
||||
|
||||
@Test
|
||||
void syncNow_triggersSyncWhenMeteringOn() {
|
||||
when(syncProvider.getIfAvailable()).thenReturn(syncService);
|
||||
|
||||
ResponseEntity<Void> resp = controller.syncNow();
|
||||
|
||||
assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.NO_CONTENT);
|
||||
verify(syncService).syncNow();
|
||||
}
|
||||
|
||||
@Test
|
||||
void syncNow_returns409WhenMeteringOff() {
|
||||
when(syncProvider.getIfAvailable()).thenReturn(null); // metering disabled → bean absent
|
||||
|
||||
ResponseEntity<Void> resp = controller.syncNow();
|
||||
|
||||
assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.CONFLICT);
|
||||
verify(syncService, never()).syncNow();
|
||||
}
|
||||
}
|
||||
|
||||
+52
-23
@@ -1,49 +1,78 @@
|
||||
package stirling.software.proprietary.accountlink;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.mock.web.MockHttpServletRequest;
|
||||
|
||||
import stirling.software.common.service.InternalApiClient;
|
||||
import stirling.software.proprietary.billing.BillingCategory;
|
||||
|
||||
class BillableOperationClassifierTest {
|
||||
|
||||
@Test
|
||||
void aiPathIsBillable() {
|
||||
MockHttpServletRequest req = new MockHttpServletRequest("POST", "/api/v1/ai/tools/foo");
|
||||
assertTrue(BillableOperationClassifier.isBillable(req));
|
||||
private static MockHttpServletRequest req(String uri) {
|
||||
return new MockHttpServletRequest("POST", uri);
|
||||
}
|
||||
|
||||
@Test
|
||||
void automationHeaderIsBillable() {
|
||||
MockHttpServletRequest req = new MockHttpServletRequest("POST", "/api/v1/general/merge");
|
||||
void aiPathIsAi() {
|
||||
assertEquals(
|
||||
BillingCategory.AI,
|
||||
BillableOperationClassifier.categorize(req("/api/v1/ai/tools/foo"), false));
|
||||
}
|
||||
|
||||
@Test
|
||||
void automationHeaderIsAutomation() {
|
||||
MockHttpServletRequest req = req("/api/v1/general/merge");
|
||||
req.addHeader(InternalApiClient.AUTOMATION_HEADER, "1");
|
||||
assertTrue(BillableOperationClassifier.isBillable(req));
|
||||
assertEquals(
|
||||
BillingCategory.AUTOMATION, BillableOperationClassifier.categorize(req, false));
|
||||
}
|
||||
|
||||
@Test
|
||||
void plainManualToolIsFree() {
|
||||
MockHttpServletRequest req = new MockHttpServletRequest("POST", "/api/v1/general/merge");
|
||||
assertFalse(BillableOperationClassifier.isBillable(req));
|
||||
void apiKeyToolCallIsApi() {
|
||||
assertEquals(
|
||||
BillingCategory.API,
|
||||
BillableOperationClassifier.categorize(req("/api/v1/general/merge"), true));
|
||||
}
|
||||
|
||||
@Test
|
||||
void aiSegmentNotAtPathStartIsFree() {
|
||||
// Tightened from substring to prefix: the AI segment appearing mid-path (e.g. behind a
|
||||
// proxy prefix) must NOT classify a manual tool as billable.
|
||||
MockHttpServletRequest req =
|
||||
new MockHttpServletRequest("POST", "/proxy/api/v1/ai/tools/foo");
|
||||
assertFalse(BillableOperationClassifier.isBillable(req));
|
||||
void plainManualToolIsBypassed() {
|
||||
assertEquals(
|
||||
BillingCategory.BYPASSED,
|
||||
BillableOperationClassifier.categorize(req("/api/v1/general/merge"), false));
|
||||
}
|
||||
|
||||
@Test
|
||||
void aiPathUnderContextPathIsBillable() {
|
||||
// A real context-path deployment still classifies: /<ctx>/api/v1/ai/** is billable.
|
||||
MockHttpServletRequest req =
|
||||
new MockHttpServletRequest("POST", "/stirling/api/v1/ai/tools/foo");
|
||||
void automationDominatesAiAndApiKey() {
|
||||
// An AI tool dispatched inside a workflow (automation header) + API-key auth → AUTOMATION.
|
||||
MockHttpServletRequest req = req("/api/v1/ai/tools/foo");
|
||||
req.addHeader(InternalApiClient.AUTOMATION_HEADER, "true");
|
||||
assertEquals(BillingCategory.AUTOMATION, BillableOperationClassifier.categorize(req, true));
|
||||
}
|
||||
|
||||
@Test
|
||||
void aiDominatesApiKey() {
|
||||
// A direct API-key call to an AI tool bills as AI, not API.
|
||||
assertEquals(
|
||||
BillingCategory.AI,
|
||||
BillableOperationClassifier.categorize(req("/api/v1/ai/tools/foo"), true));
|
||||
}
|
||||
|
||||
@Test
|
||||
void aiSegmentNotAtPathStartIsBypassed() {
|
||||
// Tightened from substring to prefix: the AI segment mid-path (e.g. behind a proxy prefix)
|
||||
// must NOT classify a manual tool as AI.
|
||||
assertEquals(
|
||||
BillingCategory.BYPASSED,
|
||||
BillableOperationClassifier.categorize(req("/proxy/api/v1/ai/tools/foo"), false));
|
||||
}
|
||||
|
||||
@Test
|
||||
void aiPathUnderContextPathIsAi() {
|
||||
// A real context-path deployment still classifies: /<ctx>/api/v1/ai/** is AI.
|
||||
MockHttpServletRequest req = req("/stirling/api/v1/ai/tools/foo");
|
||||
req.setContextPath("/stirling");
|
||||
assertTrue(BillableOperationClassifier.isBillable(req));
|
||||
assertEquals(BillingCategory.AI, BillableOperationClassifier.categorize(req, false));
|
||||
}
|
||||
}
|
||||
|
||||
+198
-14
@@ -3,20 +3,32 @@ package stirling.software.proprietary.accountlink;
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.Optional;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
|
||||
import stirling.software.proprietary.accountlink.GateDecision.Reason;
|
||||
|
||||
/**
|
||||
* Covers the gate decision matrix: flag-off, manual-free, unlinked, fail-open, linked-free, and
|
||||
* over-limit. Exercises the pure {@link InstanceEntitlementGate#decide} so no Spring / I/O is
|
||||
* needed.
|
||||
* Covers the gate decision matrix: flag-off, manual-free, unlinked, fail-open, grace-expired,
|
||||
* linked-free, and over-limit. The pure {@link InstanceEntitlementGate#decide} cases need no
|
||||
* Spring; the grace-window reference computation is exercised through {@link
|
||||
* InstanceEntitlementGate#evaluate} with mocked collaborators.
|
||||
*/
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class InstanceEntitlementGateTest {
|
||||
|
||||
@Mock private DeviceCredentialStore credentialStore;
|
||||
@Mock private EntitlementCache entitlementCache;
|
||||
@Mock private AccountLinkSyncStateRepository syncStateRepository;
|
||||
@Mock private LocalUsageService localUsageService;
|
||||
|
||||
private static InstanceEntitlement free() {
|
||||
return new InstanceEntitlement(false, 100, 0, null, EntitlementState.OK);
|
||||
}
|
||||
@@ -35,35 +47,67 @@ class InstanceEntitlementGateTest {
|
||||
|
||||
@Test
|
||||
void flagOff_allowsEverything_evenBillableUnlinked() {
|
||||
GateDecision d = InstanceEntitlementGate.decide(false, true, false, Optional.empty());
|
||||
GateDecision d =
|
||||
InstanceEntitlementGate.decide(false, true, false, Optional.empty(), false, 0L);
|
||||
assertTrue(d.allowed());
|
||||
assertEquals(Reason.FLAG_OFF, d.reason());
|
||||
}
|
||||
|
||||
@Test
|
||||
void manualTool_alwaysFree_evenUnlinked() {
|
||||
GateDecision d = InstanceEntitlementGate.decide(true, false, false, Optional.empty());
|
||||
GateDecision d =
|
||||
InstanceEntitlementGate.decide(true, false, false, Optional.empty(), false, 0L);
|
||||
assertTrue(d.allowed());
|
||||
assertEquals(Reason.MANUAL_FREE, d.reason());
|
||||
}
|
||||
|
||||
@Test
|
||||
void billable_notLinked_blocksWithLinkSignal() {
|
||||
GateDecision d = InstanceEntitlementGate.decide(true, true, false, Optional.empty());
|
||||
GateDecision d =
|
||||
InstanceEntitlementGate.decide(true, true, false, Optional.empty(), false, 0L);
|
||||
assertFalse(d.allowed());
|
||||
assertEquals(Reason.NOT_LINKED, d.reason());
|
||||
}
|
||||
|
||||
@Test
|
||||
void billable_linked_entitlementUnreachable_failsOpen() {
|
||||
GateDecision d = InstanceEntitlementGate.decide(true, true, true, Optional.empty());
|
||||
void billable_linked_entitlementUnreachable_withinGrace_failsOpen() {
|
||||
GateDecision d =
|
||||
InstanceEntitlementGate.decide(true, true, true, Optional.empty(), false, 0L);
|
||||
assertTrue(d.allowed());
|
||||
assertEquals(Reason.FAIL_OPEN, d.reason());
|
||||
}
|
||||
|
||||
@Test
|
||||
void billable_linked_entitlementUnreachable_graceExpired_blocks() {
|
||||
GateDecision d =
|
||||
InstanceEntitlementGate.decide(true, true, true, Optional.empty(), true, 0L);
|
||||
assertFalse(d.allowed());
|
||||
assertEquals(Reason.GRACE_EXPIRED, d.reason());
|
||||
}
|
||||
|
||||
@Test
|
||||
void billable_linked_freePoolAvailable_allows() {
|
||||
GateDecision d = InstanceEntitlementGate.decide(true, true, true, Optional.of(free()));
|
||||
GateDecision d =
|
||||
InstanceEntitlementGate.decide(true, true, true, Optional.of(free()), false, 0L);
|
||||
assertTrue(d.allowed());
|
||||
assertEquals(Reason.ENTITLED, d.reason());
|
||||
}
|
||||
|
||||
@Test
|
||||
void billable_linked_unsubscribed_pendingLocalUsageDepletesGrant_blocks() {
|
||||
// free() has 100 free units left per the last sync; 100 accrued locally since would exhaust
|
||||
// it once charged, so the gate stops here in real time rather than waiting for the sync.
|
||||
GateDecision d =
|
||||
InstanceEntitlementGate.decide(true, true, true, Optional.of(free()), false, 100L);
|
||||
assertFalse(d.allowed());
|
||||
assertEquals(Reason.OVER_LIMIT, d.reason());
|
||||
}
|
||||
|
||||
@Test
|
||||
void billable_linked_unsubscribed_pendingLocalUsageLeavesRoom_allows() {
|
||||
// 99 pending against 100 remaining → one unit of grant still projected free → allow.
|
||||
GateDecision d =
|
||||
InstanceEntitlementGate.decide(true, true, true, Optional.of(free()), false, 99L);
|
||||
assertTrue(d.allowed());
|
||||
assertEquals(Reason.ENTITLED, d.reason());
|
||||
}
|
||||
@@ -72,7 +116,7 @@ class InstanceEntitlementGateTest {
|
||||
void billable_linked_unsubscribedAndExhausted_blocksOverLimit() {
|
||||
GateDecision d =
|
||||
InstanceEntitlementGate.decide(
|
||||
true, true, true, Optional.of(exhaustedUnsubscribed()));
|
||||
true, true, true, Optional.of(exhaustedUnsubscribed()), false, 0L);
|
||||
assertFalse(d.allowed());
|
||||
assertEquals(Reason.OVER_LIMIT, d.reason());
|
||||
}
|
||||
@@ -81,7 +125,7 @@ class InstanceEntitlementGateTest {
|
||||
void billable_linked_subscribedWithinCap_allows() {
|
||||
GateDecision d =
|
||||
InstanceEntitlementGate.decide(
|
||||
true, true, true, Optional.of(subscribedWithinCap()));
|
||||
true, true, true, Optional.of(subscribedWithinCap()), false, 0L);
|
||||
assertTrue(d.allowed());
|
||||
assertEquals(Reason.ENTITLED, d.reason());
|
||||
}
|
||||
@@ -89,18 +133,67 @@ class InstanceEntitlementGateTest {
|
||||
@Test
|
||||
void billable_linked_subscribedOverCap_blocks() {
|
||||
GateDecision d =
|
||||
InstanceEntitlementGate.decide(true, true, true, Optional.of(subscribedOverCap()));
|
||||
InstanceEntitlementGate.decide(
|
||||
true, true, true, Optional.of(subscribedOverCap()), false, 0L);
|
||||
assertFalse(d.allowed());
|
||||
assertEquals(Reason.OVER_LIMIT, d.reason());
|
||||
}
|
||||
|
||||
@Test
|
||||
void billable_linked_subscribedCapped_pendingLocalUsageWouldExceedCap_blocks() {
|
||||
// Within cap per the last sync (spend 10 / cap 100), but 95 accrued locally since would
|
||||
// push
|
||||
// projected spend to 105 → the gate stops now, not after the next sync reconciles.
|
||||
GateDecision d =
|
||||
InstanceEntitlementGate.decide(
|
||||
true, true, true, Optional.of(subscribedWithinCap()), false, 95L);
|
||||
assertFalse(d.allowed());
|
||||
assertEquals(Reason.OVER_LIMIT, d.reason());
|
||||
}
|
||||
|
||||
@Test
|
||||
void billable_linked_subscribedCapped_pendingLeavesCapRoom_allows() {
|
||||
// 10 synced + 80 pending = 90 < 100 cap → still room.
|
||||
GateDecision d =
|
||||
InstanceEntitlementGate.decide(
|
||||
true, true, true, Optional.of(subscribedWithinCap()), false, 80L);
|
||||
assertTrue(d.allowed());
|
||||
assertEquals(Reason.ENTITLED, d.reason());
|
||||
}
|
||||
|
||||
@Test
|
||||
void billable_linked_subscribedCapped_freeGrantAbsorbsPending_allows() {
|
||||
// 50 free units remain, so 40 pending is entirely free → 0 projected paid < 100 cap →
|
||||
// allow.
|
||||
InstanceEntitlement subscribedWithGrant =
|
||||
new InstanceEntitlement(true, 50, 0, 100L, EntitlementState.OK);
|
||||
GateDecision d =
|
||||
InstanceEntitlementGate.decide(
|
||||
true, true, true, Optional.of(subscribedWithGrant), false, 40L);
|
||||
assertTrue(d.allowed());
|
||||
assertEquals(Reason.ENTITLED, d.reason());
|
||||
}
|
||||
|
||||
@Test
|
||||
void billable_linked_subscribedUncapped_pendingIgnored_allows() {
|
||||
// No cap → local pending has no ceiling to hit → always allowed.
|
||||
InstanceEntitlement uncapped =
|
||||
new InstanceEntitlement(true, 0, 999, null, EntitlementState.OK);
|
||||
GateDecision d =
|
||||
InstanceEntitlementGate.decide(
|
||||
true, true, true, Optional.of(uncapped), false, 500L);
|
||||
assertTrue(d.allowed());
|
||||
assertEquals(Reason.ENTITLED, d.reason());
|
||||
}
|
||||
|
||||
@Test
|
||||
void billable_linked_revoked_blocksWithRevokedSignal() {
|
||||
// Authoritative deny (revoked/invalid credential) surfaced by the cache as REVOKED —
|
||||
// blocks distinctly from over-limit, even though the snapshot is "present".
|
||||
InstanceEntitlement revoked =
|
||||
new InstanceEntitlement(false, 0, 0, null, EntitlementState.REVOKED);
|
||||
GateDecision d = InstanceEntitlementGate.decide(true, true, true, Optional.of(revoked));
|
||||
GateDecision d =
|
||||
InstanceEntitlementGate.decide(true, true, true, Optional.of(revoked), false, 0L);
|
||||
assertFalse(d.allowed());
|
||||
assertEquals(Reason.REVOKED, d.reason());
|
||||
}
|
||||
@@ -110,7 +203,98 @@ class InstanceEntitlementGateTest {
|
||||
// Defensive: an explicit OVER_LIMIT state blocks even if a stale free count looks positive.
|
||||
InstanceEntitlement conflicting =
|
||||
new InstanceEntitlement(false, 5, 0, null, EntitlementState.OVER_LIMIT);
|
||||
GateDecision d = InstanceEntitlementGate.decide(true, true, true, Optional.of(conflicting));
|
||||
GateDecision d =
|
||||
InstanceEntitlementGate.decide(
|
||||
true, true, true, Optional.of(conflicting), false, 0L);
|
||||
assertFalse(d.allowed());
|
||||
assertEquals(Reason.OVER_LIMIT, d.reason());
|
||||
}
|
||||
|
||||
// --- grace window (evaluate()) ---------------------------------------------------------------
|
||||
|
||||
private InstanceEntitlementGate gate(AccountLinkProperties props) {
|
||||
return new InstanceEntitlementGate(
|
||||
props, credentialStore, entitlementCache, syncStateRepository, localUsageService);
|
||||
}
|
||||
|
||||
private static AccountLinkProperties props(boolean meteringEnabled, int graceDays) {
|
||||
AccountLinkProperties p = new AccountLinkProperties();
|
||||
p.setEnabled(true);
|
||||
p.getMetering().setEnabled(meteringEnabled);
|
||||
p.getMetering().setGraceDays(graceDays);
|
||||
return p;
|
||||
}
|
||||
|
||||
@Test
|
||||
void evaluate_meteringOff_unreachable_failsOpen_neverGraceBlocks() {
|
||||
when(credentialStore.isLinked()).thenReturn(true);
|
||||
when(entitlementCache.current()).thenReturn(Optional.empty());
|
||||
|
||||
GateDecision d = gate(props(false, 3)).evaluate(true);
|
||||
|
||||
// Metering off → grace never applies, even if a sync is ancient.
|
||||
assertTrue(d.allowed());
|
||||
assertEquals(Reason.FAIL_OPEN, d.reason());
|
||||
}
|
||||
|
||||
@Test
|
||||
void evaluate_neverSynced_pastGraceSinceLink_blocks() {
|
||||
when(credentialStore.isLinked()).thenReturn(true);
|
||||
when(entitlementCache.current()).thenReturn(Optional.empty());
|
||||
when(syncStateRepository.findById(AccountLinkSyncState.SINGLETON_ID))
|
||||
.thenReturn(Optional.empty());
|
||||
DeviceCredential cred = new DeviceCredential();
|
||||
cred.setLinkedAt(LocalDateTime.now().minusDays(5));
|
||||
when(credentialStore.get()).thenReturn(Optional.of(cred));
|
||||
|
||||
GateDecision d = gate(props(true, 3)).evaluate(true);
|
||||
|
||||
assertFalse(d.allowed());
|
||||
assertEquals(Reason.GRACE_EXPIRED, d.reason());
|
||||
}
|
||||
|
||||
@Test
|
||||
void evaluate_recentSync_withinGrace_failsOpen() {
|
||||
when(credentialStore.isLinked()).thenReturn(true);
|
||||
when(entitlementCache.current()).thenReturn(Optional.empty());
|
||||
AccountLinkSyncState state = new AccountLinkSyncState();
|
||||
state.setLastSuccessAt(LocalDateTime.now().minusDays(1));
|
||||
when(syncStateRepository.findById(AccountLinkSyncState.SINGLETON_ID))
|
||||
.thenReturn(Optional.of(state));
|
||||
|
||||
GateDecision d = gate(props(true, 3)).evaluate(true);
|
||||
|
||||
assertTrue(d.allowed());
|
||||
assertEquals(Reason.FAIL_OPEN, d.reason());
|
||||
}
|
||||
|
||||
@Test
|
||||
void evaluate_unsubscribed_localUsageWouldExceedGrant_blocksInRealTime() {
|
||||
// 100 free units remaining per the last sync, but 100 already accrued locally since — the
|
||||
// gate subtracts the pending delta and blocks now, not after the next sync reconciles.
|
||||
when(credentialStore.isLinked()).thenReturn(true);
|
||||
when(entitlementCache.current()).thenReturn(Optional.of(free()));
|
||||
when(localUsageService.currentPeriodUnsynced())
|
||||
.thenReturn(new LocalUsageService.LocalUsage(LocalDateTime.now(), 100, 0, 0, 100));
|
||||
|
||||
GateDecision d = gate(props(true, 3)).evaluate(true);
|
||||
|
||||
assertFalse(d.allowed());
|
||||
assertEquals(Reason.OVER_LIMIT, d.reason());
|
||||
}
|
||||
|
||||
@Test
|
||||
void evaluate_subscribedCapped_localUsageWouldExceedCap_blocksInRealTime() {
|
||||
// Subscribed within cap per the last sync (spend 10 / cap 100), but 90 accrued locally
|
||||
// since — evaluate() now depletes the cap by pending usage for capped subscriptions too, so
|
||||
// the gate stops now instead of overshooting the cap until the next sync.
|
||||
when(credentialStore.isLinked()).thenReturn(true);
|
||||
when(entitlementCache.current()).thenReturn(Optional.of(subscribedWithinCap()));
|
||||
when(localUsageService.currentPeriodUnsynced())
|
||||
.thenReturn(new LocalUsageService.LocalUsage(LocalDateTime.now(), 0, 90, 0, 90));
|
||||
|
||||
GateDecision d = gate(props(true, 3)).evaluate(true);
|
||||
|
||||
assertFalse(d.allowed());
|
||||
assertEquals(Reason.OVER_LIMIT, d.reason());
|
||||
}
|
||||
|
||||
+13
-1
@@ -19,6 +19,7 @@ class InstanceEntitlementGateWiringTest {
|
||||
private AccountLinkProperties properties;
|
||||
private DeviceCredentialStore store;
|
||||
private EntitlementCache cache;
|
||||
private LocalUsageService localUsage;
|
||||
private InstanceEntitlementGate gate;
|
||||
|
||||
@BeforeEach
|
||||
@@ -27,7 +28,14 @@ class InstanceEntitlementGateWiringTest {
|
||||
properties.setEnabled(true);
|
||||
store = mock(DeviceCredentialStore.class);
|
||||
cache = mock(EntitlementCache.class);
|
||||
gate = new InstanceEntitlementGate(properties, store, cache);
|
||||
localUsage = mock(LocalUsageService.class);
|
||||
gate =
|
||||
new InstanceEntitlementGate(
|
||||
properties,
|
||||
store,
|
||||
cache,
|
||||
mock(AccountLinkSyncStateRepository.class),
|
||||
localUsage);
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -55,6 +63,10 @@ class InstanceEntitlementGateWiringTest {
|
||||
.thenReturn(
|
||||
Optional.of(
|
||||
new InstanceEntitlement(false, 5, 0, null, EntitlementState.OK)));
|
||||
// Unsubscribed → the gate reads local unsynced usage to deplete the grant in real time;
|
||||
// nothing pending here, so the 5 free units still allow the request.
|
||||
when(localUsage.currentPeriodUnsynced())
|
||||
.thenReturn(new LocalUsageService.LocalUsage(null, 0, 0, 0, 0));
|
||||
GateDecision d = gate.evaluate(true);
|
||||
assertTrue(d.allowed());
|
||||
assertEquals(GateDecision.Reason.ENTITLED, d.reason());
|
||||
|
||||
+113
-1
@@ -3,24 +3,55 @@ package stirling.software.proprietary.accountlink;
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyBoolean;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.ArgumentMatchers.isNull;
|
||||
import static org.mockito.ArgumentMatchers.notNull;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.verifyNoInteractions;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.nio.file.Path;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.Optional;
|
||||
|
||||
import org.apache.pdfbox.pdmodel.PDDocument;
|
||||
import org.apache.pdfbox.pdmodel.PDPage;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.springframework.beans.factory.ObjectProvider;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.mock.web.MockHttpServletRequest;
|
||||
import org.springframework.mock.web.MockHttpServletResponse;
|
||||
import org.springframework.mock.web.MockMultipartFile;
|
||||
import org.springframework.mock.web.MockMultipartHttpServletRequest;
|
||||
|
||||
import stirling.software.common.util.TempFile;
|
||||
import stirling.software.common.util.TempFileManager;
|
||||
import stirling.software.proprietary.billing.BillingCategory;
|
||||
import stirling.software.proprietary.billing.UnitCalcPolicy;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class InstanceEntitlementInterceptorTest {
|
||||
|
||||
@Mock private InstanceEntitlementGate gate;
|
||||
@Mock private EntitlementCache entitlementCache;
|
||||
@Mock private ObjectProvider<UsageMeterService> meterProvider;
|
||||
@Mock private TempFileManager tempFileManager;
|
||||
|
||||
private InstanceEntitlementInterceptor interceptor() {
|
||||
return new InstanceEntitlementInterceptor(
|
||||
gate, entitlementCache, meterProvider, tempFileManager);
|
||||
}
|
||||
|
||||
private boolean preHandle(MockHttpServletResponse response) throws Exception {
|
||||
return new InstanceEntitlementInterceptor(gate)
|
||||
return interceptor()
|
||||
.preHandle(
|
||||
new MockHttpServletRequest("GET", "/api/v1/ai/x"), response, new Object());
|
||||
}
|
||||
@@ -58,4 +89,85 @@ class InstanceEntitlementInterceptorTest {
|
||||
assertTrue(preHandle(response));
|
||||
assertEquals(200, response.getStatus());
|
||||
}
|
||||
|
||||
@Test
|
||||
void metersSuccessfulBillableOp() throws Exception {
|
||||
when(gate.evaluate(anyBoolean()))
|
||||
.thenReturn(GateDecision.allow(GateDecision.Reason.ENTITLED));
|
||||
UsageMeterService meter = mock(UsageMeterService.class);
|
||||
when(meterProvider.getIfAvailable()).thenReturn(meter);
|
||||
UnitCalcPolicy policy = new UnitCalcPolicy(1, 1_048_576L, 1, 1000);
|
||||
LocalDateTime period = LocalDateTime.of(2026, 6, 1, 0, 0);
|
||||
when(entitlementCache.current()).thenReturn(Optional.of(entitled(policy, period)));
|
||||
|
||||
InstanceEntitlementInterceptor interceptor = interceptor();
|
||||
MockHttpServletRequest req = new MockHttpServletRequest("POST", "/api/v1/ai/x");
|
||||
MockHttpServletResponse resp = new MockHttpServletResponse();
|
||||
interceptor.preHandle(req, resp, new Object()); // stashes AI category
|
||||
interceptor.afterCompletion(req, resp, new Object(), null);
|
||||
|
||||
// No uploaded files → bytes axis → the 1-unit floor; no input identity → null signature.
|
||||
verify(meter).accrue(eq(period), eq(BillingCategory.AI), eq(1L), isNull());
|
||||
}
|
||||
|
||||
@Test
|
||||
void metersPdfByPageCountNotJustBytes(@TempDir Path tmp) throws Exception {
|
||||
when(gate.evaluate(anyBoolean()))
|
||||
.thenReturn(GateDecision.allow(GateDecision.Reason.ENTITLED));
|
||||
UsageMeterService meter = mock(UsageMeterService.class);
|
||||
when(meterProvider.getIfAvailable()).thenReturn(meter);
|
||||
// docPagesPerUnit=1, docBytesPerUnit=1MB → a tiny 5-page PDF costs 5 on the page axis but
|
||||
// only 1 on the byte axis: page-counting (via jpdfium) is what makes this bill correctly.
|
||||
UnitCalcPolicy policy = new UnitCalcPolicy(1, 1_048_576L, 1, 1000);
|
||||
LocalDateTime period = LocalDateTime.of(2026, 6, 1, 0, 0);
|
||||
when(entitlementCache.current()).thenReturn(Optional.of(entitled(policy, period)));
|
||||
// Materialise to a real path under @TempDir; the interceptor writes the upload there and
|
||||
// jpdfium + the hasher read it back.
|
||||
TempFile temp = mock(TempFile.class);
|
||||
when(temp.getPath()).thenReturn(tmp.resolve("input.bin"));
|
||||
when(tempFileManager.createManagedTempFile(any())).thenReturn(temp);
|
||||
|
||||
InstanceEntitlementInterceptor interceptor = interceptor();
|
||||
MockMultipartHttpServletRequest req = new MockMultipartHttpServletRequest();
|
||||
req.setRequestURI("/api/v1/ai/x");
|
||||
req.addFile(new MockMultipartFile("file", "doc.pdf", "application/pdf", fivePagePdf()));
|
||||
MockHttpServletResponse resp = new MockHttpServletResponse();
|
||||
interceptor.preHandle(req, resp, new Object());
|
||||
interceptor.afterCompletion(req, resp, new Object(), null);
|
||||
|
||||
// 5 pages + a non-null input-set signature (file ops carry a dedup key).
|
||||
verify(meter).accrue(eq(period), eq(BillingCategory.AI), eq(5L), notNull());
|
||||
}
|
||||
|
||||
@Test
|
||||
void doesNotMeterWhenMeteringSwitchOff() throws Exception {
|
||||
when(gate.evaluate(anyBoolean()))
|
||||
.thenReturn(GateDecision.allow(GateDecision.Reason.ENTITLED));
|
||||
when(meterProvider.getIfAvailable()).thenReturn(null); // metering.enabled = false
|
||||
|
||||
InstanceEntitlementInterceptor interceptor = interceptor();
|
||||
MockHttpServletRequest req = new MockHttpServletRequest("POST", "/api/v1/ai/x");
|
||||
MockHttpServletResponse resp = new MockHttpServletResponse();
|
||||
interceptor.preHandle(req, resp, new Object());
|
||||
interceptor.afterCompletion(req, resp, new Object(), null);
|
||||
|
||||
// Meter absent → no entitlement lookup, no accrual.
|
||||
verifyNoInteractions(entitlementCache);
|
||||
}
|
||||
|
||||
private static InstanceEntitlement entitled(UnitCalcPolicy policy, LocalDateTime period) {
|
||||
return new InstanceEntitlement(
|
||||
true, 0, 0, 100L, EntitlementState.OK, policy, period, period.plusMonths(1));
|
||||
}
|
||||
|
||||
private static byte[] fivePagePdf() throws Exception {
|
||||
try (PDDocument doc = new PDDocument();
|
||||
ByteArrayOutputStream out = new ByteArrayOutputStream()) {
|
||||
for (int i = 0; i < 5; i++) {
|
||||
doc.addPage(new PDPage());
|
||||
}
|
||||
doc.save(out);
|
||||
return out.toByteArray();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+75
@@ -0,0 +1,75 @@
|
||||
package stirling.software.proprietary.accountlink;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class LocalUsageServiceTest {
|
||||
|
||||
@Mock private UsageCounterRepository counters;
|
||||
@Mock private EntitlementCache entitlementCache;
|
||||
|
||||
private LocalUsageService service;
|
||||
private final LocalDateTime period = LocalDateTime.of(2026, 6, 1, 0, 0);
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
service = new LocalUsageService(counters, entitlementCache);
|
||||
}
|
||||
|
||||
private static UsageCounter counter(
|
||||
LocalDateTime period, String category, long cumulative, long synced) {
|
||||
return new UsageCounter(period, category, cumulative, synced, LocalDateTime.now());
|
||||
}
|
||||
|
||||
private static InstanceEntitlement entitledFor(LocalDateTime periodStart) {
|
||||
return new InstanceEntitlement(
|
||||
true,
|
||||
0,
|
||||
0,
|
||||
null,
|
||||
EntitlementState.OK,
|
||||
null,
|
||||
periodStart,
|
||||
periodStart.plusMonths(1));
|
||||
}
|
||||
|
||||
@Test
|
||||
void unknownPeriodReturnsZeros() {
|
||||
when(entitlementCache.current()).thenReturn(Optional.empty());
|
||||
|
||||
LocalUsageService.LocalUsage usage = service.currentPeriodUnsynced();
|
||||
|
||||
assertThat(usage.periodStart()).isNull();
|
||||
assertThat(usage.totalUnsyncedUnits()).isZero();
|
||||
}
|
||||
|
||||
@Test
|
||||
void sumsPerCategoryUnsyncedDeltaForCurrentPeriod() {
|
||||
when(entitlementCache.current()).thenReturn(Optional.of(entitledFor(period)));
|
||||
when(counters.findByPeriodStart(period))
|
||||
.thenReturn(
|
||||
List.of(
|
||||
counter(period, "API", 30L, 10L), // 20 unsynced
|
||||
counter(period, "AI", 4L, 4L), // 0 unsynced (all reported)
|
||||
counter(period, "AUTOMATION", 7L, 2L))); // 5 unsynced
|
||||
|
||||
LocalUsageService.LocalUsage usage = service.currentPeriodUnsynced();
|
||||
|
||||
assertThat(usage.periodStart()).isEqualTo(period);
|
||||
assertThat(usage.apiUnsyncedUnits()).isEqualTo(20L);
|
||||
assertThat(usage.aiUnsyncedUnits()).isEqualTo(0L);
|
||||
assertThat(usage.automationUnsyncedUnits()).isEqualTo(5L);
|
||||
assertThat(usage.totalUnsyncedUnits()).isEqualTo(25L);
|
||||
}
|
||||
}
|
||||
+133
@@ -0,0 +1,133 @@
|
||||
package stirling.software.proprietary.accountlink;
|
||||
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyLong;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.times;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.verifyNoInteractions;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.Optional;
|
||||
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.springframework.dao.DataIntegrityViolationException;
|
||||
|
||||
import stirling.software.proprietary.billing.BillingCategory;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class UsageMeterServiceTest {
|
||||
|
||||
@Mock private UsageCounterRepository repo;
|
||||
@Mock private MeteredInputSignatureRepository signatureRepo;
|
||||
|
||||
private UsageMeterService service;
|
||||
private final LocalDateTime period = LocalDateTime.of(2026, 6, 1, 0, 0);
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
service = new UsageMeterService(repo, signatureRepo, new AccountLinkProperties());
|
||||
}
|
||||
|
||||
@Test
|
||||
void incrementsExistingCounter() {
|
||||
when(repo.increment(eq(period), eq("AI"), eq(5L), any())).thenReturn(1);
|
||||
|
||||
service.accrue(period, BillingCategory.AI, 5, null);
|
||||
|
||||
verify(repo).increment(eq(period), eq("AI"), eq(5L), any());
|
||||
verify(repo, never()).saveAndFlush(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void insertsWhenNoRowExists() {
|
||||
when(repo.increment(eq(period), eq("API"), eq(3L), any())).thenReturn(0);
|
||||
|
||||
service.accrue(period, BillingCategory.API, 3, null);
|
||||
|
||||
verify(repo).saveAndFlush(any(UsageCounter.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void retriesIncrementWhenInsertLosesRace() {
|
||||
// First increment misses (no row); insert loses the race to a concurrent thread; the
|
||||
// second increment then succeeds against the row that thread created.
|
||||
when(repo.increment(eq(period), eq("AUTOMATION"), eq(2L), any())).thenReturn(0, 1);
|
||||
when(repo.saveAndFlush(any())).thenThrow(new DataIntegrityViolationException("dup"));
|
||||
|
||||
service.accrue(period, BillingCategory.AUTOMATION, 2, null);
|
||||
|
||||
verify(repo, times(2)).increment(eq(period), eq("AUTOMATION"), eq(2L), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void skipsBypassedNonPositiveAndNullPeriod() {
|
||||
service.accrue(period, BillingCategory.BYPASSED, 5, null);
|
||||
service.accrue(period, BillingCategory.AI, 0, null);
|
||||
service.accrue(null, BillingCategory.AI, 5, null);
|
||||
|
||||
verifyNoInteractions(repo, signatureRepo);
|
||||
}
|
||||
|
||||
@Test
|
||||
void chargesNewSignatureThenAccrues() {
|
||||
when(signatureRepo.findByPeriodStartAndSignature(period, "op-sig-new"))
|
||||
.thenReturn(Optional.empty());
|
||||
when(repo.increment(eq(period), eq("AI"), eq(5L), any())).thenReturn(1);
|
||||
|
||||
service.accrue(period, BillingCategory.AI, 5, "op-sig-new");
|
||||
|
||||
verify(signatureRepo).saveAndFlush(any(MeteredInputSignature.class));
|
||||
verify(repo).increment(eq(period), eq("AI"), eq(5L), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void skipsConcurrentDuplicateClaim() {
|
||||
// Unseen this period, but a concurrent op wins the insert first → treated as within-window
|
||||
// chaining, not re-charged.
|
||||
when(signatureRepo.findByPeriodStartAndSignature(period, "op-sig-race"))
|
||||
.thenReturn(Optional.empty());
|
||||
when(signatureRepo.saveAndFlush(any()))
|
||||
.thenThrow(new DataIntegrityViolationException("dup"));
|
||||
|
||||
service.accrue(period, BillingCategory.AI, 5, "op-sig-race");
|
||||
|
||||
verify(repo, never()).increment(any(), any(), anyLong(), any());
|
||||
verify(repo, never()).saveAndFlush(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void skipsRepeatWithinWorkflowWindow() {
|
||||
// Same input set seen moments ago → chaining → not re-charged; the window slides.
|
||||
MeteredInputSignature recent =
|
||||
new MeteredInputSignature(period, "op-sig", LocalDateTime.now());
|
||||
when(signatureRepo.findByPeriodStartAndSignature(period, "op-sig"))
|
||||
.thenReturn(Optional.of(recent));
|
||||
|
||||
service.accrue(period, BillingCategory.AI, 5, "op-sig");
|
||||
|
||||
verify(repo, never()).increment(any(), any(), anyLong(), any());
|
||||
verify(signatureRepo).save(recent); // window touched
|
||||
}
|
||||
|
||||
@Test
|
||||
void chargesRepeatOutsideWorkflowWindow() {
|
||||
// Same input set last seen well past the 5-minute window → an independent re-run → charged.
|
||||
MeteredInputSignature stale =
|
||||
new MeteredInputSignature(period, "op-sig", LocalDateTime.now().minusMinutes(10));
|
||||
when(signatureRepo.findByPeriodStartAndSignature(period, "op-sig"))
|
||||
.thenReturn(Optional.of(stale));
|
||||
when(repo.increment(eq(period), eq("AI"), eq(5L), any())).thenReturn(1);
|
||||
|
||||
service.accrue(period, BillingCategory.AI, 5, "op-sig");
|
||||
|
||||
verify(repo).increment(eq(period), eq("AI"), eq(5L), any());
|
||||
verify(signatureRepo).save(stale); // window touched
|
||||
}
|
||||
}
|
||||
+171
@@ -0,0 +1,171 @@
|
||||
package stirling.software.proprietary.accountlink;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyLong;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.times;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.verifyNoInteractions;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.springframework.scheduling.config.ScheduledTaskRegistrar;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class UsageSyncServiceTest {
|
||||
|
||||
@Mock private UsageCounterRepository counters;
|
||||
@Mock private AccountLinkSyncStateRepository syncState;
|
||||
@Mock private DeviceCredentialStore credentialStore;
|
||||
@Mock private AccountLinkClient client;
|
||||
@Mock private EntitlementCache entitlementCache;
|
||||
|
||||
private UsageSyncService service;
|
||||
private final LocalDateTime period = LocalDateTime.of(2026, 6, 1, 0, 0);
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
service =
|
||||
new UsageSyncService(
|
||||
counters,
|
||||
syncState,
|
||||
credentialStore,
|
||||
client,
|
||||
entitlementCache,
|
||||
new AccountLinkProperties());
|
||||
}
|
||||
|
||||
@Test
|
||||
void registersFixedDelayTaskWithConfiguredInterval() {
|
||||
AccountLinkProperties props = new AccountLinkProperties();
|
||||
props.getMetering().setSyncIntervalHours(6);
|
||||
UsageSyncService svc =
|
||||
new UsageSyncService(
|
||||
counters, syncState, credentialStore, client, entitlementCache, props);
|
||||
|
||||
ScheduledTaskRegistrar registrar = new ScheduledTaskRegistrar();
|
||||
svc.configureTasks(registrar);
|
||||
|
||||
// Pins the interval binding in CI — the old @Scheduled SpEL only resolved at flags-on boot.
|
||||
assertThat(registrar.getFixedDelayTaskList()).hasSize(1);
|
||||
assertThat(registrar.getFixedDelayTaskList().get(0).getIntervalDuration())
|
||||
.isEqualTo(Duration.ofHours(6));
|
||||
}
|
||||
|
||||
private static DeviceCredential credential() {
|
||||
DeviceCredential c = new DeviceCredential();
|
||||
c.setDeviceId("dev-1");
|
||||
c.setDeviceSecret("sec-1");
|
||||
return c;
|
||||
}
|
||||
|
||||
private static UsageCounter counter(LocalDateTime period, String category, long cumulative) {
|
||||
return new UsageCounter(period, category, cumulative, LocalDateTime.now());
|
||||
}
|
||||
|
||||
private static InstanceEntitlement entitled() {
|
||||
return new InstanceEntitlement(true, 0, 0, null, EntitlementState.OK);
|
||||
}
|
||||
|
||||
@Test
|
||||
void notLinkedSkipsEntirely() {
|
||||
when(credentialStore.get()).thenReturn(Optional.empty());
|
||||
|
||||
service.syncNow();
|
||||
|
||||
verifyNoInteractions(client, entitlementCache);
|
||||
verify(counters, never()).findPeriodsWithUnsyncedUsage();
|
||||
}
|
||||
|
||||
@Test
|
||||
void nothingPendingStillForcesEntitlementRefresh() {
|
||||
when(credentialStore.get()).thenReturn(Optional.of(credential()));
|
||||
when(counters.findPeriodsWithUnsyncedUsage()).thenReturn(List.of());
|
||||
|
||||
service.syncNow();
|
||||
|
||||
// No usage to report, so nothing is sent and no markers advance — but the sync still forces
|
||||
// an entitlement refresh so an out-of-band plan change (e.g. a just-completed subscription)
|
||||
// surfaces on the gate immediately instead of waiting out the entitlement-cache TTL.
|
||||
verifyNoInteractions(client);
|
||||
verify(syncState, never()).save(any());
|
||||
verify(entitlementCache, never()).accept(any());
|
||||
verify(entitlementCache).invalidate();
|
||||
verify(entitlementCache).current();
|
||||
}
|
||||
|
||||
@Test
|
||||
void reportsCumulativePerCategoryAndAdvancesSyncedMarkers() {
|
||||
AccountLinkSyncState state = new AccountLinkSyncState();
|
||||
state.setId(AccountLinkSyncState.SINGLETON_ID);
|
||||
state.setLastSyncSeq(5L);
|
||||
when(credentialStore.get()).thenReturn(Optional.of(credential()));
|
||||
when(counters.findPeriodsWithUnsyncedUsage()).thenReturn(List.of(period));
|
||||
when(counters.findByPeriodStart(period))
|
||||
.thenReturn(List.of(counter(period, "API", 12L), counter(period, "AI", 4L)));
|
||||
when(syncState.findById(AccountLinkSyncState.SINGLETON_ID)).thenReturn(Optional.of(state));
|
||||
InstanceEntitlement fresh = entitled();
|
||||
when(client.reportUsage(
|
||||
eq("dev-1"), eq("sec-1"), eq(6L), eq(period), eq(12L), eq(4L), eq(0L)))
|
||||
.thenReturn(fresh);
|
||||
|
||||
service.syncNow();
|
||||
|
||||
// Seq advanced from 5 → 6 and the report carried the per-category cumulative.
|
||||
verify(client)
|
||||
.reportUsage(eq("dev-1"), eq("sec-1"), eq(6L), eq(period), eq(12L), eq(4L), eq(0L));
|
||||
// Only categories with usage are marked; AUTOMATION (0) is skipped.
|
||||
verify(counters).markSynced(period, "API", 12L);
|
||||
verify(counters).markSynced(period, "AI", 4L);
|
||||
verify(counters, never()).markSynced(eq(period), eq("AUTOMATION"), anyLong());
|
||||
// Two saves: the pre-report seq reservation + the post-success timestamp.
|
||||
verify(syncState, times(2)).save(state);
|
||||
verify(entitlementCache).accept(fresh);
|
||||
}
|
||||
|
||||
@Test
|
||||
void transportFailureReservesSeqButLeavesMarkersUntouched() {
|
||||
AccountLinkSyncState state = new AccountLinkSyncState();
|
||||
state.setId(AccountLinkSyncState.SINGLETON_ID);
|
||||
when(credentialStore.get()).thenReturn(Optional.of(credential()));
|
||||
when(counters.findPeriodsWithUnsyncedUsage()).thenReturn(List.of(period));
|
||||
when(counters.findByPeriodStart(period)).thenReturn(List.of(counter(period, "API", 12L)));
|
||||
when(syncState.findById(AccountLinkSyncState.SINGLETON_ID)).thenReturn(Optional.of(state));
|
||||
when(client.reportUsage(any(), any(), anyLong(), any(), anyLong(), anyLong(), anyLong()))
|
||||
.thenReturn(null);
|
||||
|
||||
service.syncNow();
|
||||
|
||||
verify(counters, never()).markSynced(any(), any(), anyLong());
|
||||
verify(syncState, times(1)).save(state); // seq reserved, success not recorded
|
||||
verify(entitlementCache).accept(null); // nothing fresh adopted
|
||||
}
|
||||
|
||||
@Test
|
||||
void revokedAbortsWithoutMarkingOrAdoptingEntitlement() {
|
||||
AccountLinkSyncState state = new AccountLinkSyncState();
|
||||
state.setId(AccountLinkSyncState.SINGLETON_ID);
|
||||
when(credentialStore.get()).thenReturn(Optional.of(credential()));
|
||||
when(counters.findPeriodsWithUnsyncedUsage()).thenReturn(List.of(period));
|
||||
when(counters.findByPeriodStart(period)).thenReturn(List.of(counter(period, "API", 12L)));
|
||||
when(syncState.findById(AccountLinkSyncState.SINGLETON_ID)).thenReturn(Optional.of(state));
|
||||
when(client.reportUsage(any(), any(), anyLong(), any(), anyLong(), anyLong(), anyLong()))
|
||||
.thenThrow(new AccountLinkClient.RevokedException(403));
|
||||
|
||||
service.syncNow();
|
||||
|
||||
verify(counters, never()).markSynced(any(), any(), anyLong());
|
||||
verify(entitlementCache, never()).accept(any());
|
||||
}
|
||||
}
|
||||
+30
@@ -0,0 +1,30 @@
|
||||
package stirling.software.proprietary.billing;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
class BillingCategoryClassifierTest {
|
||||
|
||||
@Test
|
||||
void automationWinsOverEverything() {
|
||||
assertEquals(
|
||||
BillingCategory.AUTOMATION, BillingCategoryClassifier.classify(true, true, true));
|
||||
}
|
||||
|
||||
@Test
|
||||
void aiWinsOverApiKey() {
|
||||
assertEquals(BillingCategory.AI, BillingCategoryClassifier.classify(false, true, true));
|
||||
}
|
||||
|
||||
@Test
|
||||
void apiKeyWhenNotAutomationOrAi() {
|
||||
assertEquals(BillingCategory.API, BillingCategoryClassifier.classify(false, false, true));
|
||||
}
|
||||
|
||||
@Test
|
||||
void bypassedWhenNoSignal() {
|
||||
assertEquals(
|
||||
BillingCategory.BYPASSED, BillingCategoryClassifier.classify(false, false, false));
|
||||
}
|
||||
}
|
||||
+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
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user