Compare commits

...
Author SHA1 Message Date
Anthony Stirling 529a4004ed Stop the modern wordmark resolving to the classic artwork 2026-08-27 17:19:31 +01:00
ConnorYohandJames Brunton 732ef18ae5 feat(account-link): redirect-based connect handshake for self-hosted linking (#7494)
Links a self-hosted instance to a SaaS team over an ordinary redirect,
and leaves the admin's browser holding a Stirling session at the same
time.

## The problem

A self-hosted server needs a device credential bound to a SaaS team, and
the admin's Supabase JWT must never reach the instance backend. Three
things ruled out the obvious approaches:

- **A customer hostname can never be in Supabase's redirect
allow-list**, so the sign-in cannot happen on the instance's own origin.
That is why SSO and sign-up did not work for linking at all.
- **A device credential identifies a server, not a person.** Every
attended portal read (Usage, Billing, Documents, Infrastructure) goes
through `getPortalSaasToken()` and needs a *user* session, so a
credential-only link left all of them asking for a second sign-in.
- **The previous design relayed a JWT** from the browser into the
instance, which is the thing we wanted to avoid. That path is deleted
here.

## The solution

Redirect and nonce, modelled on desktop's
`authService.loginWithSelfHostedOAuth`: mint a nonce, hand the browser
off, accept only a callback carrying that nonce back. Desktop has the OS
route the reply; self-hosted has no OS hop, so our own approval page
performs it. That is the point — the human half happens on an origin we
control.

```
instance                     SaaS                        admin's browser
   |  POST connect/request     |                                |
   |  (name, callback, nonce,  |                                |
   |   claim-secret hash)      |                                |
   |-------------------------->|                                |
   |  <- requestId + authorizeUrl                               |
   |                           |      GET /link?request=...     |
   |                           |<-------------------------------|
   |                           |  sign in (SSO works here),     |
   |                           |  see ACCOUNT + ORIGIN, approve |
   |                           |------------------------------->|
   |                           |   302 callback#nonce+session    |
   |  POST connect/claim       |                                |
   |  (requestId, claim secret)|                                |
   |-------------------------->|                                |
   |  <- device credential     |                                |
```

Four properties carry the safety, and each is stated in the code because
each is easy to lose in a refactor:

- **The redirect target is never caller-supplied.** Validated once at
creation, then read back from the stored row, so nothing in the approval
page's URL can steer the token elsewhere.
- **Approval and minting are separate.** Approval records the team and
hands out nothing usable; the credential is minted only on claim,
authenticated by a secret that never entered a browser.
- **A re-authentication cannot move a server between teams.** The team
is pinned at creation from the credential only that instance holds, so
an approver from another team gets `WRONG_TEAM` instead of a rebind.
- **The approver has to confirm what they are binding.** The page shows
the address and the signed-in account, with a way to switch, and a
checkbox naming the address gates the approve button. The name the
server reports is deliberately not shown: the requester picks it on an
unauthenticated endpoint, and its honest value is the hostname already
in the address.

The session rides the URL fragment, so it stays out of access logs and
`Referer`, and is stripped before anything awaits. The claim is
row-locked, so one approval mints once. A request lives 30 minutes; a
settled one is not offered again, since approving it fails server-side.

Signing in mid-flow no longer loses the request. The id is kept on the
SaaS origin and resumed after any sign-in, which is what makes creating
an account work: the confirmation email opens a new tab, where the
`next` parameter is gone. Reading it does not consume it — the request
may be open in two tabs — and only a recorded decision retires it.

The result lands as a modal over the portal the admin started from, and
the portal re-reads its link status so the page behind agrees with the
modal.

Plaintext `http://` callbacks are accepted rather than refused, because
many self-hosted instances legitimately run plain HTTP on a private
network; the address carries a warning icon explaining the risk, derived
server-side so a requester cannot suppress it. Hard-refusing `http://`
to a public IP literal is a reasonable follow-up; a bare hostname can't
be classified without a DNS lookup, so the warning stays the general
mechanism.

## Configuration

Four surfaces. Placeholders below, not values.

**SaaS backend**

| Setting | Needed | Why |
|---|---|---|
| `stirling.billing.account-link.enabled` | Yes, `true` | The connect
controller and service are `@ConditionalOnProperty` with no default, so
without it the endpoints do not exist. |
| `system.frontendUrl` | Only when the approval page is not on the API's
own origin | Where the approver is sent. Must include the app's base
path if it is served under one, or the redirect misses `/link`. |

**SaaS frontend**

| Setting | Needed | Why |
|---|---|---|
| `VITE_SUPABASE_URL`, `VITE_SUPABASE_PUBLISHABLE_DEFAULT_KEY` | Yes |
Its own sign-in. Must be the project the SaaS backend validates tokens
against. |
| `RUN_SUBPATH` | Only if served under a subpath | Moves the approval
page to `<base>/<subpath>/link`, so `system.frontendUrl` has to agree. |

**Self-hosted backend**

| Setting | Needed | Why |
|---|---|---|
| `stirling.billing.account-link.enabled` | Yes, `true` | Defaults to
`false`. |
| `stirling.billing.account-link.saas-base-url` | Yes | Origin of the
SaaS API it links to. Not the SaaS frontend. |
| `system.frontendUrl` | Optional | Externally reachable base URL for
the callback. Otherwise derived from the request's `Origin`, which is
right for ordinary deployments and wrong behind a rewriting proxy. |

**Self-hosted frontend**

| Setting | Needed | Why |
|---|---|---|
| `VITE_SUPABASE_URL`, `VITE_SUPABASE_PUBLISHABLE_DEFAULT_KEY` | Yes |
Accepts the session handed over in the callback fragment. |
| `VITE_SAAS_API_URL` | For Usage and Billing | Attended reads go to the
SaaS API with the admin's token. Absent, those surfaces stay on the
mock. |
| `VITE_INCLUDE_PORTAL` | Production builds | Dev builds include the
portal automatically; without it there is no link UI and no callback
route. |

Two things worth stating because neither fails loudly:

- **Both frontends must use the URL *and* key of the same Supabase
project**, and the same one the SaaS backend validates against. A key
from one project with a URL from another is accepted by the browser and
rejected by Supabase, which surfaces much later as "session expired" on
Usage rather than as an error at hand-over.
- **The Supabase redirect allow-list must contain the SaaS app's
`/auth/callback`**, since a confirmation email returns through it.
Entries are matched exactly.

- **`system.frontendUrl` is the existing setting for this**, not a new
one, so each side reads its own value and there is nothing extra to
configure. It also gates share links, so on a stack with storage and
sharing already on, setting it here turns those on too.

The self-hosted side deliberately does **not** configure where the
approval page lives — SaaS answers that in the connect-request reply,
being the only party that knows.

Also here, because testing this needs two stacks side by side:
`linked:staging` / `linked:dev` (which derive `system.frontendUrl` and
`RUN_SUBPATH` themselves), the missing `frontend:staging:saas`, and a
per-mode vite `cacheDir` — two dev servers in different modes otherwise
re-optimise over one shared dep cache.

## How to test

Automated and green: `task frontend:check:all` plus both backend
modules. `ConnectRequestServiceTest` covers callback validation, the
per-IP cap, single-use approval, claim outcomes, expiry, `WRONG_TEAM`
and reauth confirming without minting; `ConnectServiceTest` covers
callback-resolution precedence including a foreign-origin callback being
discarded; `ConnectControllerTest` covers the authorize URL, including
the forwarded-header path and only the first hop being trusted;
`ConnectCallback.test.tsx` covers the fragment being stripped
synchronously and malformed fragments refused;
`LinkAccountModal.test.tsx` covers link and reauth hitting different
endpoints.

Manual walkthrough:

1. `task linked:staging` — added here; brings up a SaaS stack and a
self-hosted instance pointed at it, on discovered ports, and prints the
four addresses.
2. Open the link-account modal in the self-hosted portal and continue.
Expect the SaaS approval page at `/link?request=<id>`.
3. Sign in as a team leader, or create an account and confirm the email.
Either way you should come back to the approval page.
4. Tick the acknowledgement and approve. Expect the fragment gone from
the address bar immediately, a result modal over the portal, the portal
showing linked without a reload, and attended reads (Usage, Billing)
working without a second sign-in.
5. Repeat, approving as a member of a different team. Expect a refusal,
not a rebind.

## Outstanding

- #7415 to be reworked against this design once this lands.
- **No SaaS-side UI to disconnect a server.** `GET
/account-link/instances` and `POST /account-link/instances/{id}/revoke`
are already team-scoped and leader-gated, and the portal has a panel
that uses them, but
`portal-saas/components/settings/accountLinkSettings.tsx` exports `null`
on the reasoning that "SaaS has no account-link concept". That held when
linking was a self-hosted admin managing their own instance; here a
leader approves a server they may not administer, and has no way to
withdraw it. The seam to fill is that one file. Expected to land with
the CTA work in #7415.

---------

Co-authored-by: James Brunton <jbrunton96@gmail.com>
2026-08-27 10:32:32 +00:00
EthanHealy01andClaude f7a2c626c9 Persist the workbench session across the editor/processor switch (#7654)
## What

Switching editor -> processor (or reloading) unmounts every editor
provider, which emptied the workbench. This PR mirrors the workbench
into per-tab sessionStorage and refills an empty one from that record on
the next mount:

- **Files, selection, view and active document survive** the shell
switch and reloads. Each recorded file is resolved to its *current leaf*
version on restore, so a file versioned by a policy or another tab comes
back at its latest state.
- **The switch back lands where the user left**: the processor sidebar's
"editor" button consumes a one-shot return path saved at switch time.
- **The app switch respects unsaved changes**: `useOtherAppSwitch`
(proprietary + saas) now routes through `requestNavigation`, so the same
warning guards it as any other navigation.
- Desktop shadows `WorkbenchSessionPersistence` with a stub (OS-launched
files own boot there).

## How to test

I've run through each of these manually:

- Upload several PDFs in the editor, select a couple, and switch to the
Active Files grid. Click "Open PDF Processor" in the sidebar footer,
then switch back to the editor. The same files, selection and view
should return, and you should land on the editor page you left.
- Open a document in the viewer, then reload the tab. The workbench
should refill and come back on the viewer with the same document active.
- With unsaved changes in a tool, click the processor switch. The
unsaved-changes warning should appear, and the switch should only
proceed if you confirm.
- Open a second browser tab with different files. Each tab should
restore its own workbench independently (the record is per-tab
sessionStorage).
- While in the processor, delete one of the open files from storage,
then switch back. The remaining files should restore and a warning toast
should report "Restored X of Y files".

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-08-27 10:12:48 +00:00
EthanHealy01 caeca0b88a Fix classification escalation: the local pass was claiming the server dispatch key (#7667)
Follow-up to #7580: the escalation it added could never fire.

## What's broken

The auto-run skips a policy that has already run on a file, keyed on
`(categoryId, fileId)`. `recordRunStart` claims that key — and #7580 has
the **browser-side first pass** record its own run under `categoryId:
"classification"` for the uploaded file. So the local heuristic ticks
the very key the server escalation checks, and the AI is never asked, at
any confidence.

Trigger is the default seeded setup: **Classification as the only
on-upload policy**, and a local verdict below `high`. Any other
on-upload policy masks it, because classification then targets that
policy's output — a new file id whose key was never claimed. That's why
this went unnoticed.

Two smaller faults in the same path:

- A chained output carried no `classificationConfidence`, so
`shouldDispatchToAi` waited for a verdict that could never arrive (a
tool-derived file gets no local pass).
- Browser-local runs were polled against the server: 3 × 404 per file,
after which `MAX_NOT_FOUND` marked a local run that had actually
**succeeded** as `FAILED`.

## The fix

- `PolicyRunRecord.browserLocal`; `recordRunStart` skips the dispatch
claim for such a run. It is the first pass, not the policy's run.
- The local pass meters under `classification:local-meter` instead of
the category id, so metering dedupe survives without suppressing
dispatch.
- The poll effect skips browser-local runs.
- `CONSUME_FILES` inherits `classificationConfidence` alongside the
labels, so the verdict survives a version bump.

## How to test

Download
[`low-confidence-classification.pdf`](https://github.com/Stirling-Tools/Stirling-PDF/raw/fix/chained-classification-confidence/frontend/editor/src/proprietary/services/heuristic/fixtures/low-confidence-classification.pdf)
(checked in as a fixture, verdict pinned by a test).

With **Classification as the only on-upload policy**, upload it and
watch the Network tab:

- **Before:** no `POST /api/v1/policies/{id}/run` for classification,
ever. Console shows `local-classification-*` 404s.
- **After:** exactly one, and the engine receives `POST
/api/v1/documents/classify`.

Judge it on that request, not on the resulting label — the model's
answer varies, so a label comparison can pass or fail for the wrong
reason.

Headless equivalent:

```
npx vitest run --project proprietary src/proprietary/components/policies/usePolicyAutoRun.escalation.test.tsx
```

Passes here, fails on `main` on "asks the AI about an unsure verdict
even though the local pass already ran". Its other two cases pass on
both, so the guards still hold: a confident verdict still costs nothing,
and a file with no verdict yet still waits rather than racing the free
pass.

New tests drive the **real** run store — mocking it is what let this
through.

`task frontend:check`: 255 files / 2202 tests.
2026-08-26 17:34:13 +00:00
James Brunton c93feb5dfc Remove a bunch of unnecessary casts from the frontend (#7662)
# Description of Changes
Originally, I wanted to re-enable typed linting on our repo but using
Oxlint this time to avoid the memory and speed issues that ESLint was
causing. Unfortunately, it's not stable enough yet to actually use on
our repo (although it is close, I suspect it'll be stable enough fairly
soon). I was able to remove many of the unnecessary casts that it found
though, so even though this won't be enforced, it's still worth cleaning
up what I've found.
2026-08-26 14:09:55 +00:00
Anthony Stirling f945cc7dc6 Regenerate expired test certificates and guard against future expiry (#7682)
The bundled signing test certificates expired at **07:41:10 UTC on
2026-08-26**. They were issued exactly one year earlier, so they went
from fine to fatal mid-morning with no warning, and they take down
`main` and every open branch, not just one PR.

First casualty was the `docker-compose-tests` job on #6802, which
started at 07:45:

```
java.security.cert.CertificateExpiredException: NotAfter: Wed Aug 26 07:41:10 UTC 2026
    at CreateSignatureBase.checkValidity(CreateSignatureBase.java:159)
    at CertSignControllerTest.testSignPdfWithPkcs12(CertSignControllerTest.java:205)
```

```
$ openssl x509 -in app/core/src/test/resources/certs/test-cert.pem -noout -dates
notBefore=Aug 26 07:41:10 2025 GMT
notAfter =Aug 26 07:41:10 2026 GMT
```

## What was broken

`CertSignControllerTest` (7 tests) and `PdfSigningServiceImplTest` (2)
fail outright. `ValidateSignatureControllerMoreTest` and
`CertificateValidationServiceMoreTest` read the same fixtures.

Auditing the rest of the repo turned up three more time bombs that had
not gone off yet:

| Fixture | Was | Problem |
|---|---|---|
| `app/core/.../certs/test-cert.*` + `test-key.*` | expired 2026-08-26 |
**already breaking every branch** |
| `test-certs/valid-test.p12`, `valid-test.jks` (proprietary + frontend
copies) | expire 2027-03-25 | same failure, seven months out |
| `test-certs/not-yet-valid-test.p12` | valid **from** 2027-03-25 |
becomes valid, so its test silently stops proving anything, on the same
day |

## What this does

**Regenerates every fixture** with the identical subject DN, alias,
password, key size and signature algorithm as before, changing only the
validity window. Nothing that any test asserts on has moved.

- valid fixtures: `2025-01-01` to `2125-01-01`
- `not-yet-valid-test.p12`: `2125-01-01` to `2126-01-01`, so it stays in
the future
- `expired-test.p12`: pinned to its permanently-past 2024 window

**Adds `scripts/generate-test-certs.sh`** as the source of truth, so the
next regeneration is one command instead of archaeology. It documents
every DN, alias and password, pins the validity windows, and runs on
Linux, macOS and Git Bash.

**Adds two guard tests** that fail with an actionable message, naming
the script, while there is still a year of runway:

- `BundledTestCertificateExpiryTest` (app/core) checks all seven formats
parse, are in their validity window, and have more than 365 days left
- `BundledWorkflowCertificateExpiryTest` (proprietary) does the same for
the valid pair, and additionally asserts the expired fixture is still
expired and the not-yet-valid one is still in the future

That last pair matters: those two fixtures exist to test a validity
outcome, and each one silently stops testing anything once the clock
passes its window.

## Verification

Run locally against the regenerated bytes, on the exact content
committed here:

```
./gradlew :stirling-pdf:test --tests '*CertSignControllerTest*' --tests '*BundledTestCertificateExpiryTest*' \
  --tests '*PdfSigningServiceImplTest*' --tests '*ValidateSignatureControllerMoreTest*' \
  --tests '*CertificateValidationServiceMoreTest*'
BUILD SUCCESSFUL

./gradlew :proprietary:test --tests '*BundledWorkflowCertificateExpiryTest*' --tests '*CertificateValidationIntegrationTest*' \
  --tests '*SigningFinalizationServiceMoreTest*' --tests '*ServerCertificateServiceTest*' \
  --tests '*CertificateSubmissionValidatorTest*' --tests '*WorkflowSessionServiceTest*'
BUILD SUCCESSFUL
```

`spotlessCheck` passes on both modules.
2026-08-26 10:43:17 +00:00
dependabot[bot]andAnthony Stirling 72b7892312 Translations + com.squareup.okhttp3:okhttp-bom from 5.3.2 to 5.4.0 (#7599)
Co-authored-by: Anthony Stirling <77850077+Frooodle@users.noreply.github.com>
Signed-off-by: dependabot[bot] <support@github.com>
2026-08-26 08:05:45 +01:00
James Brunton 353df7a647 Improve modals in Sources page in Processor (#7664)
# Description of Changes

Various changes throughout to try and convert the bulk of the dev UI
sources modals to production quality. Changes include:

- Fixing inconsistencies between different modals
- Hide things users will rarely need to change behind advanced
- Removed clutter in the UI
- Renaming settings in terms that the user will understand and care
about

<img width="2360" height="3068" alt="image"
src="https://github.com/user-attachments/assets/2c637e8f-bc1b-4c7e-98cb-d836ae626ba5"
/>

<img width="2360" height="3008" alt="image"
src="https://github.com/user-attachments/assets/d894aadc-7c83-41d1-b614-881637a6bd34"
/>
2026-08-25 15:59:16 +00:00
James Brunton 0d75715af2 Fix flaky e2e tests (#7595)
# Description of Changes
e2e Playwright tests are currently failing intermittently on all
platforms for different reasons, most notably WebKit, which seems to
fail much more often than the others. This PR attempts to fix the
issues. I've ran the e2e tests a few times now and they don't seem to be
inconsistent any more, but it's difficult to tell if all the issues are
genuinely fixed due to the inconsistent nature. As far as I can tell,
I've not broken anything though.
2026-08-25 15:56:44 +00:00
github-actions[bot]andFrooodle b44202185a chore: update Gradle to 9.7.1 (#7673)
Automated update of the Gradle wrapper and Gradle Docker build images.

Gradle version: `9.7.1`
Docker image: `gradle:9.7.1-jdk25`

Co-authored-by: Frooodle <77850077+Frooodle@users.noreply.github.com>
2026-08-25 12:36:38 +00:00
EthanHealy01 49c1e75ced Surface recorded failures in a notification bell (Review Flow PR 4) (#7478)
Review Flow PR 4. Stacked on #7477. Recorded failures appear in a
notification bell, showing each reader the failures they are allowed to
see and the actions they can actually take.

Scope is deliberately viewing and routing only. Resolving a failure —
retry, decrypt-and-retry — is #7479, which also brings the write path
for it; nothing resolution-shaped ships here, not even dark.

## What's added

**A notification bell** in the editor and the processor shell. Polls
`GET /api/v1/notifications` every 30 seconds, shows an unread badge, and
lists open failures newest first. Each row shows the failure's title,
its message with **Copy error** and **Show full message** chips, an
occurrence count, and its available actions.

**A notification API** (`stirling.software.proprietary.notification`),
derived from failures on read rather than stored in its own table:

| Route | Purpose |
|---|---|
| `GET /api/v1/notifications` | the caller's open failures, newest first
|

Read-only by design: every action the bell offers is one the client runs
on its own device, so there is nothing to post back. Every id is
prefixed (`failure:<uuid>`), so the bell never holds a raw failure id it
could hand to a failure endpoint.

**Per-reader actions.** A `FailureKind` declares each action with an
audience (`OWNER`, `TEAM_REVIEWER`, `ANYONE_WHO_SEES`). The server
resolves that against the reader and derives `Ownership` (`MINE` /
`THEIRS` / `UNOWNED`) from the row's actor, so an admin reviewing
someone else's failure is not offered a document their browser does not
hold. Adding a failure kind requires no frontend change.

**Server-run and client-run actions are distinguished.**
`FailureActionId` carries an `Execution` facet; the registry requires a
bean only for server actions, and dispatching a client action on the
failure surface returns 400. The notification projection goes further:
it carries only client-run offers, so the bell cannot be sent a button
it would refuse to draw.

**Actions in the bell:** at most two. The owner of the document gets
**View file** (opens it in the editor); a team reviewer gets **View in
processor** (dev builds only). Dismiss stays on the failure queue in
`/processor/documents` — deciding a failure's fate belongs to the review
surface, not the panel that announces it. An action id the build has not
wired is skipped rather than rendered dead, so the server can ship new
kinds ahead of the clients that understand them.

**Attended policy runs record their document.** `POST
/api/v1/policies/{id}/run` accepts an optional opaque `fileId`, recorded
when the run carries exactly one primary document. This is what lets a
repeat fold onto one incident instead of opening a new one per upload,
lets deleting the file clear its failure, and lets the owner open the
document from the row.

## Behaviour changes

- **The bell re-reads as soon as a failure you caused is recorded**,
rather than leaving you to wait out a poll interval for news of your own
upload. Applies to a failed tool run and to a policy run reaching
`FAILED`. Other people's failures still arrive on the poll, which is
what it is for.
- **An action the reader cannot use is not rendered.** Where the server
gave a reason for withholding it, that reason appears as the row's
one-line note. An action that was never offered to that reader produces
no note.
- **Deleting a document closes every incident about it that the deleter
caused**, including a failed policy run on their own upload, so a user's
own errors leave the bell with the file rather than lingering with a
dead button.
- **The failures list in `/processor/documents` stays behind
`import.meta.env.DEV`**, and View in processor is gated to match so it
cannot navigate to a section that is not mounted. Both lift when
failures get their own review screen.
- **One poll for all bells.** The bell is mounted in three places; the
list, document lookups and read marker are shared, so mounting more than
one does not multiply requests.
- `ACKNOWLEDGE` is no longer offered by any kind. The id, bean and
status remain so existing rows stay readable.

## Known limits

- The poll does not pause when the tab is hidden.
- No retention or per-team cap on `file_run_events`.

## How to test

Needs a proprietary or SaaS build with login enabled. `task dev:all`,
then sign in.

1. **Create a failure.** Add a password-protected PDF to the editor and
choose **Skip for now** when it asks to unlock. The upload starts a
policy run that fails on it.
2. **Watch the bell.** The badge should appear within a second or two,
not after 30 — this is the refresh-on-failure path. Open it: a row
titled "Password-protected document" with the error message and the two
chips.
3. **The buttons should be View file and View in processor, nothing
else.** No Dismiss and no retries: dispositions live on the review
surface, resolutions in #7479.
4. **View file** closes the panel and selects that document in the
editor.
5. **Dismiss from the queue instead.** Open `/processor/documents` (dev
build), find the row in the failures list and dismiss it there; the bell
drops it on its next read.
6. **Confirm the local-document probe.** Create a second failure, then
delete that file from the editor and reload. Its incident closes with
it; a row whose document is still present keeps **View file**.
7. **Confirm attribution end to end.** Sign in as a plain member, run a
shared policy on your own upload so it fails. The member sees their own
row in the bell. Sign in as the team leader: they see it too, but with
**View in processor** instead of **View file**, because the document is
not in their browser.
8. **Confirm folding.** Add the same locked PDF again and skip again.
The existing row's occurrence count increases rather than a second row
appearing.
9. **Confirm one poll for many bells.** Open the editor and the
processor in two tabs. Each tab issues its own poll, but within a tab
the several mounted bells share one — the Network tab should show one
`GET /api/v1/notifications` per 30s per tab, not three.

## Migration

None. No new column and no new value in any CHECK-constrained enum;
`CheckConstrainedEnumsTest` fails if that changes.
2026-08-24 22:29:41 +00:00
stirlingbot[bot] 826e487f00 Update Frontend 3rd Party Licenses (#7650)
Auto-generated by stirlingbot[bot]

This PR updates the frontend license report based on changes to
package.json dependencies.

Signed-off-by: stirlingbot[bot] <stirlingbot[bot]@users.noreply.github.com>
Co-authored-by: stirlingbot[bot] <195170888+stirlingbot[bot]@users.noreply.github.com>
2026-08-24 22:16:25 +00:00
stirlingbot[bot] bcad2cd486 Update Backend 3rd Party Licenses (#7653)
Auto-generated by stirlingbot[bot]

This PR updates the backend license report based on dependency changes.

Signed-off-by: stirlingbot[bot] <stirlingbot[bot]@users.noreply.github.com>
Co-authored-by: stirlingbot[bot] <195170888+stirlingbot[bot]@users.noreply.github.com>
2026-08-24 22:04:00 +00:00
372 changed files with 17682 additions and 2186 deletions
+62 -3
View File
@@ -40,12 +40,15 @@ tasks:
AIENGINE_ENABLED: '{{.AIENGINE_ENABLED | default "false"}}'
AIENGINE_TIMEOUTSECONDS: '{{.AIENGINE_TIMEOUTSECONDS | default "120"}}'
SECURITY_ENABLELOGIN: '{{.SECURITY_ENABLELOGIN | default ""}}'
# Set by dev:linked. Inline rather than in `env:` so an empty value emits nothing
# and cannot blank the committed default.
ACCOUNT_LINK_SAAS_BASE_URL: '{{.ACCOUNT_LINK_SAAS_BASE_URL | default ""}}'
env:
SERVER_PORT: '{{.PORT}}'
cmds:
- cmd: '{{if .AIENGINE_URL}}AIENGINE_URL={{.AIENGINE_URL}} AIENGINE_ENABLED={{.AIENGINE_ENABLED}} AIENGINE_TIMEOUTSECONDS={{.AIENGINE_TIMEOUTSECONDS}} {{end}}{{if .SECURITY_ENABLELOGIN}}SECURITY_ENABLELOGIN={{.SECURITY_ENABLELOGIN}} {{end}}cmd /c ".\gradlew.bat :stirling-pdf:bootRun"'
- cmd: '{{if .AIENGINE_URL}}AIENGINE_URL={{.AIENGINE_URL}} AIENGINE_ENABLED={{.AIENGINE_ENABLED}} AIENGINE_TIMEOUTSECONDS={{.AIENGINE_TIMEOUTSECONDS}} {{end}}{{if .SECURITY_ENABLELOGIN}}SECURITY_ENABLELOGIN={{.SECURITY_ENABLELOGIN}} {{end}}{{if .ACCOUNT_LINK_SAAS_BASE_URL}}STIRLING_BILLING_ACCOUNT_LINK_ENABLED=true STIRLING_BILLING_ACCOUNT_LINK_SAAS_BASE_URL={{.ACCOUNT_LINK_SAAS_BASE_URL}} {{end}}cmd /c ".\gradlew.bat :stirling-pdf:bootRun"'
platforms: [windows]
- cmd: '{{if .AIENGINE_URL}}AIENGINE_URL={{.AIENGINE_URL}} AIENGINE_ENABLED={{.AIENGINE_ENABLED}} AIENGINE_TIMEOUTSECONDS={{.AIENGINE_TIMEOUTSECONDS}} {{end}}{{if .SECURITY_ENABLELOGIN}}SECURITY_ENABLELOGIN={{.SECURITY_ENABLELOGIN}} {{end}}./gradlew :stirling-pdf:bootRun'
- cmd: '{{if .AIENGINE_URL}}AIENGINE_URL={{.AIENGINE_URL}} AIENGINE_ENABLED={{.AIENGINE_ENABLED}} AIENGINE_TIMEOUTSECONDS={{.AIENGINE_TIMEOUTSECONDS}} {{end}}{{if .SECURITY_ENABLELOGIN}}SECURITY_ENABLELOGIN={{.SECURITY_ENABLELOGIN}} {{end}}{{if .ACCOUNT_LINK_SAAS_BASE_URL}}STIRLING_BILLING_ACCOUNT_LINK_ENABLED=true STIRLING_BILLING_ACCOUNT_LINK_SAAS_BASE_URL={{.ACCOUNT_LINK_SAAS_BASE_URL}} {{end}}./gradlew :stirling-pdf:bootRun'
platforms: [linux, darwin]
dev:bundled:
@@ -84,6 +87,8 @@ tasks:
AIENGINE_URL: '{{.AIENGINE_URL}}'
AIENGINE_ENABLED: '{{.AIENGINE_ENABLED}}'
AIENGINE_TIMEOUTSECONDS: '{{.AIENGINE_TIMEOUTSECONDS}}'
APP_BASE_URL: '{{.APP_BASE_URL}}'
BASE_PATH: '{{.BASE_PATH}}'
staging:saas:
desc: "Start SaaS backend against the shared v3 staging project"
@@ -95,10 +100,47 @@ tasks:
AIENGINE_URL: '{{.AIENGINE_URL}}'
AIENGINE_ENABLED: '{{.AIENGINE_ENABLED}}'
AIENGINE_TIMEOUTSECONDS: '{{.AIENGINE_TIMEOUTSECONDS}}'
APP_BASE_URL: '{{.APP_BASE_URL}}'
BASE_PATH: '{{.BASE_PATH}}'
dev:linked:
desc: "Self-hosted backend linked to a locally running SaaS backend (see task linked:*)"
ignore_error: true
vars:
PORT: '{{.PORT | default "8080"}}'
SAAS_BASE_URL: '{{.SAAS_BASE_URL | default "http://localhost:8081"}}'
cmds:
- 'echo ">> self-hosted :{{.PORT}} linking to SaaS at {{.SAAS_BASE_URL}}"'
# The two backends run different STIRLING_FLAVOURs, which are different Gradle
# project graphs sharing one build/ tree. Waiting avoids overlapping builds; it
# does not make the sharing safe, so avoid rebuilding one while the other runs.
- cmd: |
n=0
while [ "$n" -lt 150 ]; do
if curl -s -m 2 "{{.SAAS_BASE_URL}}" >/dev/null 2>&1; then
echo ">> SaaS backend is up, starting self-hosted"
break
fi
n=$((n + 1))
{{if eq OS "windows"}}powershell -NoProfile -Command "Start-Sleep -Seconds 2"{{else}}sleep 2{{end}}
done
if [ "$n" -ge 150 ]; then
echo ">> SaaS backend never answered; starting anyway"
fi
- task: dev:proprietary
vars:
PORT: '{{.PORT}}'
ACCOUNT_LINK_SAAS_BASE_URL: '{{.SAAS_BASE_URL}}'
_run:saas:
internal: true
dotenv: ['app/.env.saas.local', 'app/.env.saas']
# The frontend files are here only for RUN_SUBPATH, which the authorize URL needs.
# Last, because dotenv is set-if-absent: app/* still decides everything else.
dotenv:
- 'app/.env.saas.local'
- 'app/.env.saas'
- 'frontend/editor/.env.saas.local'
- 'frontend/editor/.env.saas'
ignore_error: true
vars:
PORT: '{{.PORT | default "8080"}}'
@@ -111,12 +153,29 @@ tasks:
AIENGINE_URL: '{{.AIENGINE_URL | default ""}}'
AIENGINE_ENABLED: '{{.AIENGINE_ENABLED | default "false"}}'
AIENGINE_TIMEOUTSECONDS: '{{.AIENGINE_TIMEOUTSECONDS | default "120"}}'
# Empty is the same as unset: the property defaults to empty and is blank-checked.
APP_BASE_URL: '{{.APP_BASE_URL | default ""}}'
# Relocates configs/pipeline/logs, for a second backend in the same directory.
# Empty is the same as unset: the reader blank-checks it.
BASE_PATH: '{{.BASE_PATH | default ""}}'
env:
SERVER_PORT: '{{.PORT}}'
STIRLING_FLAVOR: saas
STIRLING_BASE_PATH: '{{.BASE_PATH}}'
AIENGINE_URL: '{{.AIENGINE_URL}}'
AIENGINE_ENABLED: '{{.AIENGINE_ENABLED}}'
AIENGINE_TIMEOUTSECONDS: '{{.AIENGINE_TIMEOUTSECONDS}}'
# Appends RUN_SUBPATH: the approval page is at <base>/link, so a subpath build
# serves it at <base>/app/link. An explicit value still wins.
SYSTEM_FRONTENDURL:
sh: |
if [ -n "${SYSTEM_FRONTENDURL:-}" ]; then
echo "${SYSTEM_FRONTENDURL}"
elif [ -n "{{.APP_BASE_URL}}" ] && [ -n "${RUN_SUBPATH:-}" ]; then
echo "{{.APP_BASE_URL}}/${RUN_SUBPATH}"
else
echo "{{.APP_BASE_URL}}"
fi
cmds:
# PROFILE_ARGS is empty when PROFILES=none, i.e. the bare `saas` profile
# against SAAS_DB_* (production).
+13 -3
View File
@@ -121,17 +121,17 @@ tasks:
sh: |
case "${SAAS_ENV:-dev}" in
staging) ref="${SAAS_STAGING_PROJECT_REF:?set it in app/.env.saas.local}" ;;
*) ref="${SAAS_DEV_PROJECT_REF:?set it in app/.env.saas.local, or run task staging:saas}" ;;
*) ref="${SAAS_DEV_PROJECT_REF:?set it in app/.env.saas.local, or pass SAAS_ENV=staging}" ;;
esac
echo "https://${ref}.supabase.co"
VITE_SUPABASE_PUBLISHABLE_DEFAULT_KEY:
sh: |
case "${SAAS_ENV:-dev}" in
staging) echo "${SAAS_STAGING_PUBLISHABLE_KEY:?set it in app/.env.saas.local}" ;;
*) echo "${SAAS_DEV_PUBLISHABLE_KEY:?set it in app/.env.saas.local}" ;;
*) echo "${SAAS_DEV_PUBLISHABLE_KEY:?set it in app/.env.saas.local, or pass SAAS_ENV=staging}" ;;
esac
cmds:
- 'echo ">> frontend Supabase target: $VITE_SUPABASE_URL"'
- 'echo ">> frontend {{.SAAS_ENV}}: Supabase $VITE_SUPABASE_URL, backend $BACKEND_URL"'
- npx vite editor --mode saas --port {{.PORT}}{{if .OPEN}} --open{{end}}
dev:
@@ -173,6 +173,16 @@ tasks:
OPEN: '{{.OPEN}}'
SAAS_ENV: '{{.SAAS_ENV}}'
staging:saas:
desc: "Start frontend dev server against the shared v3 staging project"
cmds:
- task: dev:saas
vars:
SAAS_ENV: staging
PORT: '{{.PORT}}'
BACKEND_URL: '{{.BACKEND_URL}}'
OPEN: '{{.OPEN}}'
dev:desktop:
desc: "Start frontend dev server in desktop mode"
deps:
+86
View File
@@ -121,6 +121,92 @@ tasks:
cmds:
- task: dev:_all
# No engine: linking never calls it.
linked:staging:
desc: "SaaS on the shared v3 project + a self-hosted instance linked to it"
cmds:
- task: linked:_all
vars: { SAAS_ENV: staging }
linked:dev:
desc: "SaaS on the current PR's preview branch + a self-hosted instance linked to it"
cmds:
- task: linked:_all
vars: { SAAS_ENV: dev }
linked:_all:
internal: true
vars:
SAAS_ENV: '{{.SAAS_ENV | default "staging"}}'
PORTS:
sh: '{{if eq OS "windows"}}{{.FIND_FREE_PORT_PS}} 8081 5174 8080 5173{{else}}{{.FIND_FREE_PORT_SH}} 8081 5174 8080 5173{{end}}'
SAAS_BACKEND_PORT: '{{index (splitList "\n" .PORTS) 0}}'
SAAS_FRONTEND_PORT: '{{index (splitList "\n" .PORTS) 1}}'
APP_BACKEND_PORT: '{{index (splitList "\n" .PORTS) 2}}'
APP_FRONTEND_PORT: '{{index (splitList "\n" .PORTS) 3}}'
deps:
# APP_BASE_URL is the SaaS *frontend*: the approval page is served by vite, not
# by the API. BASE_PATH moves this backend's configs/pipeline aside so it does not
# race the self-hosted one, which keeps ./configs and its existing database.
- task: 'backend:{{.SAAS_ENV}}:saas'
vars:
PORT: '{{.SAAS_BACKEND_PORT}}'
APP_BASE_URL: 'http://localhost:{{.SAAS_FRONTEND_PORT}}'
BASE_PATH: 'tmp/linked-saas'
- task: frontend:dev:saas
vars:
PORT: '{{.SAAS_FRONTEND_PORT}}'
BACKEND_URL: 'http://localhost:{{.SAAS_BACKEND_PORT}}'
SAAS_ENV: '{{.SAAS_ENV}}'
- task: backend:dev:linked
vars:
PORT: '{{.APP_BACKEND_PORT}}'
SAAS_BASE_URL: 'http://localhost:{{.SAAS_BACKEND_PORT}}'
- task: frontend:dev:proprietary
vars:
PORT: '{{.APP_FRONTEND_PORT}}'
BACKEND_URL: 'http://localhost:{{.APP_BACKEND_PORT}}'
OPEN: "true"
- task: linked:_ready
vars:
SAAS_BACKEND_PORT: '{{.SAAS_BACKEND_PORT}}'
SAAS_FRONTEND_PORT: '{{.SAAS_FRONTEND_PORT}}'
APP_BACKEND_PORT: '{{.APP_BACKEND_PORT}}'
APP_FRONTEND_PORT: '{{.APP_FRONTEND_PORT}}'
# Waits for all four to answer, then prints where they landed.
linked:_ready:
internal: true
cmds:
- cmd: |
n=0
ok=0
while [ "$n" -lt 150 ]; do
ok=1
for u in "http://localhost:{{.SAAS_BACKEND_PORT}}" \
"http://localhost:{{.SAAS_FRONTEND_PORT}}" \
"http://localhost:{{.APP_BACKEND_PORT}}" \
"http://localhost:{{.APP_FRONTEND_PORT}}"; do
# Not -o /dev/null: Windows curl.exe treats it as a real path and exits 23.
curl -s -m 2 "$u" >/dev/null 2>&1 || ok=0
done
if [ "$ok" = 1 ]; then break; fi
n=$((n + 1))
# `sleep` is a binary, not a builtin, and Windows has none.
{{if eq OS "windows"}}powershell -NoProfile -Command "Start-Sleep -Seconds 2"{{else}}sleep 2{{end}}
done
echo ""
if [ "$ok" = 1 ]; then
echo ">> all four answering"
else
echo ">> still waiting on one or more after 5 minutes; addresses below anyway"
fi
echo ">> self-hosted UI http://localhost:{{.APP_FRONTEND_PORT}}/processor"
echo ">> self-hosted api http://localhost:{{.APP_BACKEND_PORT}}"
echo ">> saas UI http://localhost:{{.SAAS_FRONTEND_PORT}}"
echo ">> saas api http://localhost:{{.SAAS_BACKEND_PORT}}"
echo ""
dev:_all:
internal: true
vars:
@@ -186,7 +186,7 @@ system:
maxDPI: 500 # Maximum allowed DPI for PDF to image conversion
corsAllowedOrigins: [] # List of allowed origins for CORS (e.g. ['http://localhost:5173', 'https://app.example.com']). WARNING: leaving this empty falls back to allowing ALL origins (with credentials), it does NOT disable CORS. Set explicit origins to lock it down.
backendUrl: "" # Backend base URL for SAML/OAuth/API callbacks (e.g. 'http://localhost:8080' for dev, 'https://api.example.com' for production). REQUIRED for SSO authentication to work correctly. This is where your IdP will send SAML responses and OAuth callbacks. Leave empty to default to 'http://localhost:8080' in development.
frontendUrl: "" # Frontend URL for invite email links (e.g. 'https://app.example.com'). Optional - if not set, will use backendUrl. This is the URL users click in invite emails.
frontendUrl: "" # Base URL of the web app, as a browser reaches it (e.g. 'https://app.example.com', or 'https://example.com/app' if served under a base path). Optional - if not set, will use backendUrl. Used for any link handed to a browser: invite emails, share links, mobile QR codes, and the account-link handshake.
enableMobileScanner: true # Enable mobile phone QR code upload feature. Requires frontendUrl to be configured.
enableMobileSignature: true # Enable drawing signatures on a phone via QR code from the Sign tool. Requires frontendUrl to be configured.
mobileScannerSettings:
@@ -17,7 +17,7 @@
{
"moduleName": "ch.qos.logback:logback-classic",
"moduleUrl": "http://www.qos.ch",
"moduleVersion": "1.6.1",
"moduleVersion": "1.6.3",
"moduleLicense": "LGPL-2.1-only",
"moduleLicenseUrl": "https://www.gnu.org/licenses/old-licenses/lgpl-2.1.html"
},
@@ -31,7 +31,7 @@
{
"moduleName": "ch.qos.logback:logback-core",
"moduleUrl": "http://www.qos.ch",
"moduleVersion": "1.6.1",
"moduleVersion": "1.6.3",
"moduleLicense": "LGPL-2.1-only",
"moduleLicenseUrl": "https://www.gnu.org/licenses/old-licenses/lgpl-2.1.html"
},
@@ -1064,21 +1064,14 @@
{
"moduleName": "io.swagger.core.v3:swagger-annotations-jakarta",
"moduleUrl": "https://github.com/swagger-api/swagger-core/modules/swagger-annotations",
"moduleVersion": "2.2.46",
"moduleVersion": "2.2.47",
"moduleLicense": "Apache License, Version 2.0",
"moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0"
},
{
"moduleName": "io.swagger.core.v3:swagger-annotations-jakarta",
"moduleUrl": "https://github.com/swagger-api/swagger-core/modules/swagger-annotations",
"moduleVersion": "2.2.47",
"moduleLicense": "Apache License, Version 2.0",
"moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0"
},
{
"moduleName": "io.swagger.core.v3:swagger-core-jakarta",
"moduleUrl": "https://github.com/swagger-api/swagger-core/modules/swagger-core",
"moduleVersion": "2.2.46",
"moduleVersion": "2.2.53",
"moduleLicense": "Apache License, Version 2.0",
"moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0"
},
@@ -1090,9 +1083,9 @@
"moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0"
},
{
"moduleName": "io.swagger.core.v3:swagger-models-jakarta",
"moduleUrl": "https://github.com/swagger-api/swagger-core/modules/swagger-models",
"moduleVersion": "2.2.46",
"moduleName": "io.swagger.core.v3:swagger-core-jakarta",
"moduleUrl": "https://github.com/swagger-api/swagger-core/modules/swagger-core",
"moduleVersion": "2.2.53",
"moduleLicense": "Apache License, Version 2.0",
"moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0"
},
@@ -1103,6 +1096,13 @@
"moduleLicense": "Apache License, Version 2.0",
"moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0"
},
{
"moduleName": "io.swagger.core.v3:swagger-models-jakarta",
"moduleUrl": "https://github.com/swagger-api/swagger-core/modules/swagger-models",
"moduleVersion": "2.2.53",
"moduleLicense": "Apache License, Version 2.0",
"moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0"
},
{
"moduleName": "jakarta.activation:jakarta.activation-api",
"moduleUrl": "https://www.eclipse.org",
@@ -2304,7 +2304,7 @@
},
{
"moduleName": "org.simplejavamail:core-module",
"moduleVersion": "9.3.1",
"moduleVersion": "9.3.2",
"moduleLicense": "The Apache Software License, Version 2.0",
"moduleLicenseUrl": "http://www.apache.org/licenses/LICENSE-2.0.txt"
},
@@ -2317,13 +2317,13 @@
},
{
"moduleName": "org.simplejavamail:outlook-module",
"moduleVersion": "9.3.1",
"moduleVersion": "9.3.2",
"moduleLicense": "The Apache Software License, Version 2.0",
"moduleLicenseUrl": "http://www.apache.org/licenses/LICENSE-2.0.txt"
},
{
"moduleName": "org.simplejavamail:simple-java-mail",
"moduleVersion": "9.3.1",
"moduleVersion": "9.3.2",
"moduleLicense": "The Apache Software License, Version 2.0",
"moduleLicenseUrl": "http://www.apache.org/licenses/LICENSE-2.0.txt"
},
@@ -2343,10 +2343,10 @@
},
{
"moduleName": "org.snakeyaml:snakeyaml-engine",
"moduleUrl": "https://bitbucket.org/snakeyaml/snakeyaml-engine",
"moduleVersion": "3.0.1",
"moduleUrl": "https://codeberg.org/snakeyaml/snakeyaml-engine",
"moduleVersion": "3.1.1",
"moduleLicense": "Apache License, Version 2.0",
"moduleLicenseUrl": "http://www.apache.org/licenses/LICENSE-2.0.txt"
"moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0.txt"
},
{
"moduleName": "org.springdoc:springdoc-openapi-starter-common",
+18 -23
View File
@@ -1,26 +1,21 @@
Bag Attributes
friendlyName: alias
localKeyID: 43 4A B0 2D D5 03 52 9F 5B 78 50 64 54 22 AB F7 C8 0B 1F 2B
subject=C = US, ST = CA, L = SF, O = Test, OU = Test, CN = Test
issuer=C = US, ST = CA, L = SF, O = Test, OU = Test, CN = Test
-----BEGIN CERTIFICATE-----
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
-----END CERTIFICATE-----
+18 -23
View File
@@ -1,26 +1,21 @@
Bag Attributes
friendlyName: alias
localKeyID: 43 4A B0 2D D5 03 52 9F 5B 78 50 64 54 22 AB F7 C8 0B 1F 2B
subject=C = US, ST = CA, L = SF, O = Test, OU = Test, CN = Test
issuer=C = US, ST = CA, L = SF, O = Test, OU = Test, CN = Test
-----BEGIN CERTIFICATE-----
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
-----END CERTIFICATE-----
Binary file not shown.
Binary file not shown.
Binary file not shown.
+18 -23
View File
@@ -1,26 +1,21 @@
Bag Attributes
friendlyName: alias
localKeyID: 43 4A B0 2D D5 03 52 9F 5B 78 50 64 54 22 AB F7 C8 0B 1F 2B
subject=C = US, ST = CA, L = SF, O = Test, OU = Test, CN = Test
issuer=C = US, ST = CA, L = SF, O = Test, OU = Test, CN = Test
-----BEGIN CERTIFICATE-----
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
-----END CERTIFICATE-----
Binary file not shown.
+29 -29
View File
@@ -1,34 +1,34 @@
Bag Attributes
friendlyName: alias
localKeyID: 43 4A B0 2D D5 03 52 9F 5B 78 50 64 54 22 AB F7 C8 0B 1F 2B
localKeyID: C0 76 69 F4 6E D7 E6 03 D1 EB AD F1 A4 66 C4 14 3A 9B CB D4
Key Attributes: <No Attributes>
-----BEGIN ENCRYPTED PRIVATE KEY-----
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
-----END ENCRYPTED PRIVATE KEY-----
+29 -29
View File
@@ -1,34 +1,34 @@
Bag Attributes
friendlyName: alias
localKeyID: 43 4A B0 2D D5 03 52 9F 5B 78 50 64 54 22 AB F7 C8 0B 1F 2B
localKeyID: C0 76 69 F4 6E D7 E6 03 D1 EB AD F1 A4 66 C4 14 3A 9B CB D4
Key Attributes: <No Attributes>
-----BEGIN ENCRYPTED PRIVATE KEY-----
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
-----END ENCRYPTED PRIVATE KEY-----
@@ -24,22 +24,6 @@ import tools.jackson.databind.node.ObjectNode;
/**
* Outbound calls from a self-hosted instance to its linked SaaS backend (combined-billing "Mode
* A").
*
* <p>Calls:
*
* <ul>
* <li>{@link #register} — relays the admin's short-lived Supabase JWT to {@code POST
* /api/v1/account-link/register}; the SaaS side mints + returns a device credential.
* <li>{@link #fetchEntitlement} — authenticates with the stored device credential against {@code
* GET /api/v1/instance/entitlement}; what the local gate consults.
* <li>{@link #reportUsage} — daily usage sync ({@code POST /api/v1/instance/sync}); reports
* cumulative units and returns the refreshed entitlement.
* <li>{@link #revokeSelf} — self-revokes the credential on local unlink ({@code POST
* /api/v1/instance/revoke-self}).
* </ul>
*
* <p>Uses {@code java.net.http.HttpClient} (the established self-hosted outbound pattern; see
* {@code AiEngineClient}); base URL + client are injectable so tests can stub SaaS.
*/
@Slf4j
@Service
@@ -72,13 +56,7 @@ public class AccountLinkClient {
this.httpClient = httpClient;
}
/** The device credential a successful {@link #register} returns. */
public record RegisterResult(String deviceId, String deviceSecret, Long teamId) {}
/**
* A non-2xx reply from the SaaS account-link API. Carries the upstream status so the caller can
* map auth failures (401/403) through rather than masking everything as a 502.
*/
/** A non-2xx reply from the SaaS account-link API. */
public static class UpstreamException extends IOException {
private final int status;
@@ -92,11 +70,7 @@ public class AccountLinkClient {
}
}
/**
* Authoritative deny (401/403) — the device credential is revoked or invalid. Unlike a
* transport/server failure (which returns {@code null} and fails open), the cache must BLOCK on
* this. Unchecked so it propagates through {@link #fetchEntitlement}'s transport try/catch.
*/
/** Authoritative deny (401/403) — the device credential is revoked or invalid. */
public static final class RevokedException extends RuntimeException {
private final int status;
@@ -110,46 +84,142 @@ public class AccountLinkClient {
}
}
/** What the SaaS side hands back when it records a connect handshake. */
public record ConnectRequestResult(
String requestId, int expiresInSeconds, String authorizeUrl) {}
public enum ConnectClaimOutcome {
/** Approved and collected; the credential fields are populated. */
GRANTED,
/** A re-authentication was approved. */
CONFIRMED,
/** No human decision yet. */
PENDING,
/** Declined, expired or already used. */
REJECTED,
/** SaaS unreachable or erroring. */
UNAVAILABLE
}
public record ConnectClaimResult(
ConnectClaimOutcome outcome, String deviceId, String deviceSecret, Long teamId) {
static ConnectClaimResult of(ConnectClaimOutcome outcome) {
return new ConnectClaimResult(outcome, null, null, null);
}
}
/** Opens a connect handshake. */
public ConnectRequestResult connectRequest(
String name, String callbackUrl, String nonce, String claimSecret) throws IOException {
return connectRequest(name, callbackUrl, nonce, claimSecret, null);
}
/**
* Relays the admin Supabase JWT to the SaaS register endpoint and returns the minted
* credential.
*
* @throws IOException on transport failure or a non-2xx response (caller surfaces to the
* admin).
* As {@link #connectRequest}, but presenting an existing device credential so the SaaS side
* treats this as a re-authentication and pins the handshake to the team we already belong to.
*/
public RegisterResult register(String supabaseJwt, String instanceName) throws IOException {
String body =
instanceName == null || instanceName.isBlank()
? "{}"
: "{\"name\":" + mapper.writeValueAsString(instanceName) + "}";
HttpRequest request =
public ConnectRequestResult connectRequest(
String name,
String callbackUrl,
String nonce,
String claimSecret,
DeviceCredential credential)
throws IOException {
ObjectNode root = mapper.createObjectNode();
if (name != null && !name.isBlank()) {
root.put("name", name);
}
root.put("callbackUrl", callbackUrl);
root.put("nonce", nonce);
root.put("claimSecret", claimSecret);
HttpRequest.Builder builder =
HttpRequest.newBuilder()
.uri(uri("/api/v1/account-link/register"))
.header("Authorization", "Bearer " + supabaseJwt)
.uri(uri("/api/v1/account-link/connect/request"))
.header("Content-Type", "application/json")
.header("Accept", "application/json")
.timeout(timeout())
.POST(HttpRequest.BodyPublishers.ofString(body))
.build();
.POST(HttpRequest.BodyPublishers.ofString(mapper.writeValueAsString(root)));
if (credential != null) {
builder.header(HEADER_DEVICE_ID, credential.getDeviceId())
.header(HEADER_DEVICE_SECRET, credential.getDeviceSecret());
}
HttpResponse<String> response = send(request);
HttpResponse<String> response = send(builder.build());
if (response.statusCode() / 100 != 2) {
throw new UpstreamException(response.statusCode(), response.body());
}
JsonNode root = mapper.readTree(response.body());
String deviceId = text(root, "deviceId");
String deviceSecret = text(root, "deviceSecret");
if (deviceId == null || deviceSecret == null) {
throw new IOException("SaaS register response missing deviceId/deviceSecret");
JsonNode body = mapper.readTree(response.body());
String requestId = text(body, "requestId");
if (requestId == null) {
throw new IOException("SaaS connect response missing requestId");
}
String authorizeUrl = text(body, "authorizeUrl");
if (authorizeUrl == null || !isAbsoluteHttpUrl(authorizeUrl)) {
throw new IOException("SaaS connect response carried no usable authorizeUrl");
}
return new ConnectRequestResult(requestId, body.path("expiresIn").asInt(0), authorizeUrl);
}
/**
* Collects the device credential for an approved handshake, proving possession of the claim
* secret.
*/
public ConnectClaimResult connectClaim(String requestId, String claimSecret) {
HttpResponse<String> response;
try {
ObjectNode root = mapper.createObjectNode();
root.put("requestId", requestId);
root.put("claimSecret", claimSecret);
HttpRequest request =
HttpRequest.newBuilder()
.uri(uri("/api/v1/account-link/connect/claim"))
.header("Content-Type", "application/json")
.header("Accept", "application/json")
.timeout(timeout())
.POST(
HttpRequest.BodyPublishers.ofString(
mapper.writeValueAsString(root)))
.build();
response = send(request);
} catch (Exception e) {
log.debug("Connect claim failed (transport): {}", e.getMessage());
return ConnectClaimResult.of(ConnectClaimOutcome.UNAVAILABLE);
}
int status = response.statusCode();
if (status == 202) {
return ConnectClaimResult.of(ConnectClaimOutcome.PENDING);
}
if (status >= 500 && status <= 599) {
return ConnectClaimResult.of(ConnectClaimOutcome.UNAVAILABLE);
}
if (status < 200 || status > 299) {
return ConnectClaimResult.of(ConnectClaimOutcome.REJECTED);
}
try {
JsonNode body = mapper.readTree(response.body());
Long teamId = body.hasNonNull("teamId") ? body.get("teamId").asLong() : null;
// A re-authentication says so explicitly and carries no credential, so an absent
// credential is only an error when we were expecting one.
if ("confirmed".equals(text(body, "status"))) {
return new ConnectClaimResult(ConnectClaimOutcome.CONFIRMED, null, null, teamId);
}
String deviceId = text(body, "deviceId");
String deviceSecret = text(body, "deviceSecret");
if (deviceId == null || deviceSecret == null) {
log.warn("Connect claim succeeded but the reply carried no credential");
return ConnectClaimResult.of(ConnectClaimOutcome.REJECTED);
}
return new ConnectClaimResult(
ConnectClaimOutcome.GRANTED, deviceId, deviceSecret, teamId);
} catch (RuntimeException e) {
log.debug("Connect claim parse failed: {}", e.getMessage());
return ConnectClaimResult.of(ConnectClaimOutcome.REJECTED);
}
Long teamId = root.hasNonNull("teamId") ? root.get("teamId").asLong() : null;
return new RegisterResult(deviceId, deviceSecret, teamId);
}
/**
* Revokes this instance's own credential on the SaaS side, authenticated by that credential.
* Best-effort: returns {@code false} if SaaS is unreachable or rejects, so the caller (local
* unlink) can still clear locally and log the orphan for follow-up. Idempotent on SaaS.
*/
public boolean revokeSelf(String deviceId, String deviceSecret) {
try {
@@ -174,17 +244,7 @@ public class AccountLinkClient {
}
}
/**
* Fetches the current entitlement using the stored device credential. Three outcomes:
*
* <ul>
* <li>2xx → the parsed snapshot.
* <li>401/403 → {@link RevokedException} (authoritative deny — revoked/invalid credential);
* the caller must BLOCK, not fail open.
* <li>transport failure, other non-2xx (e.g. 5xx), or a malformed body → {@code null}
* ("unknown" — the caller fails open).
* </ul>
*/
/** Fetches the current entitlement using the stored device credential. */
public InstanceEntitlement fetchEntitlement(String deviceId, String deviceSecret) {
HttpResponse<String> response;
try {
@@ -224,9 +284,6 @@ public class AccountLinkClient {
/**
* Reports the period's cumulative per-category units to {@code POST /api/v1/instance/sync} and
* returns the fresh entitlement in the same reply — one round-trip both reports and refreshes.
* SaaS bills the delta against its last-seen cumulative, so resending the same totals is
* idempotent. Same three outcomes as {@link #fetchEntitlement}; on {@code null} the caller must
* not advance its last-synced markers so the usage retries next sync.
*/
public InstanceEntitlement reportUsage(
String deviceId,
@@ -360,4 +417,19 @@ public class AccountLinkClient {
private static String text(JsonNode node, String field) {
return node.hasNonNull(field) ? node.get(field).asText() : null;
}
/** Absolute http(s) with a host. */
static boolean isAbsoluteHttpUrl(String candidate) {
try {
URI uri = URI.create(candidate.strip());
String scheme = uri.getScheme();
return uri.isAbsolute()
&& scheme != null
&& ("http".equalsIgnoreCase(scheme) || "https".equalsIgnoreCase(scheme))
&& uri.getHost() != null
&& !uri.getHost().isBlank();
} catch (IllegalArgumentException e) {
return false;
}
}
}
@@ -16,21 +16,11 @@ import org.springframework.web.bind.annotation.RestController;
import io.swagger.v3.oas.annotations.Hidden;
import jakarta.servlet.http.HttpServletRequest;
import lombok.extern.slf4j.Slf4j;
/**
* Same-origin account-link surface on the self-hosted instance (combined-billing "Mode A").
*
* <p>The portal (served from this same origin, admin authenticated by the existing self-hosted
* security chain) calls these. {@code POST /link} relays the admin's Supabase JWT to the SaaS
* backend, which mints + returns a device credential we store locally. {@code GET /status} backs
* the portal's link card; {@code GET /usage} exposes locally-accrued unsynced usage the portal adds
* to SaaS-synced spend; {@code POST /sync-now} forces an immediate usage sync (ops "reconcile now"
* / test aid).
*
* <p>Admin-only, {@code @Profile("!saas")}, gated behind {@code
* stirling.billing.account-link.enabled} — off → bean absent → 404.
*/
/** Same-origin account-link surface on the self-hosted instance (combined billing). */
@Slf4j
@Hidden
@RestController
@@ -41,51 +31,110 @@ import lombok.extern.slf4j.Slf4j;
public class AccountLinkController {
private final AccountLinkService service;
private final ConnectService connectService;
private final LocalUsageService localUsageService;
// Present only when metering is on (its own flag); absent → /sync-now reports 409.
private final ObjectProvider<UsageSyncService> syncServiceProvider;
public AccountLinkController(
AccountLinkService service,
ConnectService connectService,
LocalUsageService localUsageService,
ObjectProvider<UsageSyncService> syncServiceProvider) {
this.service = service;
this.connectService = connectService;
this.localUsageService = localUsageService;
this.syncServiceProvider = syncServiceProvider;
}
/** {@code supabaseJwt} is the admin's short-lived token the portal already holds. */
public record LinkRequest(String supabaseJwt, String name) {}
/** {@code callbackUrl} is the portal telling us where its own callback route lives. */
public record ConnectStartRequest(String name, String callbackUrl) {}
@PostMapping("/link")
public ResponseEntity<?> link(@RequestBody LinkRequest req) {
if (req == null || req.supabaseJwt() == null || req.supabaseJwt().isBlank()) {
return ResponseEntity.badRequest()
.body(java.util.Map.of("error", "supabaseJwt is required"));
}
/** {@code nonce} comes from the callback fragment the approval page redirected to. */
public record ConnectCompleteRequest(String nonce) {}
/**
* Opens a browser-mediated link handshake and returns the approval URL to send the admin to.
*/
@PostMapping("/connect/start")
public ResponseEntity<?> connectStart(
@RequestBody(required = false) ConnectStartRequest req, HttpServletRequest http) {
try {
return ResponseEntity.ok(service.link(req.supabaseJwt(), req.name()));
return ResponseEntity.ok(
connectService.start(req != null ? req.name() : null, callbackHint(req, http)));
} catch (AccountLinkClient.UpstreamException e) {
// Auth failures are the admin's token, not a gateway fault: surface 401/403 as-is so
// the portal can prompt a re-sign-in. Anything else upstream → 502. Don't echo the
// raw upstream body back to the browser.
HttpStatus status =
e.status() == HttpStatus.UNAUTHORIZED.value()
|| e.status() == HttpStatus.FORBIDDEN.value()
? HttpStatus.valueOf(e.status())
: HttpStatus.BAD_GATEWAY;
log.warn("Account-link register rejected upstream: HTTP {}", e.status());
return ResponseEntity.status(status).body(java.util.Map.of("error", "LINK_FAILED"));
} catch (IOException e) {
// Don't echo e.getMessage() to the browser: a DNS/connection/TLS failure can carry the
// configured SaaS host/IP. Log it server-side; return the same opaque body the
// UpstreamException branch does.
log.warn("Account-link failed (transport): {}", e.getMessage());
log.warn("Account-link connect rejected upstream: HTTP {}", e.status());
return ResponseEntity.status(HttpStatus.BAD_GATEWAY)
.body(java.util.Map.of("error", "LINK_FAILED"));
.body(java.util.Map.of("error", "CONNECT_FAILED"));
} catch (IOException e) {
// Same reasoning as /link: a transport message can carry the configured SaaS host.
log.warn("Account-link connect failed (transport): {}", e.getMessage());
return ResponseEntity.status(HttpStatus.BAD_GATEWAY)
.body(java.util.Map.of("error", "CONNECT_FAILED"));
}
}
/** Re-establishes the admin's SaaS session for a server that is already linked. */
@PostMapping("/connect/reauth")
public ResponseEntity<?> connectReauth(
@RequestBody(required = false) ConnectStartRequest req, HttpServletRequest http) {
try {
return ResponseEntity.ok(connectService.startReauth(callbackHint(req, http)));
} catch (AccountLinkClient.UpstreamException e) {
log.warn("Account-link reauth rejected upstream: HTTP {}", e.status());
return ResponseEntity.status(HttpStatus.BAD_GATEWAY)
.body(java.util.Map.of("error", "CONNECT_FAILED"));
} catch (IOException e) {
log.warn("Account-link reauth failed: {}", e.getMessage());
return ResponseEntity.status(HttpStatus.BAD_GATEWAY)
.body(java.util.Map.of("error", "CONNECT_FAILED"));
}
}
/** Called by the callback page with the nonce it found in the fragment. */
@PostMapping("/connect/complete")
public ResponseEntity<ConnectService.ConnectStatus> connectComplete(
@RequestBody(required = false) ConnectCompleteRequest req) {
return ResponseEntity.ok(connectService.complete(req != null ? req.nonce() : null));
}
/** Everything we know about where the admin's browser is, for the callback. */
private static ConnectService.CallbackHint callbackHint(
ConnectStartRequest req, HttpServletRequest http) {
return new ConnectService.CallbackHint(
req != null ? req.callbackUrl() : null, http.getHeader("Origin"), baseUrlOf(http));
}
/**
* This instance's base URL as the browser reached it, including any context path so a subpath
* deployment builds a callback that actually resolves.
*/
private static String baseUrlOf(HttpServletRequest request) {
String forwardedProto = firstHop(request.getHeader("X-Forwarded-Proto"));
String forwardedHost = firstHop(request.getHeader("X-Forwarded-Host"));
String scheme = forwardedProto != null ? forwardedProto : request.getScheme();
String hostPort;
if (forwardedHost != null) {
hostPort = forwardedHost;
} else {
int port = request.getServerPort();
boolean defaultPort =
("http".equals(scheme) && port == 80)
|| ("https".equals(scheme) && port == 443);
hostPort = defaultPort ? request.getServerName() : request.getServerName() + ":" + port;
}
String context = request.getContextPath() == null ? "" : request.getContextPath();
return scheme + "://" + hostPort + context;
}
private static String firstHop(String headerValue) {
if (headerValue == null || headerValue.isBlank()) {
return null;
}
String first = headerValue.split(",")[0].strip();
return first.isEmpty() ? null : first;
}
@GetMapping("/status")
public ResponseEntity<AccountLinkService.LinkStatus> status() {
return ResponseEntity.ok(service.status());
@@ -106,12 +155,7 @@ public class AccountLinkController {
return ResponseEntity.ok(localUsageService.currentPeriodUnsynced());
}
/**
* Forces an immediate usage sync to SaaS — the same work the daily scheduler does. An admin
* "reconcile now" action (and a test aid so you don't wait on the scheduler). Idempotent:
* re-reports the current cumulative, so a repeat trigger bills nothing. {@code 204} once run;
* {@code 409} when metering is off (the sync bean is absent).
*/
/** Forces an immediate usage sync to SaaS — the same work the daily scheduler does. */
@PostMapping("/sync-now")
public ResponseEntity<Void> syncNow() {
UsageSyncService sync = syncServiceProvider.getIfAvailable();
@@ -8,29 +8,17 @@ import org.springframework.stereotype.Component;
import lombok.Getter;
import lombok.Setter;
/**
* Self-hosted side of combined-billing "Mode A" (connected self-hosted).
*
* <p>Binds the {@code stirling.billing.account-link.*} keys. {@link #enabled} mirrors the same flag
* the gated beans test with {@code @ConditionalOnProperty}; it is kept here only so non-conditional
* code (e.g. the gate's flag-off short-circuit, exposed status) can read it. The whole feature is
* <b>off by default</b> and <b>dark</b> — when off nothing gates and the link endpoints 404.
*/
/** Self-hosted side of combined billing: this instance bills through a linked SaaS team. */
@Getter
@Setter
@Component
@ConfigurationProperties(prefix = "stirling.billing.account-link")
public class AccountLinkProperties {
/** Master switch. When {@code false} (default) the feature is fully inert. */
/** Master switch. */
private boolean enabled = false;
/**
* Base URL of the SaaS backend this instance links to (register + entitlement live there).
*
* <p>STUB: defaults to the public cloud host; an operator overrides it for staging. There is no
* existing SaaS-base-url property in the self-hosted profile, so this is introduced here.
*/
/** Base URL of the SaaS backend this instance links to (register + entitlement live there). */
private String saasBaseUrl = "https://stirling.com/app";
/** Cached entitlement is reused for this long before a refresh is attempted. */
@@ -39,20 +27,18 @@ public class AccountLinkProperties {
/** Connect/read timeout for the outbound SaaS calls. */
private int requestTimeoutSeconds = 10;
/** Phase 2 usage metering + daily sync. Keyed under {@code …account-link.metering.*}. */
/** Phase 2 usage metering + daily sync. */
private final Metering metering = new Metering();
/**
* Dedicated billing switch, <b>separate</b> from {@link #enabled} so the link plumbing can be
* enabled (e.g. to test linking) without ever turning on real usage metering, reporting, or cap
* enforcement. Both default off; metering requires the master flag too. This is the production
* safety key — flipping it on is what actually bills linked instances.
* Separate from {@link #enabled} so linking can be exercised without billing anything. Both
* default off, and metering needs the master flag as well.
*/
@Getter
@Setter
public static class Metering {
/** Turns on usage metering, the daily sync, and cap enforcement. Default off. */
/** Turns on usage metering, the daily sync, and cap enforcement. */
private boolean enabled = false;
/**
@@ -65,12 +51,7 @@ public class AccountLinkProperties {
*/
private int graceDays = 3;
/**
* Dedup window for identical input sets. A re-run of the same inputs within this window is
* treated as workflow chaining and not re-charged; the same inputs run again after it are
* billed afresh. Mirrors the cloud's {@code payg.lineage.workflow-window} so the same op
* costs the same on the instance and in the cloud.
*/
/** Dedup window for identical input sets. */
private Duration workflowWindow = Duration.ofMinutes(5);
}
}
@@ -1,6 +1,5 @@
package stirling.software.proprietary.accountlink;
import java.io.IOException;
import java.util.Optional;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
@@ -9,13 +8,7 @@ import org.springframework.stereotype.Service;
import lombok.extern.slf4j.Slf4j;
/**
* Linking orchestrator (self-hosted side of combined-billing "Mode A").
*
* <p>{@link #link} is the same-origin action the portal triggers: it relays the admin's Supabase
* JWT to the SaaS register endpoint, then persists the returned device credential secure-at-rest.
* The credential — not the JWT — authenticates all later unattended entitlement calls.
*/
/** Linking orchestrator (self-hosted side of combined billing). */
@Slf4j
@Service
@Profile("!saas")
@@ -38,24 +31,9 @@ public class AccountLinkService {
/** Status of this instance's link, for the portal's "Account link" card. */
public record LinkStatus(boolean linked, String deviceId, Long teamId, String linkedAt) {}
/**
* Registers this instance with the SaaS team behind {@code supabaseJwt} and stores the
* credential.
*
* @throws IOException if the SaaS register call fails (surfaced to the admin as a link error).
*/
public LinkStatus link(String supabaseJwt, String instanceName) throws IOException {
AccountLinkClient.RegisterResult result = client.register(supabaseJwt, instanceName);
credentialStore.save(result.deviceId(), result.deviceSecret(), result.teamId());
entitlementCache.invalidate();
log.info("Account-link: instance linked to team {}", result.teamId());
return status();
}
/**
* Unlinks this instance — best-effort tells SaaS to revoke first (so the row gets {@code
* revoked_at} set), then clears locally regardless. If SaaS is unreachable the local clear
* still proceeds (admin's intent must win); the orphan row can be revoked from the portal.
* revoked_at} set), then clears locally regardless.
*/
public void unlink() {
credentialStore
@@ -12,7 +12,7 @@ import lombok.NoArgsConstructor;
import lombok.Setter;
/**
* Singleton row holding this instance's daily-sync bookkeeping (combined-billing "Mode A").
* Singleton row holding this instance's daily-sync bookkeeping (combined billing).
*
* <p>{@link #lastSyncSeq} is reserved (incremented + persisted) <em>before</em> each report so it
* is strictly monotonic across restarts and partial failures — SaaS dedups replays by comparing it,
@@ -2,5 +2,5 @@ package stirling.software.proprietary.accountlink;
import org.springframework.data.jpa.repository.JpaRepository;
/** Persistence for the singleton {@link AccountLinkSyncState} (combined-billing "Mode A"). */
/** Persistence for the singleton {@link AccountLinkSyncState} (combined billing). */
public interface AccountLinkSyncStateRepository extends JpaRepository<AccountLinkSyncState, Long> {}
@@ -0,0 +1,276 @@
package stirling.software.proprietary.accountlink;
import java.io.IOException;
import java.net.URI;
import java.net.URISyntaxException;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.SecureRandom;
import java.time.Duration;
import java.time.LocalDateTime;
import java.util.Base64;
import java.util.Locale;
import java.util.Optional;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.context.annotation.Profile;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import lombok.extern.slf4j.Slf4j;
import stirling.software.common.model.ApplicationProperties;
/** Browser-mediated account linking, instance side. */
@Slf4j
@Service
@Profile("!saas")
@ConditionalOnProperty(name = "stirling.billing.account-link.enabled", havingValue = "true")
public class ConnectService {
/** Frontend route that consumes the callback fragment. */
static final String CALLBACK_PATH = "/account-link/callback";
private static final int SECRET_BYTES = 32;
private final AccountLinkClient client;
private final ConnectStateRepository stateRepo;
private final DeviceCredentialStore credentialStore;
private final EntitlementCache entitlementCache;
private final ApplicationProperties applicationProperties;
private final SecureRandom random = new SecureRandom();
public ConnectService(
AccountLinkClient client,
ConnectStateRepository stateRepo,
DeviceCredentialStore credentialStore,
EntitlementCache entitlementCache,
ApplicationProperties applicationProperties) {
this.client = client;
this.stateRepo = stateRepo;
this.credentialStore = credentialStore;
this.entitlementCache = entitlementCache;
this.applicationProperties = applicationProperties;
}
public enum Phase {
/** Nothing in flight and not linked. */
NONE,
/** A handshake is open, waiting for a leader to approve it on the SaaS site. */
PENDING,
/** Linked. */
LINKED,
/** The handshake outlived its window; start a new one. */
EXPIRED,
/** Declined or already used; start a new one. */
REJECTED,
/** SaaS could not be reached; the handshake is still valid and can be retried. */
UNAVAILABLE
}
/** What the portal renders. */
public record ConnectStatus(
Phase phase, String authorizeUrl, Long secondsRemaining, Long teamId) {
static ConnectStatus of(Phase phase) {
return new ConnectStatus(phase, null, null, null);
}
}
/** Everything we know about where the admin's browser actually is, in decreasing authority. */
public record CallbackHint(
String requestedCallbackUrl, String browserOrigin, String derivedBaseUrl) {}
/** Opens a handshake and returns where to send the admin. */
@Transactional
public ConnectStatus start(String name, CallbackHint hint) throws IOException {
if (credentialStore.isLinked()) {
return status();
}
return open(name, hint, null);
}
/**
* Opens a handshake that only re-establishes the admin's browser session, for an instance that
* is already linked.
*/
@Transactional
public ConnectStatus startReauth(CallbackHint hint) throws IOException {
DeviceCredential credential =
credentialStore
.get()
.orElseThrow(
() ->
new IOException(
"This server is not linked, so there is no session"
+ " to re-establish"));
return open(credential.getDeviceId(), hint, credential);
}
private ConnectStatus open(String name, CallbackHint hint, DeviceCredential credential)
throws IOException {
String callbackUrl = resolveCallbackUrl(hint);
if (callbackUrl == null) {
throw new IOException(
"Cannot determine where to send the admin back to; set system.frontendUrl");
}
String nonce = randomSecret();
String claimSecret = randomSecret();
AccountLinkClient.ConnectRequestResult created =
client.connectRequest(name, callbackUrl, nonce, claimSecret, credential);
LocalDateTime now = LocalDateTime.now();
ConnectState state = new ConnectState();
state.setId(ConnectState.SINGLETON_ID);
state.setRequestId(created.requestId());
state.setNonce(nonce);
state.setClaimSecret(claimSecret);
state.setCallbackUrl(callbackUrl);
state.setAuthorizeUrl(created.authorizeUrl());
state.setCreatedAt(now);
state.setExpiresAt(
now.plusSeconds(created.expiresInSeconds() > 0 ? created.expiresInSeconds() : 900));
stateRepo.save(state);
log.info("Account-link connect: handshake {} opened", created.requestId());
return pendingStatus(state, now);
}
/** Finishes a handshake from the callback the approval page redirected to. */
@Transactional
public ConnectStatus complete(String nonce) {
Optional<ConnectState> found = stateRepo.findById(ConnectState.SINGLETON_ID);
if (found.isEmpty()) {
// Already finished (a double-submitted callback) or never started.
return status();
}
ConnectState state = found.get();
if (state.isExpired(LocalDateTime.now())) {
stateRepo.delete(state);
return ConnectStatus.of(Phase.EXPIRED);
}
if (nonce == null || !nonceMatches(nonce, state.getNonce())) {
log.warn(
"Account-link connect: callback for handshake {} had a bad nonce",
state.getRequestId());
return ConnectStatus.of(Phase.REJECTED);
}
AccountLinkClient.ConnectClaimResult claim =
client.connectClaim(state.getRequestId(), state.getClaimSecret());
return switch (claim.outcome()) {
case GRANTED -> {
credentialStore.save(claim.deviceId(), claim.deviceSecret(), claim.teamId());
entitlementCache.invalidate();
stateRepo.delete(state);
log.info("Account-link connect: linked to team {}", claim.teamId());
yield new ConnectStatus(Phase.LINKED, null, null, claim.teamId());
}
case CONFIRMED -> {
stateRepo.delete(state);
log.info(
"Account-link connect: session re-established for team {}", claim.teamId());
yield new ConnectStatus(Phase.LINKED, null, null, claim.teamId());
}
case PENDING ->
// The admin reached the callback before the approval committed. The row stays,
// so a retry finishes it.
ConnectStatus.of(Phase.PENDING);
case REJECTED -> {
stateRepo.delete(state);
yield ConnectStatus.of(Phase.REJECTED);
}
case UNAVAILABLE -> ConnectStatus.of(Phase.UNAVAILABLE);
};
}
@Transactional(readOnly = true)
public ConnectStatus status() {
Optional<DeviceCredential> credential = credentialStore.get();
if (credential.isPresent()) {
return new ConnectStatus(Phase.LINKED, null, null, credential.get().getTeamId());
}
Optional<ConnectState> state = stateRepo.findById(ConnectState.SINGLETON_ID);
if (state.isEmpty()) {
return ConnectStatus.of(Phase.NONE);
}
LocalDateTime now = LocalDateTime.now();
if (state.get().isExpired(now)) {
return ConnectStatus.of(Phase.EXPIRED);
}
return pendingStatus(state.get(), now);
}
private static ConnectStatus pendingStatus(ConnectState state, LocalDateTime now) {
long remaining = Duration.between(now, state.getExpiresAt()).toSeconds();
return new ConnectStatus(
Phase.PENDING, state.getAuthorizeUrl(), Math.max(remaining, 0), null);
}
/** Decides the callback, preferring knowledge over inference. */
String resolveCallbackUrl(CallbackHint hint) {
String configured = applicationProperties.getSystem().getFrontendUrl();
if (configured != null && !configured.isBlank()) {
return trimTrailingSlash(configured.strip()) + CALLBACK_PATH;
}
String browserOrigin = originOf(hint.browserOrigin());
if (browserOrigin != null) {
String requested = hint.requestedCallbackUrl();
if (requested != null && browserOrigin.equals(originOf(requested))) {
return requested.strip();
}
return browserOrigin + CALLBACK_PATH;
}
return hint.derivedBaseUrl() == null || hint.derivedBaseUrl().isBlank()
? null
: trimTrailingSlash(hint.derivedBaseUrl().strip()) + CALLBACK_PATH;
}
/** Scheme, host and port of an absolute http(s) URL; null if it is not one. */
private static String originOf(String candidate) {
if (candidate == null || candidate.isBlank()) {
return null;
}
URI uri;
try {
uri = new URI(candidate.strip());
} catch (URISyntaxException e) {
return null;
}
if (uri.getScheme() == null || uri.getHost() == null) {
return null;
}
String scheme = uri.getScheme().toLowerCase(Locale.ROOT);
if (!"http".equals(scheme) && !"https".equals(scheme)) {
return null;
}
int port = uri.getPort();
boolean defaultPort =
port == -1
|| ("http".equals(scheme) && port == 80)
|| ("https".equals(scheme) && port == 443);
return defaultPort
? scheme + "://" + uri.getHost()
: scheme + "://" + uri.getHost() + ":" + port;
}
private static String trimTrailingSlash(String value) {
return value.replaceAll("/+$", "");
}
private String randomSecret() {
byte[] buf = new byte[SECRET_BYTES];
random.nextBytes(buf);
return Base64.getUrlEncoder().withoutPadding().encodeToString(buf);
}
/** Constant-time so a caller cannot probe the nonce a character at a time. */
private static boolean nonceMatches(String candidate, String expected) {
if (expected == null) {
return false;
}
return MessageDigest.isEqual(
candidate.getBytes(StandardCharsets.UTF_8),
expected.getBytes(StandardCharsets.UTF_8));
}
}
@@ -0,0 +1,60 @@
package stirling.software.proprietary.accountlink;
import java.io.Serializable;
import java.time.LocalDateTime;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
/** The one in-flight "connect this server" handshake, instance side. */
@Entity
@Table(name = "account_link_connect_state")
@NoArgsConstructor
@Getter
@Setter
public class ConnectState implements Serializable {
private static final long serialVersionUID = 1L;
public static final Long SINGLETON_ID = 1L;
@Id
@Column(name = "id")
private Long id = SINGLETON_ID;
/** Opaque handle the SaaS side gave us; identifies the handshake on both sides. */
@Column(name = "request_id", nullable = false, length = 64)
private String requestId;
/** Correlator we minted. */
@Column(name = "nonce", nullable = false, length = 128)
private String nonce;
/** Secret we minted and sent to SaaS server to server. */
@Column(name = "claim_secret", nullable = false, length = 128)
private String claimSecret;
/** Where we asked the approval page to send the admin back to. */
@Column(name = "callback_url", nullable = false, length = 2048)
private String callbackUrl;
/** The approval URL handed to the browser, so a reload can offer it again. */
@Column(name = "authorize_url", nullable = false, length = 2048)
private String authorizeUrl;
@Column(name = "created_at", nullable = false)
private LocalDateTime createdAt;
@Column(name = "expires_at", nullable = false)
private LocalDateTime expiresAt;
public boolean isExpired(LocalDateTime now) {
return expiresAt != null && expiresAt.isBefore(now);
}
}
@@ -0,0 +1,6 @@
package stirling.software.proprietary.accountlink;
import org.springframework.data.jpa.repository.JpaRepository;
/** Data access for the singleton {@link ConnectState} row. */
public interface ConnectStateRepository extends JpaRepository<ConnectState, Long> {}
@@ -13,8 +13,8 @@ import lombok.NoArgsConstructor;
import lombok.Setter;
/**
* The device credential this self-hosted instance received when it linked a SaaS account
* (combined-billing "Mode A"). Singleton — one instance links to exactly one SaaS team.
* The device credential this self-hosted instance received when it linked a SaaS account (combined
* billing). Singleton — one instance links to exactly one SaaS team.
*
* <p>Unlike the SaaS side (which stores only a hash), the instance must keep the plaintext {@code
* deviceSecret} so it can present it on every unattended entitlement call. It lives in the local
@@ -8,7 +8,7 @@ import org.springframework.context.annotation.Profile;
import org.springframework.stereotype.Service;
/**
* Decides whether a request may proceed under combined-billing "Mode A" on a self-hosted instance.
* Decides whether a request may proceed under combined billing on a self-hosted instance.
*
* <p>Rules (in order):
*
@@ -39,8 +39,8 @@ import stirling.software.proprietary.policy.controller.PolicyRunRoutes;
import stirling.software.proprietary.security.model.ApiKeyAuthenticationToken;
/**
* Request-time gate + meter for combined-billing "Mode A". {@code preHandle} blocks billable (API /
* AI / automation) work when the instance is unlinked or over its limit; manual tools pass through.
* Request-time gate + meter for combined billing. {@code preHandle} blocks billable (API / AI /
* automation) work when the instance is unlinked or over its limit; manual tools pass through.
* {@code afterCompletion} meters a successful billable op into the per-period cumulative counter.
*
* <p>Blocking responds {@code 402} with a machine-readable body the FE maps to a "link to activate"
@@ -16,11 +16,11 @@ import lombok.NoArgsConstructor;
/**
* The last time the instance metered a given input set this period — the local equivalent of the
* cloud's lineage join (combined-billing "Mode A"). The meter dedups on a rolling <b>workflow
* window</b>: an identical input set re-submitted within the window (see {@link
* AccountLinkProperties.Metering}) is treated as workflow chaining and not re-charged, while the
* same inputs run again after the window are billed afresh — matching the cloud's 5-minute open-job
* window so the same operation costs the same on the instance and in the cloud.
* cloud's lineage join (combined billing). The meter dedups on a rolling <b>workflow window</b>: an
* identical input set re-submitted within the window (see {@link AccountLinkProperties.Metering})
* is treated as workflow chaining and not re-charged, while the same inputs run again after the
* window are billed afresh — matching the cloud's 5-minute open-job window so the same operation
* costs the same on the instance and in the cloud.
*
* <p>{@code lastMeteredAt} is refreshed on every sighting (the window slides, as recording a cloud
* artifact touches its job). One row per {@code (period, signature)}; the unique constraint also
@@ -5,7 +5,7 @@ import java.util.Optional;
import org.springframework.data.jpa.repository.JpaRepository;
/** Persistence for the per-period metered input-set signatures (combined-billing "Mode A"). */
/** Persistence for the per-period metered input-set signatures (combined billing). */
public interface MeteredInputSignatureRepository
extends JpaRepository<MeteredInputSignature, Long> {
@@ -17,10 +17,10 @@ import lombok.NoArgsConstructor;
import stirling.software.proprietary.billing.BillingCategory;
/**
* Durable per-(billing period, category) cumulative usage counter for combined-billing "Mode A".
* Each successful billable op increments its row; the daily sync reports the cumulative totals and
* SaaS bills the delta since the last sync. The cumulative model is idempotent (a resend bills
* nothing) and tamper-evident (a counter that drops is a signal). One row per {@code (period_start,
* Durable per-(billing period, category) cumulative usage counter for combined billing. Each
* successful billable op increments its row; the daily sync reports the cumulative totals and SaaS
* bills the delta since the last sync. The cumulative model is idempotent (a resend bills nothing)
* and tamper-evident (a counter that drops is a signal). One row per {@code (period_start,
* category)}, auto-created by Hibernate; only the flag-gated {@link UsageMeterService} writes it.
*/
@Entity
@@ -9,7 +9,7 @@ import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import org.springframework.transaction.annotation.Transactional;
/** Persistence for the per-period/per-category usage counters (combined-billing "Mode A"). */
/** Persistence for the per-period/per-category usage counters (combined billing). */
public interface UsageCounterRepository extends JpaRepository<UsageCounter, Long> {
/**
@@ -18,8 +18,8 @@ import lombok.extern.slf4j.Slf4j;
import stirling.software.proprietary.billing.BillingCategory;
/**
* Daily usage sender for combined-billing "Mode A". Reports each period's cumulative per-category
* usage to SaaS, which bills the delta against its own last-seen totals.
* Daily usage sender for combined billing. Reports each period's cumulative per-category usage to
* SaaS, which bills the delta against its own last-seen totals.
*
* <p>Resilience: the sync seq is persisted before the report so it never regresses across
* restarts/failures; a transport failure leaves the {@code lastSyncedUnits} markers untouched so
@@ -11,9 +11,9 @@ import java.util.HexFormat;
/**
* SHA-256 content fingerprint shared by the SaaS charge path and the linked self-hosted instance's
* meter (combined-billing "Mode A"), so both derive an <em>identical</em> signature for the same
* bytes — the basis for lineage dedup. Pure, no Spring: fixed 64 KiB buffer (allocation independent
* of file size), hardware-accelerated by the JVM where available.
* meter (combined billing), so both derive an <em>identical</em> signature for the same bytes — the
* basis for lineage dedup. Pure, no Spring: fixed 64 KiB buffer (allocation independent of file
* size), hardware-accelerated by the JVM where available.
*
* <p>Lives in {@code :proprietary} (not {@code :common}) so it stays out of the community core
* build yet is reachable from {@code :saas} (which depends on {@code :proprietary}).
@@ -1,20 +1,17 @@
package stirling.software.proprietary.failure;
import org.springframework.http.HttpStatus;
import lombok.Getter;
/**
* Why an action could not be dispatched. Carries a {@link Reason} rather than an HTTP status, so
* the service stays web-agnostic and the controller owns the mapping.
*/
/** Carries a {@link Reason} rather than an HTTP status, so the service stays web-agnostic. */
@Getter
public class FailureActionException extends RuntimeException {
public enum Reason {
/**
* No such event, it belongs to another team, or the caller's team did not resolve. One
* reason for all three, so the response does not vary with which it was. Unrelated to
* {@link FailureKind#UNKNOWN}, which is an unclassified failure rather than a refused
* action.
* No such event, another team's, or an unresolved team: one reason, so the answer cannot
* vary.
*/
EVENT_NOT_FOUND,
@@ -22,15 +19,13 @@ public class FailureActionException extends RuntimeException {
ACTION_NOT_RECOGNISED,
/**
* The action exists but this kind does not declare it, so an incoherent pairing (releasing
* a document whose destination is what failed) cannot be dispatched even by hand.
*
* <p>Unreachable today: both kinds declare both actions, so no request can trip this guard
* until a kind ships with a restricted action set. Declared now because the guard must
* exist before that kind does, not after.
* The action exists but this kind does not offer it, so it cannot be dispatched by hand.
*/
ACTION_NOT_DECLARED,
/** Offered, but the client is what runs it, so refused rather than half-performed. */
ACTION_NOT_DISPATCHABLE,
/** The event is already closed, so no further transition is possible. */
ALREADY_CLOSED
}
@@ -41,9 +36,21 @@ public class FailureActionException extends RuntimeException {
this(reason, message, null);
}
/** For a refusal that follows from a lower-level failure, so its stack is not dropped. */
public FailureActionException(Reason reason, String message, Throwable cause) {
super(message, cause);
this.reason = reason;
}
/**
* Lives with the reasons it maps, so every surface that dispatches an action answers alike. A
* closed row is a conflict, not a bad request: it was well-formed and valid a moment earlier.
*/
public static HttpStatus statusOf(Reason reason) {
return switch (reason) {
case EVENT_NOT_FOUND -> HttpStatus.NOT_FOUND;
case ACTION_NOT_RECOGNISED, ACTION_NOT_DECLARED, ACTION_NOT_DISPATCHABLE ->
HttpStatus.BAD_REQUEST;
case ALREADY_CLOSED -> HttpStatus.CONFLICT;
};
}
}
@@ -1,11 +1,52 @@
package stirling.software.proprietary.failure;
import lombok.Getter;
/**
* The actions a {@link FailureKind} may declare. Both are incident dispositions: they change how
* the event is shown and touch nothing else, which is what makes them valid for every kind
* including {@link FailureKind#UNKNOWN}, and why there is no {@code APPROVE} yet.
* The actions a {@link FailureKind} may declare. Client actions are declared here rather than
* invented per client, so the server keeps deciding what a kind offers, in what order and labelled
* how.
*/
@Getter
public enum FailureActionId {
ACKNOWLEDGE,
DISMISS
/**
* Kept in the vocabulary for as long as any persisted row is {@code ACKNOWLEDGED}: such rows
* must stay readable and closable whether or not any kind currently offers this.
*/
ACKNOWLEDGE(Execution.SERVER, "Acknowledge"),
DISMISS(Execution.SERVER, "Dismiss"),
/** Open the document behind the incident, in whichever client can resolve its id. */
VIEW_FILE(Execution.CLIENT, "View file"),
VIEW_IN_PROCESSOR(Execution.CLIENT, "View in processor");
/** Dispatch refuses a {@code CLIENT} id, so this is enforced rather than merely documented. */
public enum Execution {
/** {@link FailureActionRegistry} requires a {@link FailureAction} bean for these. */
SERVER,
/**
* Declared and rendered, never dispatched: the server has neither the file nor the tool.
*/
CLIENT
}
private final Execution execution;
/** English fallback, for a client with no translation for the label key. */
private final String defaultLabel;
FailureActionId(Execution execution, String defaultLabel) {
this.execution = execution;
this.defaultLabel = defaultLabel;
}
/** Also whether it can be dispatched. */
public boolean runsOnServer() {
return execution == Execution.SERVER;
}
}
@@ -16,6 +16,9 @@ import lombok.extern.slf4j.Slf4j;
* Resolves a {@link FailureActionId} to the bean that implements it. The startup check is the
* point: because kinds declare action ids as data, one could name an action nobody implements,
* which would otherwise show up as a button that 400s rather than as a failed boot.
*
* <p>Only {@link FailureActionId.Execution#SERVER} ids belong here: a bean for a client action is
* refused, because dispatch could never reach it.
*/
@Slf4j
@Service
@@ -25,6 +28,14 @@ public class FailureActionRegistry {
public FailureActionRegistry(List<FailureAction> actions) {
for (FailureAction action : actions) {
if (!action.id().runsOnServer()) {
throw new IllegalStateException(
"Action "
+ action.id()
+ " is run by the client, so "
+ action.getClass().getName()
+ " could never be dispatched");
}
FailureAction clash = byId.put(action.id(), action);
if (clash != null) {
throw new IllegalStateException(
@@ -38,10 +49,7 @@ public class FailureActionRegistry {
}
}
/**
* Fail fast if any kind declares an action with no handler, naming every gap rather than the
* first, so one boot tells you everything that is missing.
*/
/** Names every gap rather than the first, so one boot tells you everything that is missing. */
@PostConstruct
void verifyEveryDeclaredActionHasAHandler() {
List<String> gaps =
@@ -49,6 +57,7 @@ public class FailureActionRegistry {
.flatMap(
kind ->
kind.getActions().stream()
.filter(FailureActionId::runsOnServer)
.filter(action -> !byId.containsKey(action))
.map(action -> kind.getId() + " -> " + action))
.toList();
@@ -0,0 +1,15 @@
package stirling.software.proprietary.failure;
/**
* Who an offered action is for, the read scope having already decided they may see the incident.
* The distinction is possession, not seniority: a reviewer cannot reach a document only its owner
* holds.
*/
public enum FailureAudience {
OWNER,
/** Anyone who triages the team's incidents, whoever hit them. */
TEAM_REVIEWER,
ANYONE_WHO_SEES
}
@@ -1,7 +1,11 @@
package stirling.software.proprietary.failure;
import static stirling.software.proprietary.failure.FailureActionId.ACKNOWLEDGE;
import static stirling.software.proprietary.failure.FailureActionId.DISMISS;
import static stirling.software.proprietary.failure.FailureActionId.VIEW_FILE;
import static stirling.software.proprietary.failure.FailureActionId.VIEW_IN_PROCESSOR;
import static stirling.software.proprietary.failure.FailureAudience.ANYONE_WHO_SEES;
import static stirling.software.proprietary.failure.FailureAudience.OWNER;
import static stirling.software.proprietary.failure.FailureAudience.TEAM_REVIEWER;
import java.util.Arrays;
import java.util.HashMap;
@@ -20,13 +24,8 @@ import lombok.Getter;
* The registry of failure kinds, described as data: a stable id, i18n keys and an English fallback
* like {@code ExceptionUtils.ErrorCode}, plus the facets a review surface needs.
*
* <p>Actions are declared here but implemented in {@link FailureAction} beans resolved by id, so a
* new kind ships as a registry entry plus copy. Two members today: {@link #UNKNOWN} gives every
* failed run a record, and kinds get promoted out of it as production shows what occurs.
*
* <p>A kind offers an acknowledgement only where there is something to acknowledge <em>doing</em>.
* With nothing to fix, "seen it" and "clear it" are the same decision, so the row offers only the
* one that clears it.
* <p>A new kind ships as a registry entry plus copy. Each offer also says who it is for, since one
* incident is read both by whoever hit it and by whoever reviews after them.
*/
@Getter
public enum FailureKind {
@@ -37,8 +36,9 @@ public enum FailureKind {
FailureScope.FILE,
errorCodes("E004"),
fallback("This document is password-protected, so the pipeline could not read it."),
offer(ACKNOWLEDGE),
offer(DISMISS, "dismissSkipFile")),
offer(VIEW_FILE, OWNER),
offer(VIEW_IN_PROCESSOR, TEAM_REVIEWER),
offer(DISMISS, ANYONE_WHO_SEES)),
UNKNOWN(
FailureStage.INTERNAL,
@@ -47,7 +47,11 @@ public enum FailureKind {
FailureScope.RUN,
noErrorCodes(),
fallback("This run failed for a reason Stirling does not yet recognise."),
offer(DISMISS));
// Same order as every other kind: declaration order is display order, so the document
// leads wherever it is offered rather than moving between failures.
offer(VIEW_FILE, OWNER),
offer(VIEW_IN_PROCESSOR, TEAM_REVIEWER),
offer(DISMISS, ANYONE_WHO_SEES));
private static final String KEY_PREFIX = "portal.failures.kind.";
private static final String ACTION_KEY_PREFIX = "portal.failures.action.";
@@ -95,22 +99,26 @@ public enum FailureKind {
}
/**
* One action this kind offers, with the key to label it by. One ordered list rather than ids
* plus a parallel map of overrides, which could disagree with each other.
* One ordered list rather than ids plus parallel maps of audiences and labels, which could
* disagree with each other.
*
* @param labelKeySuffix key under {@code portal.failures.action.}, or null for the generic
* label
*/
private record Offer(FailureActionId id, String labelKeySuffix) {}
private record Offer(FailureActionId id, FailureAudience audience, String labelKeySuffix) {}
/** An action labelled by this kind's own wording, where the generic label reads badly. */
private static Offer offer(FailureActionId id, String labelKeySuffix) {
return new Offer(id, labelKeySuffix);
/** Declaration order is display order. */
private static Offer offer(FailureActionId id, FailureAudience audience) {
return new Offer(id, audience, null);
}
/** An action labelled by the shared wording for that action. */
private static Offer offer(FailureActionId id) {
return new Offer(id, null);
/**
* As {@link #offer(FailureActionId, FailureAudience)}, but labelled by this kind's own wording
* where the shared one reads badly.
*/
private static Offer offer(
FailureActionId id, FailureAudience audience, String labelKeySuffix) {
return new Offer(id, audience, labelKeySuffix);
}
/**
@@ -149,6 +157,22 @@ public enum FailureKind {
return offers.stream().map(Offer::id).toList();
}
/**
* What this kind offers, in declaration order, each with its label resolved. What a review
* surface reads, so it never has to ask two separate questions about one offer.
*/
public List<OfferedAction> getOfferedActions() {
return offers.stream()
.map(
offer ->
new OfferedAction(
offer.id(), labelKeyFor(offer.id()), offer.audience()))
.toList();
}
/** One action as a kind declares it: what to call it and who it is for. */
public record OfferedAction(FailureActionId id, String labelKey, FailureAudience audience) {}
/** Whether this kind offers {@code action}. The dispatch guard: see {@code FailureActionId}. */
public boolean declares(FailureActionId action) {
return offers.stream().anyMatch(offer -> offer.id() == action);
@@ -74,8 +74,10 @@ public class FileRunEventController {
@Operation(
summary = "Apply an action to a recorded failure",
description =
"Rejected with 400 if the failure's kind does not declare the action, so an"
+ " action that makes no sense for a given failure cannot be applied.")
"Rejected with 400 if the failure's kind does not declare the action, or if the"
+ " action is one the client runs rather than the server, so neither an"
+ " action that makes no sense for a given failure nor one the server"
+ " cannot perform can be applied.")
public FileRunEventView act(
@PathVariable String eventId,
@PathVariable String actionId,
@@ -87,7 +89,8 @@ public class FileRunEventController {
FileRunEvent updated = service.dispatch(eventId, actionId, inputs);
return FileRunEventView.of(updated, service.availableActions(updated));
} catch (FailureActionException e) {
throw new ResponseStatusException(statusFor(e.getReason()), e.getMessage(), e);
throw new ResponseStatusException(
FailureActionException.statusOf(e.getReason()), e.getMessage(), e);
}
}
@@ -147,18 +150,6 @@ public class FileRunEventController {
return Arrays.stream(FailureKind.values()).map(FailureKindView::of).toList();
}
/**
* A closed row is a conflict rather than a bad request: the request was well-formed and would
* have been valid a moment earlier.
*/
private static HttpStatus statusFor(FailureActionException.Reason reason) {
return switch (reason) {
case EVENT_NOT_FOUND -> HttpStatus.NOT_FOUND;
case ACTION_NOT_RECOGNISED, ACTION_NOT_DECLARED -> HttpStatus.BAD_REQUEST;
case ALREADY_CLOSED -> HttpStatus.CONFLICT;
};
}
/** Wrapped rather than a bare array so pagination can be added without breaking clients. */
public record FileRunEventsResponse(List<FileRunEventView> events) {}
@@ -178,10 +169,13 @@ public class FileRunEventController {
}
}
/** Inputs an action declared it needs. Empty for both actions that exist today. */
/**
* Inputs an action declared it needs. Empty for every action the server runs today: the one
* that needs a password is run by the client, which never sends it here.
*/
public record ActionRequest(Map<String, String> inputs) {
Map<String, String> safeInputs() {
public Map<String, String> safeInputs() {
return inputs == null ? Map.of() : inputs;
}
}
@@ -98,20 +98,18 @@ public interface FileRunEventRepository extends JpaRepository<FileRunEventEntity
* Close the incidents about documents their owner deleted from the editor: the queue is what
* needs attention, and a document that no longer exists needs none.
*
* <p>Restricted to that owner's own editor rows. File ids are minted by the client, so scoping
* on team alone would let one caller close a colleague's incidents by naming ids. Processor
* rows are excluded outright: nothing was deleted from an editor there.
* <p>Scoped by the absence of a source rather than by origin: a source-fed run's {@code fileId}
* is a hash no client can name. Narrowed to the owner's own rows, since clients mint the ids.
*/
@Modifying(clearAutomatically = true)
@Transactional
@Query(
"update FileRunEventEntity e set e.status ="
+ " stirling.software.proprietary.failure.FileRunEventStatus.FILE_REMOVED,"
+ " e.statusActor = :actor, e.statusAt = :now where e.origin ="
+ " stirling.software.proprietary.failure.FailureOrigin.TOOL and ((:teamId is"
+ " null and e.teamId is null) or e.teamId = :teamId) and ((:actor is null and"
+ " e.actor is null) or e.actor = :actor) and e.fileId in :fileIds and e.status in"
+ " :allowedFrom")
+ " e.statusActor = :actor, e.statusAt = :now where e.sourceId is null and"
+ " ((:teamId is null and e.teamId is null) or e.teamId = :teamId) and"
+ " ((:actor is null and e.actor is null) or e.actor = :actor) and e.fileId in"
+ " :fileIds and e.status in :allowedFrom")
int markFilesRemoved(
@Param("teamId") Long teamId,
@Param("actor") String actor,
@@ -21,12 +21,22 @@ import stirling.software.proprietary.policy.config.PolicyManagementAuthority;
* team always comes from the authenticated principal, and scoping applies only when login is
* enabled so single-user deployments keep working. When the team cannot be resolved the caller
* reads nothing; see {@link #readScope()}.
*
* <p>The read scope decides who sees an incident; {@link #availableActions} decides who may act.
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class FileRunEventService {
/** Why an offered action came back disabled. Copy lives under {@code portal.failures}. */
private static final String CLOSED_REASON_KEY = "portal.failures.disabled.closed";
private static final String UNATTENDED_REASON_KEY = "portal.failures.disabled.unattended";
/** The row never named a document, so unlike the unattended case no client can find one. */
private static final String DOCUMENTLESS_REASON_KEY = "portal.failures.disabled.noDocument";
private final FileRunEventStore store;
private final FailureActionRegistry actionRegistry;
private final PolicyManagementAuthority policyManagementAuthority;
@@ -116,36 +126,17 @@ public class FileRunEventService {
* Dispatch an action against one event.
*
* @throws FailureActionException if the event is not the caller's, the action is unknown, the
* event's kind does not declare the action, or the event is already closed
* event's kind does not declare the action, the client is what runs the action, or the
* event is already closed
*/
public FileRunEvent dispatch(String eventId, String actionId, Map<String, String> inputs) {
// Whoever can see it can close it: a leader for the whole team, everyone else for the
// failures they caused. Someone who fixes their own problem should not have to ask a leader
// to clear the row.
//
// Closing the row is all this covers. Acting on the document behind it, such as supplying a
// password for a retry, would need its own permission, and no such action exists yet.
ReadScope scope = readScope();
if (!scope.permitted()) {
// Reported as "no such event", the same as an id from another team, so the response
// does
// not depend on whether the id happens to exist.
throw new FailureActionException(
FailureActionException.Reason.EVENT_NOT_FOUND, "No such event: " + eventId);
}
FileRunEvent event =
store.find(eventId, scope.teamId())
// Reported as "no such event" rather than a refusal, so a member cannot
// learn that a colleague's incident exists by trying to close it.
.filter(
found ->
scope.actor() == null
|| scope.actor().equals(found.actor()))
.orElseThrow(
() ->
new FailureActionException(
FailureActionException.Reason.EVENT_NOT_FOUND,
"No such event: " + eventId));
// Audience decides what is offered, not what may be dispatched, so this scope is the whole
// gate. A server action aimed at OWNER alone would need its own guard here.
FileRunEvent event = requireVisible(eventId);
FailureActionId resolvedId = parseActionId(actionId);
@@ -156,6 +147,12 @@ public class FileRunEventService {
FailureActionException.Reason.ACTION_NOT_DECLARED,
"Kind " + event.kind().getId() + " does not offer action " + resolvedId);
}
// Without this a client could post VIEW_FILE and be answered as though something happened.
if (!resolvedId.runsOnServer()) {
throw new FailureActionException(
FailureActionException.Reason.ACTION_NOT_DISPATCHABLE,
"Action " + resolvedId + " is run by the client, not the server");
}
if (event.status().terminal()) {
throw new FailureActionException(
FailureActionException.Reason.ALREADY_CLOSED,
@@ -174,23 +171,91 @@ public class FileRunEventService {
return action.execute(event, inputs == null ? Map.of() : inputs, currentActor());
}
/** "No such event" rather than a refusal, so trying does not confirm a colleague's exists. */
private FileRunEvent requireVisible(String eventId) {
ReadScope scope = readScope();
if (!scope.permitted()) {
return notFound(eventId);
}
return store.find(eventId, scope.teamId())
.filter(found -> scope.actor() == null || scope.actor().equals(found.actor()))
.orElseGet(() -> notFound(eventId));
}
private FileRunEvent notFound(String eventId) {
throw new FailureActionException(
FailureActionException.Reason.EVENT_NOT_FOUND, "No such event: " + eventId);
}
public Ownership ownershipOf(FileRunEvent event) {
if (event.actor() == null) {
return Ownership.UNOWNED;
}
String caller = currentActor();
return event.actor().equals(caller) ? Ownership.MINE : Ownership.THEIRS;
}
/**
* Which of an event's declared actions are usable right now. Decided per row, so the client
* never renders a button that would be refused.
* Offers resolved for one caller, so no client renders a button that would be refused. Outside
* their audience is dropped, not disabled: greyed out would read as a permission problem.
*/
public List<AvailableAction> availableActions(FileRunEvent event) {
Ownership ownership = ownershipOf(event);
boolean reviewsTeam = reviewsTeam();
boolean closed = event.status().terminal();
return event.kind().getActions().stream()
.map(
action ->
new AvailableAction(
action,
event.kind().labelKeyFor(action),
!closed,
closed ? "portal.failures.disabled.closed" : null))
// Login disabled is excluded: its rows are unowned only for want of users, and its one
// operator owns everything they can see.
boolean unattended = enforced() && ownership == Ownership.UNOWNED;
// Answered here, or the client reports "not on this device" about a document the row never
// identified in the first place.
boolean documentless = event.fileId() == null || event.fileId().isBlank();
return event.kind().getOfferedActions().stream()
.filter(offer -> offeredTo(offer.audience(), ownership, reviewsTeam))
.map(offer -> availability(offer, closed, unattended, documentless))
.toList();
}
/** Enabled is derived from the reason, so a disabled button always has one to show. */
private static AvailableAction availability(
FailureKind.OfferedAction offer,
boolean closed,
boolean unattended,
boolean documentless) {
String reason = disabledReasonFor(offer.audience(), closed, unattended, documentless);
return new AvailableAction(offer.id(), offer.labelKey(), reason == null, reason);
}
/** Closed wins over everything, then the owner-only reasons, most specific first. */
private static String disabledReasonFor(
FailureAudience audience, boolean closed, boolean unattended, boolean documentless) {
if (closed) {
return CLOSED_REASON_KEY;
}
if (audience != FailureAudience.OWNER) {
return null;
}
if (unattended) {
return UNATTENDED_REASON_KEY;
}
return documentless ? DOCUMENTLESS_REASON_KEY : null;
}
/** An unattended incident has no owner, so its reviewer inherits the owner's actions. */
private static boolean offeredTo(
FailureAudience audience, Ownership ownership, boolean reviewsTeam) {
return switch (audience) {
case OWNER ->
ownership == Ownership.MINE || (ownership == Ownership.UNOWNED && reviewsTeam);
case TEAM_REVIEWER -> reviewsTeam;
case ANYONE_WHO_SEES -> true;
};
}
/** Login disabled has no roles, so its one operator triages everything. */
private boolean reviewsTeam() {
return !enforced() || policyManagementAuthority.canEditPolicies();
}
private FailureActionId parseActionId(String actionId) {
for (FailureActionId candidate : FailureActionId.values()) {
if (candidate.name().equals(actionId)) {
@@ -261,7 +326,6 @@ public class FileRunEventService {
return applicationProperties.getSecurity().isEnableLogin();
}
/** One action as offered for a specific event, with its resolved availability. */
public record AvailableAction(
FailureActionId id, String labelKey, boolean enabled, String disabledReasonKey) {}
}
@@ -60,14 +60,24 @@ public record FileRunEventView(
event.lastSeenAt() == null ? 0L : event.lastSeenAt().toEpochMilli());
}
/** One button, as offered for this specific row. */
/**
* {@code defaultLabel} and {@code execution} let a client render and route an action it was
* never built with. Declaration order is display order.
*/
public record ActionView(
String id, String labelKey, boolean enabled, String disabledReasonKey) {
String id,
String labelKey,
String defaultLabel,
FailureActionId.Execution execution,
boolean enabled,
String disabledReasonKey) {
static ActionView of(FileRunEventService.AvailableAction action) {
public static ActionView of(FileRunEventService.AvailableAction action) {
return new ActionView(
action.id().name(),
action.labelKey(),
action.id().getDefaultLabel(),
action.id().getExecution(),
action.enabled(),
action.disabledReasonKey());
}
@@ -0,0 +1,17 @@
package stirling.software.proprietary.failure;
/**
* Whose incident this is, from the reader's point of view. Derived on read, never persisted: one
* row is {@code MINE} to whoever hit it and {@code THEIRS} to the leader reviewing after them.
*/
public enum Ownership {
MINE,
/** A colleague's, visible because the caller reviews the team. */
THEIRS,
/**
* An unattended run: a folder, bucket or webhook is its only attribution, so there is no owner.
*/
UNOWNED
}
@@ -0,0 +1,48 @@
package stirling.software.proprietary.notification;
import java.util.List;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import io.swagger.v3.oas.annotations.Hidden;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
/**
* Open to any authenticated user, unlike the failure endpoints it draws on: each source scopes its
* own rows. Read-only, because every action a notification offers runs on the client's own device.
*/
@RestController
@RequestMapping("/api/v1/notifications")
@Hidden
@RequiredArgsConstructor
@Tag(name = "Notifications", description = "Things worth telling the caller about")
public class NotificationController {
/** How many notifications one read returns when the caller does not say: one panelful. */
private static final int DEFAULT_LIMIT = 20;
/** The most one read may return however large a limit the caller asks for. */
private static final int MAX_LIMIT = 100;
private final NotificationService notifications;
@GetMapping
@Operation(
summary = "List the caller's notifications",
description =
"Newest first. Derived from the sources that produce them, so there is nothing"
+ " to mark read here yet: the client tracks what it has shown.")
public NotificationsResponse list(@RequestParam(required = false) Integer limit) {
int capped = Math.min(limit == null ? DEFAULT_LIMIT : Math.max(1, limit), MAX_LIMIT);
return new NotificationsResponse(notifications.list(capped));
}
/** Wrapped so paging or a total can be added without breaking clients. */
public record NotificationsResponse(List<NotificationView> notifications) {}
}
@@ -0,0 +1,53 @@
package stirling.software.proprietary.notification;
import java.util.List;
import org.springframework.stereotype.Service;
import lombok.RequiredArgsConstructor;
import stirling.software.proprietary.failure.FileRunEvent;
import stirling.software.proprietary.failure.FileRunEventService;
import stirling.software.proprietary.failure.FileRunEventView;
/**
* Derived on read rather than stored: one source today, and a table would need a write path,
* retention and a per-user read model first. Each source scopes its own rows, so this cannot widen.
*/
@Service
@RequiredArgsConstructor
public class NotificationService {
private final FileRunEventService fileRunEvents;
/** Newest first, and only open failures: one already dealt with is not news. */
public List<NotificationView> list(int limit) {
return fileRunEvents.list(null, null, limit).stream().map(this::fromFailure).toList();
}
/** Prefixes the row id on the way out, so it is never sent bare. */
private NotificationView fromFailure(FileRunEvent event) {
return new NotificationView(
NotificationSource.FAILURE.qualify(event.id()),
NotificationSource.FAILURE,
event.kind().getId(),
event.origin(),
fileRunEvents.ownershipOf(event),
event.severity(),
event.status(),
event.kind().getTitleKey(),
event.kind().getDefaultTitle(),
event.detail(),
event.fileId(),
event.sourceId(),
event.policyId(),
event.occurrences(),
event.createdAt(),
event.lastSeenAt(),
// A disposition such as Dismiss belongs to the review surface, not the bell.
fileRunEvents.availableActions(event).stream()
.filter(action -> !action.id().runsOnServer())
.map(FileRunEventView.ActionView::of)
.toList());
}
}
@@ -0,0 +1,21 @@
package stirling.software.proprietary.notification;
import java.util.Locale;
/**
* Which subsystem produced a notification. Every id is prefixed with it, so a client never holds
* the producing row's own id and cannot reach that source's endpoints by accident.
*/
public enum NotificationSource {
FAILURE;
private static final char SEPARATOR = ':';
public String prefix() {
return name().toLowerCase(Locale.ROOT) + SEPARATOR;
}
public String qualify(String sourceRowId) {
return prefix() + sourceRowId;
}
}
@@ -0,0 +1,33 @@
package stirling.software.proprietary.notification;
import java.time.Instant;
import java.util.List;
import stirling.software.proprietary.failure.FailureOrigin;
import stirling.software.proprietary.failure.FailureSeverity;
import stirling.software.proprietary.failure.FileRunEventStatus;
import stirling.software.proprietary.failure.FileRunEventView;
import stirling.software.proprietary.failure.Ownership;
/**
* A source's row flattened to what a bell renders. {@code fileId} is an opaque reference, never a
* name, and two id spaces share it: {@code sourceId} tells them apart.
*/
public record NotificationView(
String id,
NotificationSource source,
String kindId,
FailureOrigin origin,
Ownership ownership,
FailureSeverity severity,
FileRunEventStatus status,
String titleKey,
String defaultTitle,
String detail,
String fileId,
String sourceId,
String policyId,
int occurrences,
Instant createdAt,
Instant lastSeenAt,
List<FileRunEventView.ActionView> actions) {}
@@ -576,7 +576,9 @@ public class PolicyController {
+ " under 'fileInput', supporting files under 'assets[i].key' /"
+ " 'assets[i].file' - only for bindings the policy does not already"
+ " store). Runs regardless of the policy's enabled flag, which only"
+ " gates automatic triggering. Returns a run id.")
+ " gates automatic triggering. A single-document run may also send its"
+ " own opaque 'fileId', which is recorded against any failure so the"
+ " caller can resolve it back to that document. Returns a run id.")
public ResponseEntity<JobResponse<Void>> runStoredPolicy(
@PathVariable String policyId, @Valid @ModelAttribute PolicyRunFiles files)
throws IOException {
@@ -590,7 +592,14 @@ public class PolicyController {
HttpStatus.NOT_FOUND, "No policy: " + policyId));
stampPolicyAudit(policy.toDefinition());
PolicyInputs inputs = toInputs(files);
String runId = policyRunner.runWith(policy, inputs, PolicyProgressListener.NOOP).runId();
String runId =
policyRunner
.runWith(
policy,
inputs,
PolicyProgressListener.NOOP,
documentReferenceFor(files, inputs))
.runId();
return ResponseEntity.accepted().body(new JobResponse<>(true, runId, null));
}
@@ -722,6 +731,19 @@ public class PolicyController {
return new PolicyInputs(primary, supportingFiles);
}
/**
* Only for a single-document run: an incident holds one file reference, so naming one of
* several would attribute the failure to whichever bound first. Counted off resolved inputs,
* not parts.
*/
private static String documentReferenceFor(PolicyRunFiles files, PolicyInputs inputs) {
String fileId = files.getFileId();
if (fileId == null || fileId.isBlank() || inputs.primary().size() != 1) {
return null;
}
return fileId;
}
private PolicyProgressListener streamListener(SseEmitter emitter) {
return new PolicyProgressListener() {
@Override
@@ -16,8 +16,8 @@ import lombok.Data;
* from the multipart request via {@code @ModelAttribute}; the pipeline definition itself travels as
* a separate typed {@code json} part.
*
* <p>Wire form: {@code fileInput} (repeated) for primaries, and {@code assets[i].key} / {@code
* assets[i].file} for each supporting asset.
* <p>Wire form: {@code fileInput} (repeated) for primaries, {@code assets[i].key} / {@code
* assets[i].file} for each supporting asset, and the optional {@code fileId}.
*/
@Data
@Schema(description = "Files for a policy run: primary documents plus keyed supporting assets")
@@ -29,4 +29,16 @@ public class PolicyRunFiles {
@Valid
@Schema(description = "Supporting files, each bound to the asset key its step references")
private List<NamedAsset> assets = new ArrayList<>();
/**
* Recorded against any failure of this run, so the client can resolve the row back to its
* document. Opaque by contract, never a name, and only honoured for a single-document run.
*/
@Schema(
description =
"The caller's opaque id for the document being run, echoed onto any failure"
+ " recorded for this run so the originating client can resolve it."
+ " Ignored unless exactly one primary document is supplied. Never a"
+ " filename.")
private String fileId;
}
@@ -151,7 +151,8 @@ public class PolicyEngine {
* As {@link #runPolicy(Policy, PolicyInputs, PolicyProgressListener)}, recording which source
* fed the run and its opaque reference to the document. The first says where an unattended
* failure came from; the second says which document, and is what lets the same document failing
* again fold into one incident. Both null for a user's upload.
* again fold into one incident. With no source {@code fileIdentity} is the client's own
* reference, with one it is that source's hash; this engine only carries it either way.
*/
public PolicyRunHandle runPolicy(
Policy policy,
@@ -120,10 +120,17 @@ public class PolicyRunner {
* Run a stored policy on caller-supplied files (e.g. an editor upload), bypassing its sources.
* The supplied documents are still counted against the virtual {@link EditorSource}, scoped to
* the policy's team, so the Sources overview reports the whole team's editor throughput.
*
* @param documentReference the caller's own opaque reference to the single document it runs on,
* or null when it supplied none or several. Passed through untouched.
*/
public PolicyRunHandle runWith(
Policy policy, PolicyInputs inputs, PolicyProgressListener listener) {
PolicyRunHandle handle = policyEngine.runPolicy(policy, inputs, listener);
Policy policy,
PolicyInputs inputs,
PolicyProgressListener listener,
String documentReference) {
PolicyRunHandle handle =
policyEngine.runPolicy(policy, inputs, listener, null, documentReference);
docCounter.record(EditorSource.counterKey(policy.teamId()), inputs.primary().size());
return handle;
}
@@ -21,9 +21,9 @@ import org.mockito.ArgumentCaptor;
import tools.jackson.databind.ObjectMapper;
/**
* Stubs the {@link HttpClient} so the SaaS endpoint is never actually called. Confirms register
* relays the JWT and parses the credential, and that entitlement parsing + the fail-open (null on
* unreachable) behaviour hold.
* Stubs the {@link HttpClient} so the SaaS endpoint is never actually called. Confirms the connect
* handshake refuses an authorize URL it would not navigate to and carries no user token, and that
* entitlement parsing + the fail-open (null on unreachable) behaviour hold.
*/
class AccountLinkClientTest {
@@ -48,39 +48,79 @@ class AccountLinkClientTest {
return resp;
}
// register() is gone with the JWT relay, and with it the two tests that asserted this client
// sends an Authorization: Bearer header. Nothing here carries a user token any more.
@Test
@SuppressWarnings("unchecked")
void registerRelaysJwtAndParsesCredential() throws Exception {
// Build the stub response first: nesting response() inside when() trips Mockito's
// unfinished-stubbing check (inner when() runs mid outer when()).
void connectRequestRefusesAnAuthorizeUrlItWouldNotNavigateTo() throws Exception {
// The reply drives a browser navigation, so a non-absolute or non-http(s) value must fail
// loudly here rather than reach the admin.
HttpResponse<String> resp =
response(201, "{\"deviceId\":\"dev-1\",\"deviceSecret\":\"sec-1\",\"teamId\":42}");
ArgumentCaptor<HttpRequest> captor = ArgumentCaptor.forClass(HttpRequest.class);
when(httpClient.send(captor.capture(), any(HttpResponse.BodyHandler.class)))
.thenReturn(resp);
response(201, "{\"requestId\":\"req-1\",\"authorizeUrl\":\"/link?request=req-1\"}");
when(httpClient.send(any(), any(HttpResponse.BodyHandler.class))).thenReturn(resp);
AccountLinkClient.RegisterResult result = client.register("jwt-token", "My Server");
assertEquals("dev-1", result.deviceId());
assertEquals("sec-1", result.deviceSecret());
assertEquals(42L, result.teamId());
HttpRequest sent = captor.getValue();
assertEquals("Bearer jwt-token", sent.headers().firstValue("Authorization").orElse(null));
assertEquals(
"https://saas.example.com/api/v1/account-link/register", sent.uri().toString());
assertThrows(
java.io.IOException.class,
() -> client.connectRequest("n", "https://pdf.example.com/cb", "nonce", "secret"));
}
@Test
@SuppressWarnings("unchecked")
void registerThrowsUpstreamExceptionWithStatusOnNon2xx() throws Exception {
HttpResponse<String> resp = response(401, "{\"error\":\"unauthorized\"}");
void connectRequestParsesTheAuthorizeUrlItIsGiven() throws Exception {
HttpResponse<String> resp =
response(
201,
"{\"requestId\":\"req-1\",\"expiresIn\":900,"
+ "\"authorizeUrl\":\"https://app.example.com/link?request=req-1\"}");
ArgumentCaptor<HttpRequest> captor = ArgumentCaptor.forClass(HttpRequest.class);
when(httpClient.send(captor.capture(), any(HttpResponse.BodyHandler.class)))
.thenReturn(resp);
AccountLinkClient.ConnectRequestResult result =
client.connectRequest("n", "https://pdf.example.com/cb", "nonce", "secret");
assertEquals("req-1", result.requestId());
assertEquals("https://app.example.com/link?request=req-1", result.authorizeUrl());
// No user token on this call, by design.
assertEquals(null, captor.getValue().headers().firstValue("Authorization").orElse(null));
}
@Test
@SuppressWarnings("unchecked")
void connectClaimGrantsTheCredentialOnSuccess() throws Exception {
HttpResponse<String> resp =
response(200, "{\"deviceId\":\"dev-1\",\"deviceSecret\":\"sec-1\",\"teamId\":7}");
when(httpClient.send(any(), any(HttpResponse.BodyHandler.class))).thenReturn(resp);
AccountLinkClient.UpstreamException ex =
assertThrows(
AccountLinkClient.UpstreamException.class,
() -> client.register("jwt", null));
assertEquals(401, ex.status());
AccountLinkClient.ConnectClaimResult result = client.connectClaim("req-1", "secret");
assertEquals(AccountLinkClient.ConnectClaimOutcome.GRANTED, result.outcome());
assertEquals("dev-1", result.deviceId());
assertEquals("sec-1", result.deviceSecret());
}
@Test
@SuppressWarnings("unchecked")
void connectClaimMapsTheStatusItIsGiven() throws Exception {
// The whole point of these four: a claim consumes the request server-side, so
// reading 200 as anything but success loses the credential irrecoverably.
assertEquals(AccountLinkClient.ConnectClaimOutcome.PENDING, claimOutcome(202, "{}"));
assertEquals(AccountLinkClient.ConnectClaimOutcome.UNAVAILABLE, claimOutcome(503, "{}"));
assertEquals(AccountLinkClient.ConnectClaimOutcome.REJECTED, claimOutcome(400, "{}"));
assertEquals(
AccountLinkClient.ConnectClaimOutcome.CONFIRMED,
claimOutcome(200, "{\"status\":\"confirmed\",\"teamId\":7}"));
}
@SuppressWarnings("unchecked")
private AccountLinkClient.ConnectClaimOutcome claimOutcome(int status, String body)
throws Exception {
// Built before the when(), not inside it: response() stubs a mock of its own, and
// Mockito cannot have that happen mid-stubbing.
HttpResponse<String> resp = response(status, body);
when(httpClient.send(any(), any(HttpResponse.BodyHandler.class))).thenReturn(resp);
return client.connectClaim("req-1", "secret").outcome();
}
@Test
@@ -1,6 +1,7 @@
package stirling.software.proprietary.accountlink;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
@@ -14,16 +15,15 @@ import org.springframework.beans.factory.ObjectProvider;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import stirling.software.proprietary.accountlink.AccountLinkController.LinkRequest;
/**
* The local (self-hosted) account-link controller's error mapping: an upstream auth rejection
* surfaces as 401/403 (so the portal can prompt a re-sign-in) while other upstream / transport
* faults are a 502.
* The local (self-hosted) account-link controller's error mapping. Every upstream or transport
* failure is a 502, and the response body never echoes the exception, because a DNS or TLS message
* can carry the configured SaaS host.
*/
class AccountLinkControllerTest {
private AccountLinkService service;
private ConnectService connectService;
private UsageSyncService syncService;
private ObjectProvider<UsageSyncService> syncProvider;
private AccountLinkController controller;
@@ -32,47 +32,54 @@ class AccountLinkControllerTest {
@SuppressWarnings("unchecked")
void setUp() {
service = mock(AccountLinkService.class);
connectService = mock(ConnectService.class);
syncService = mock(UsageSyncService.class);
syncProvider = mock(ObjectProvider.class);
controller =
new AccountLinkController(service, mock(LocalUsageService.class), syncProvider);
new AccountLinkController(
service, connectService, mock(LocalUsageService.class), syncProvider);
}
@Test
void link_missingJwt_returns400() {
ResponseEntity<?> resp = controller.link(new LinkRequest(" ", null));
assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST);
}
// These asserted POST /link's error mapping, which distinguished 401/403 so the portal could
// prompt a re-sign-in. That endpoint is gone with the JWT relay, and the distinction went with
// it: connect/start carries no user token, so an upstream refusal is never the admin's session
// and everything non-transport is a plain gateway failure.
@Test
void link_upstreamUnauthorized_maps401() throws Exception {
when(service.link("jwt", null))
.thenThrow(new AccountLinkClient.UpstreamException(401, "bad token"));
ResponseEntity<?> resp = controller.link(new LinkRequest("jwt", null));
assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED);
}
@Test
void link_upstreamForbidden_maps403() throws Exception {
when(service.link("jwt", null))
.thenThrow(new AccountLinkClient.UpstreamException(403, "forbidden"));
ResponseEntity<?> resp = controller.link(new LinkRequest("jwt", null));
assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.FORBIDDEN);
}
@Test
void link_upstreamServerError_maps502() throws Exception {
when(service.link("jwt", null))
void connectStart_upstreamFailure_maps502() throws Exception {
when(connectService.start(any(), any()))
.thenThrow(new AccountLinkClient.UpstreamException(500, "boom"));
ResponseEntity<?> resp = controller.link(new LinkRequest("jwt", null));
ResponseEntity<?> resp = controller.connectStart(null, request());
assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.BAD_GATEWAY);
}
@Test
void link_transportFailure_maps502() throws Exception {
when(service.link("jwt", null)).thenThrow(new IOException("connection refused"));
ResponseEntity<?> resp = controller.link(new LinkRequest("jwt", null));
void connectStart_transportFailure_maps502WithoutLeakingTheHost() throws Exception {
when(connectService.start(any(), any()))
.thenThrow(new IOException("connection refused to saas.internal:8081"));
ResponseEntity<?> resp = controller.connectStart(null, request());
assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.BAD_GATEWAY);
// The body must not echo the exception: a DNS/TLS message can carry the configured SaaS
// host.
assertThat(String.valueOf(resp.getBody())).doesNotContain("saas.internal");
}
@Test
void connectReauth_onAnUnlinkedServer_maps502() throws Exception {
when(connectService.startReauth(any())).thenThrow(new IOException("not linked"));
ResponseEntity<?> resp = controller.connectReauth(null, request());
assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.BAD_GATEWAY);
}
/** Minimal request: the controller only reads Origin and the forwarded/host details from it. */
private static jakarta.servlet.http.HttpServletRequest request() {
return new org.springframework.mock.web.MockHttpServletRequest();
}
@Test
@@ -3,12 +3,10 @@ package stirling.software.proprietary.accountlink;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import java.io.IOException;
import java.time.LocalDateTime;
import java.util.Optional;
@@ -30,33 +28,25 @@ class AccountLinkServiceTest {
service = new AccountLinkService(client, store, cache);
}
// The two link() tests here are gone with the JWT relay. Storing a credential and invalidating
// the entitlement cache is now ConnectService's job and is covered by ConnectServiceTest; what
// remains in this service is status and unlink.
@Test
void link_storesCredentialAndInvalidatesCache() throws IOException {
when(client.register("jwt", "name"))
.thenReturn(new AccountLinkClient.RegisterResult("dev-1", "sec-1", 7L));
void status_linkedFromTheStoredCredential() {
DeviceCredential stored = new DeviceCredential();
stored.setDeviceId("dev-1");
stored.setTeamId(7L);
stored.setLinkedAt(LocalDateTime.now());
when(store.get()).thenReturn(Optional.of(stored));
AccountLinkService.LinkStatus status = service.link("jwt", "name");
AccountLinkService.LinkStatus status = service.status();
verify(store).save("dev-1", "sec-1", 7L);
verify(cache).invalidate();
assertTrue(status.linked());
assertEquals("dev-1", status.deviceId());
assertEquals(7L, status.teamId());
}
@Test
void link_propagatesRegisterFailure() throws IOException {
when(client.register(any(), any())).thenThrow(new IOException("boom"));
org.junit.jupiter.api.Assertions.assertThrows(
IOException.class, () -> service.link("jwt", null));
verify(cache, org.mockito.Mockito.never()).invalidate();
}
@Test
void status_unlinkedWhenNoCredential() {
when(store.get()).thenReturn(Optional.empty());
@@ -0,0 +1,408 @@
package stirling.software.proprietary.accountlink;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyNoInteractions;
import static org.mockito.Mockito.when;
import java.time.LocalDateTime;
import java.util.Optional;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.mockito.junit.jupiter.MockitoSettings;
import org.mockito.quality.Strictness;
import stirling.software.common.model.ApplicationProperties;
import stirling.software.proprietary.accountlink.AccountLinkClient.ConnectClaimOutcome;
import stirling.software.proprietary.accountlink.AccountLinkClient.ConnectClaimResult;
import stirling.software.proprietary.accountlink.AccountLinkClient.ConnectRequestResult;
import stirling.software.proprietary.accountlink.ConnectService.Phase;
/** Unit tests for the instance half of the connect handshake. */
@ExtendWith(MockitoExtension.class)
@MockitoSettings(strictness = Strictness.LENIENT)
class ConnectServiceTest {
private static final String NONCE = "the-nonce";
private static final String CLAIM_SECRET = "the-claim-secret";
private static final String AUTHORIZE_URL = "https://app.example.com/link?request=req-1";
@Mock private AccountLinkClient client;
@Mock private ConnectStateRepository stateRepo;
@Mock private DeviceCredentialStore credentialStore;
@Mock private EntitlementCache entitlementCache;
private ApplicationProperties applicationProperties;
private ConnectService service;
@BeforeEach
void setUp() {
applicationProperties = new ApplicationProperties();
service =
new ConnectService(
client,
stateRepo,
credentialStore,
entitlementCache,
applicationProperties);
}
private void configureFrontendUrl(String url) {
applicationProperties.getSystem().setFrontendUrl(url);
}
@Test
void start_advertisesTheConfiguredFrontendUrlInPreferenceToTheRequest() throws Exception {
configureFrontendUrl("https://pdf.example.com/");
stubCreate();
service.start("prod-1", fromRequest("http://10.0.0.5:8080"));
verify(client)
.connectRequest(
anyString(),
// Trailing slash trimmed, and the request's own view ignored.
org.mockito.ArgumentMatchers.eq(
"https://pdf.example.com" + ConnectService.CALLBACK_PATH),
anyString(),
anyString(),
// A first link carries no credential; that is what makes it a first link.
org.mockito.ArgumentMatchers.isNull());
}
@Test
void start_fallsBackToTheAddressTheRequestArrivedOn() throws Exception {
stubCreate();
service.start(null, fromRequest("https://pdf.internal:8443/stirling"));
ArgumentCaptor<String> callback = ArgumentCaptor.forClass(String.class);
verify(client).connectRequest(any(), callback.capture(), anyString(), anyString(), any());
// Context path preserved, so a subpath deployment gets a callback that resolves.
assertThat(callback.getValue())
.isEqualTo("https://pdf.internal:8443/stirling" + ConnectService.CALLBACK_PATH);
}
@Test
void start_withNoAddressAtAllFailsRatherThanGuessing() {
assertThat(catchIo(() -> service.start(null, fromRequest(null))))
.hasMessageContaining("system.frontendUrl");
verifyNoInteractions(client);
}
@Test
void resolveCallback_honoursThePortalsOwnCallbackWhenTheBrowserOriginAgrees() {
// The frontend is the only party that knows its router's base path.
String requested = "http://localhost:5173/app/account-link/callback";
assertThat(
service.resolveCallbackUrl(
new ConnectService.CallbackHint(
requested,
"http://localhost:5173",
"http://localhost:8080")))
.isEqualTo(requested);
}
@Test
void resolveCallback_ignoresACallbackFromADifferentOrigin() {
assertThat(
service.resolveCallbackUrl(
new ConnectService.CallbackHint(
"https://evil.example.com/steal",
"http://localhost:5173",
"http://localhost:8080")))
.isEqualTo("http://localhost:5173" + ConnectService.CALLBACK_PATH);
}
@Test
void resolveCallback_prefersTheBrowserOriginOverTheApiRequest() {
// The whole point: :5173 is where the admin is, :8080 is where the call landed.
assertThat(
service.resolveCallbackUrl(
new ConnectService.CallbackHint(
null, "http://localhost:5173", "http://localhost:8080")))
.isEqualTo("http://localhost:5173" + ConnectService.CALLBACK_PATH);
}
@Test
void resolveCallback_letsConfigurationBeatEverything() {
configureFrontendUrl("https://pdf.example.com/");
assertThat(
service.resolveCallbackUrl(
new ConnectService.CallbackHint(
"http://localhost:5173/account-link/callback",
"http://localhost:5173",
"http://localhost:8080")))
.isEqualTo("https://pdf.example.com" + ConnectService.CALLBACK_PATH);
}
@Test
void resolveCallback_ignoresAnUnusableOriginHeader() {
// "null" is what a browser sends for an opaque origin; it must not become a callback.
assertThat(
service.resolveCallbackUrl(
new ConnectService.CallbackHint(
null, "null", "http://localhost:8080")))
.isEqualTo("http://localhost:8080" + ConnectService.CALLBACK_PATH);
}
@Test
void start_sendsTheAdminWhereverSaaSSaidToSendThem() throws Exception {
stubCreate();
ConnectService.ConnectStatus status =
service.start(null, fromRequest("https://pdf.example.com"));
assertThat(status.phase()).isEqualTo(Phase.PENDING);
// Not composed here: only the SaaS side knows where its approval page lives, so an
// instance configuring that could only get it wrong.
assertThat(status.authorizeUrl()).isEqualTo(AUTHORIZE_URL);
}
@Test
void start_keepsTheNonceAndClaimSecretItSent() throws Exception {
stubCreate();
service.start(null, fromRequest("https://pdf.example.com"));
ArgumentCaptor<String> nonce = ArgumentCaptor.forClass(String.class);
ArgumentCaptor<String> secret = ArgumentCaptor.forClass(String.class);
verify(client).connectRequest(any(), anyString(), nonce.capture(), secret.capture(), any());
ArgumentCaptor<ConnectState> saved = ArgumentCaptor.forClass(ConnectState.class);
verify(stateRepo).save(saved.capture());
assertThat(saved.getValue().getNonce()).isEqualTo(nonce.getValue());
assertThat(saved.getValue().getClaimSecret()).isEqualTo(secret.getValue());
// Two independent secrets, not one value used twice.
assertThat(nonce.getValue()).isNotEqualTo(secret.getValue());
}
@Test
void start_whenAlreadyLinkedDoesNothing() throws Exception {
when(credentialStore.isLinked()).thenReturn(true);
when(credentialStore.get()).thenReturn(Optional.of(credential(7L)));
ConnectService.ConnectStatus status =
service.start(null, fromRequest("https://pdf.example.com"));
assertThat(status.phase()).isEqualTo(Phase.LINKED);
verifyNoInteractions(client);
verify(stateRepo, never()).save(any());
}
@Test
void complete_withTheRightNonceStoresTheCredentialAndClearsTheHandshake() {
ConnectState state = openHandshake();
when(stateRepo.findById(ConnectState.SINGLETON_ID)).thenReturn(Optional.of(state));
when(client.connectClaim("req-1", CLAIM_SECRET))
.thenReturn(new ConnectClaimResult(ConnectClaimOutcome.GRANTED, "dev", "sec", 7L));
ConnectService.ConnectStatus status = service.complete(NONCE);
assertThat(status.phase()).isEqualTo(Phase.LINKED);
assertThat(status.teamId()).isEqualTo(7L);
verify(credentialStore).save("dev", "sec", 7L);
verify(entitlementCache).invalidate();
verify(stateRepo).delete(state);
}
@Test
void complete_withAWrongNonceClaimsNothingAndLeavesTheHandshakeAlone() {
ConnectState state = openHandshake();
when(stateRepo.findById(ConnectState.SINGLETON_ID)).thenReturn(Optional.of(state));
ConnectService.ConnectStatus status = service.complete("not-the-nonce");
assertThat(status.phase()).isEqualTo(Phase.REJECTED);
// The important half: an unverified caller cannot cancel a legitimate handshake.
verify(stateRepo, never()).delete(any());
verifyNoInteractions(credentialStore);
verify(client, never()).connectClaim(anyString(), anyString());
}
@Test
void complete_withNoNonceAtAllIsRejected() {
when(stateRepo.findById(ConnectState.SINGLETON_ID))
.thenReturn(Optional.of(openHandshake()));
assertThat(service.complete(null).phase()).isEqualTo(Phase.REJECTED);
verify(client, never()).connectClaim(anyString(), anyString());
}
@Test
void complete_whenSaaSHasNotCommittedTheApprovalKeepsTheHandshake() {
when(stateRepo.findById(ConnectState.SINGLETON_ID))
.thenReturn(Optional.of(openHandshake()));
when(client.connectClaim(anyString(), anyString()))
.thenReturn(ConnectClaimResult.of(ConnectClaimOutcome.PENDING));
assertThat(service.complete(NONCE).phase()).isEqualTo(Phase.PENDING);
verify(stateRepo, never()).delete(any());
}
@Test
void complete_whenSaaSIsUnreachableKeepsTheHandshakeForARetry() {
when(stateRepo.findById(ConnectState.SINGLETON_ID))
.thenReturn(Optional.of(openHandshake()));
when(client.connectClaim(anyString(), anyString()))
.thenReturn(ConnectClaimResult.of(ConnectClaimOutcome.UNAVAILABLE));
assertThat(service.complete(NONCE).phase()).isEqualTo(Phase.UNAVAILABLE);
verify(stateRepo, never()).delete(any());
verifyNoInteractions(credentialStore);
}
@Test
void complete_whenDeclinedClearsTheHandshake() {
ConnectState state = openHandshake();
when(stateRepo.findById(ConnectState.SINGLETON_ID)).thenReturn(Optional.of(state));
when(client.connectClaim(anyString(), anyString()))
.thenReturn(ConnectClaimResult.of(ConnectClaimOutcome.REJECTED));
assertThat(service.complete(NONCE).phase()).isEqualTo(Phase.REJECTED);
verify(stateRepo).delete(state);
verifyNoInteractions(credentialStore);
}
@Test
void complete_onAnExpiredHandshakeClearsItWithoutClaiming() {
ConnectState state = openHandshake();
state.setExpiresAt(LocalDateTime.now().minusSeconds(1));
when(stateRepo.findById(ConnectState.SINGLETON_ID)).thenReturn(Optional.of(state));
assertThat(service.complete(NONCE).phase()).isEqualTo(Phase.EXPIRED);
verify(stateRepo).delete(state);
verify(client, never()).connectClaim(anyString(), anyString());
}
@Test
void startReauth_presentsTheCredentialSoSaaSCanPinTheTeam() throws Exception {
when(credentialStore.get()).thenReturn(Optional.of(credential(7L)));
when(client.connectRequest(any(), anyString(), anyString(), anyString(), any()))
.thenReturn(new ConnectRequestResult("req-1", 900, AUTHORIZE_URL));
service.startReauth(fromRequest("https://pdf.example.com"));
// Sending the credential is what makes the pinning trustworthy: the team comes from
// something only this instance holds.
verify(client)
.connectRequest(
any(),
anyString(),
anyString(),
anyString(),
org.mockito.ArgumentMatchers.argThat(
c -> c != null && "dev".equals(c.getDeviceId())));
}
@Test
void startReauth_onAnUnlinkedServerFails() {
assertThat(catchIo(() -> service.startReauth(fromRequest("https://pdf.example.com"))))
.hasMessageContaining("not linked");
verifyNoInteractions(client);
}
@Test
void complete_onAConfirmedReauthKeepsTheExistingCredential() {
ConnectState state = openHandshake();
when(stateRepo.findById(ConnectState.SINGLETON_ID)).thenReturn(Optional.of(state));
when(client.connectClaim(anyString(), anyString()))
.thenReturn(new ConnectClaimResult(ConnectClaimOutcome.CONFIRMED, null, null, 7L));
ConnectService.ConnectStatus status = service.complete(NONCE);
assertThat(status.phase()).isEqualTo(Phase.LINKED);
assertThat(status.teamId()).isEqualTo(7L);
// Nothing to store: a second credential would orphan the one we already hold.
verify(credentialStore, never()).save(anyString(), anyString(), any());
verify(stateRepo).delete(state);
}
@Test
void status_reportsNothingInFlightWhenThereIsNoHandshakeOrCredential() {
assertThat(service.status().phase()).isEqualTo(Phase.NONE);
}
@Test
void status_reportsAnExpiredHandshakeRatherThanOfferingAStaleLink() {
ConnectState state = openHandshake();
state.setExpiresAt(LocalDateTime.now().minusSeconds(1));
when(stateRepo.findById(ConnectState.SINGLETON_ID)).thenReturn(Optional.of(state));
ConnectService.ConnectStatus status = service.status();
assertThat(status.phase()).isEqualTo(Phase.EXPIRED);
assertThat(status.authorizeUrl()).isNull();
}
@Test
void status_countsDownWhileAHandshakeIsOpen() {
when(stateRepo.findById(ConnectState.SINGLETON_ID))
.thenReturn(Optional.of(openHandshake()));
ConnectService.ConnectStatus status = service.status();
assertThat(status.phase()).isEqualTo(Phase.PENDING);
assertThat(status.secondsRemaining()).isPositive();
assertThat(status.authorizeUrl()).isEqualTo("https://app.example.com/link?request=req-1");
}
// ---------------------------------------------------------------------------------------
/** A start with nothing but the reconstructed request URL, as a headless caller would send. */
private static ConnectService.CallbackHint fromRequest(String derivedBaseUrl) {
return new ConnectService.CallbackHint(null, null, derivedBaseUrl);
}
private void stubCreate() throws Exception {
// The five-argument overload: a first link passes a null credential rather than none.
when(client.connectRequest(any(), anyString(), anyString(), anyString(), any()))
.thenReturn(new ConnectRequestResult("req-1", 900, AUTHORIZE_URL));
}
private static ConnectState openHandshake() {
ConnectState state = new ConnectState();
state.setId(ConnectState.SINGLETON_ID);
state.setRequestId("req-1");
state.setNonce(NONCE);
state.setClaimSecret(CLAIM_SECRET);
state.setCallbackUrl("https://pdf.example.com/account-link/callback");
state.setAuthorizeUrl("https://app.example.com/link?request=req-1");
state.setCreatedAt(LocalDateTime.now());
state.setExpiresAt(LocalDateTime.now().plusMinutes(10));
return state;
}
private static DeviceCredential credential(Long teamId) {
DeviceCredential credential = new DeviceCredential();
credential.setDeviceId("dev");
credential.setDeviceSecret("sec");
credential.setTeamId(teamId);
credential.setLinkedAt(LocalDateTime.now());
return credential;
}
/** Runs a throwing call and returns the exception, so the assertion reads in one line. */
private static Throwable catchIo(ThrowingCall call) {
try {
call.run();
throw new AssertionError("expected the call to fail");
} catch (Exception e) {
return e;
}
}
private interface ThrowingCall {
void run() throws Exception;
}
}
@@ -0,0 +1,57 @@
package stirling.software.proprietary.failure;
import static org.assertj.core.api.Assertions.assertThat;
import java.util.Arrays;
import java.util.List;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
/**
* Pins the five enums {@code file_run_events} stores behind CHECK constraints: adding a value is a
* schema change dressed as a Java one, compiling here and failing against a real database.
*/
class CheckConstrainedEnumsTest {
@Test
@DisplayName("no value has been added to a CHECK-constrained column's enum")
void everyPersistedEnumStillMatchesTheShippedCheckConstraints() {
assertThat(names(FileRunEventStatus.values()))
.containsExactlyInAnyOrder(
"NEW", "ACKNOWLEDGED", "DISMISSED", "RESOLVED", "FILE_REMOVED");
assertThat(names(FailureOrigin.values()))
.containsExactlyInAnyOrder("TOOL", "POLICY", "PIPELINE");
assertThat(names(FailureStage.values()))
.containsExactlyInAnyOrder("INPUT", "INTERNAL", "OUTPUT", "BLOCKED", "NEVER_RAN");
assertThat(names(FailureSeverity.values()))
.containsExactlyInAnyOrder("ERROR", "WARNING", "INFO");
assertThat(names(FailureScope.values()))
.containsExactlyInAnyOrder("FILE", "RUN", "POLICY", "SOURCE", "SERVER");
}
@Test
@DisplayName("the facets added since are derived, not stored")
void nothingAddedToTheModelReachedTheTable() throws Exception {
// Resolved per reader, so a column would hold the wrong answer for all but one person.
List<Class<?>> persisted =
Arrays.stream(FileRunEventEntity.class.getDeclaredFields())
.filter(field -> !field.isSynthetic())
.map(java.lang.reflect.Field::getType)
.toList();
assertThat(persisted)
.doesNotContain(
FailureAudience.class,
FailureActionId.class,
FailureActionId.Execution.class,
Ownership.class);
// A plain varchar with no CHECK, which is what lets a new kind ship without a migration.
assertThat(FileRunEventEntity.class.getDeclaredField("kindId").getType())
.isEqualTo(String.class);
}
private static List<String> names(Enum<?>[] values) {
return Arrays.stream(values).map(Enum::name).toList();
}
}
@@ -1,6 +1,9 @@
package stirling.software.proprietary.failure;
import static org.assertj.core.api.Assertions.assertThat;
import static stirling.software.proprietary.failure.FailureAudience.ANYONE_WHO_SEES;
import static stirling.software.proprietary.failure.FailureAudience.OWNER;
import static stirling.software.proprietary.failure.FailureAudience.TEAM_REVIEWER;
import java.io.IOException;
import java.io.UncheckedIOException;
@@ -29,6 +32,13 @@ import stirling.software.common.util.ExceptionUtils;
*/
class FailureKindTest {
/** In full, so a declaration pairing the right action with the wrong audience cannot pass. */
private static FailureKind.OfferedAction offered(
FailureActionId id, FailureAudience audience, String labelKeySuffix) {
return new FailureKind.OfferedAction(
id, "portal.failures.action." + labelKeySuffix, audience);
}
@Nested
@DisplayName("every kind is well formed")
class Invariants {
@@ -60,6 +70,27 @@ class FailureKindTest {
assertThat(kind.getId()).matches("^[A-Z][A-Z0-9_]*$");
}
@ParameterizedTest
@EnumSource(FailureKind.class)
void declaresItsActionsInTheSameOrderAsEveryOtherKind(FailureKind kind) {
// Declaration order is display order and the first usable offer is the row's primary,
// so
// two kinds disagreeing would flip the solid button between rows.
List<FailureActionId> ranking =
List.of(
FailureActionId.VIEW_FILE,
FailureActionId.VIEW_IN_PROCESSOR,
FailureActionId.DISMISS);
List<FailureActionId> declared = kind.getActions();
assertThat(ranking)
.as("%s declares an action the shared ranking does not rank", kind.getId())
.containsAll(declared);
assertThat(declared)
.as("%s declares its actions out of the shared order", kind.getId())
.isEqualTo(ranking.stream().filter(declared::contains).toList());
}
@Test
void idsAreUnique() {
Set<String> ids = new HashSet<>();
@@ -88,6 +119,25 @@ class FailureKindTest {
}
}
@ParameterizedTest
@EnumSource(FailureKind.class)
void everyOfferSaysWhoItIsFor(FailureKind kind) {
// Read per row to decide what a caller is shown, so a null would leak a button.
for (FailureKind.OfferedAction offer : kind.getOfferedActions()) {
assertThat(offer.audience())
.as("%s offers %s", kind.getId(), offer.id())
.isNotNull();
}
}
@ParameterizedTest
@EnumSource(FailureKind.class)
void offersEachActionAtMostOnce(FailureKind kind) {
// The same action twice would be two buttons with one meaning, and labelKeyFor would
// answer for the first.
assertThat(kind.getActions()).doesNotHaveDuplicates();
}
@Test
void noTwoKindsClaimTheSameErrorCode() {
// Computed independently of duplicateErrorCodes(), then checked against it: the boot
@@ -182,10 +232,16 @@ class FailureKindTest {
class Unknown {
@Test
void offersOnlyTheActionThatClearsIt() {
// Nothing here can be fixed, so "seen it" and "clear it" would be the same decision.
// Offering both just asks the reviewer to press two buttons to reach one outcome.
assertThat(FailureKind.UNKNOWN.getActions()).containsExactly(FailureActionId.DISMISS);
void offersItsOwnerTheirDocumentAndTheRunToWhoeverReviews() {
// Nothing here is known to be fixable, so the offers are just the places to look.
assertThat(FailureKind.UNKNOWN.getOfferedActions())
.containsExactly(
offered(FailureActionId.VIEW_FILE, OWNER, "viewFile"),
offered(
FailureActionId.VIEW_IN_PROCESSOR,
TEAM_REVIEWER,
"viewInProcessor"),
offered(FailureActionId.DISMISS, ANYONE_WHO_SEES, "dismiss"));
}
@Test
@@ -238,24 +294,49 @@ class FailureKindTest {
}
@Test
void aKindWithSomethingToFixOffersTheFixAndAWayToSkipIt() {
assertThat(FailureKind.INPUT_PASSWORD_PROTECTED.getActions())
.containsExactly(FailureActionId.ACKNOWLEDGE, FailureActionId.DISMISS);
void offersTheDocumentToItsOwnerAndTheRunToItsReviewer() {
// The point of the audiences: only the owner holds the document.
assertThat(FailureKind.INPUT_PASSWORD_PROTECTED.getOfferedActions())
.containsExactly(
offered(FailureActionId.VIEW_FILE, OWNER, "viewFile"),
offered(
FailureActionId.VIEW_IN_PROCESSOR,
TEAM_REVIEWER,
"viewInProcessor"),
offered(FailureActionId.DISMISS, ANYONE_WHO_SEES, "dismiss"));
}
@Test
void overriddenLabelWinsOverTheGenericOne() {
String label =
FailureKind.INPUT_PASSWORD_PROTECTED.labelKeyFor(FailureActionId.DISMISS);
assertThat(label).isEqualTo("portal.failures.action.dismissSkipFile");
assertThat(label).isNotEqualTo(FailureKind.genericLabelKey(FailureActionId.DISMISS));
void noKindOffersAcknowledgeAnyMore() {
// Kept in the vocabulary for rows already ACKNOWLEDGED; offered by nothing, so
// dispatchable by nothing.
for (FailureKind kind : FailureKind.values()) {
assertThat(kind.declares(FailureActionId.ACKNOWLEDGE))
.as("%s offers ACKNOWLEDGE", kind.getId())
.isFalse();
}
}
@Test
void genericLabelIsUsedWhenAKindDeclaresNoOverride() {
void everyKindLabelsItsActionsWithTheSharedWordingToday() {
// The per-kind override still exists for wording that reads badly in context.
for (FailureKind kind : FailureKind.values()) {
for (FailureActionId action : kind.getActions()) {
assertThat(kind.labelKeyFor(action))
.isEqualTo(FailureKind.genericLabelKey(action));
}
}
}
@Test
void genericLabelIsDerivedFromTheActionId() {
assertThat(FailureKind.UNKNOWN.labelKeyFor(FailureActionId.DISMISS))
.isEqualTo(FailureKind.genericLabelKey(FailureActionId.DISMISS))
.isEqualTo("portal.failures.action.dismiss");
assertThat(
FailureKind.INPUT_PASSWORD_PROTECTED.labelKeyFor(
FailureActionId.VIEW_IN_PROCESSOR))
.isEqualTo("portal.failures.action.viewInProcessor");
}
@Test
@@ -128,7 +128,9 @@ class FileRunEventControllerTest {
}
@Test
void carriesActionsAlreadyResolvedForTheRow() {
void carriesActionsAlreadyResolvedForTheRowAndItsReader() {
// A leader reading a colleague's password failure: the unlock is not theirs to do,
// so it is not in the list at all.
given(FailureKind.INPUT_PASSWORD_PROTECTED, TEAM, "f1");
List<FileRunEventView.ActionView> actions =
@@ -136,10 +138,32 @@ class FileRunEventControllerTest {
assertThat(actions)
.extracting(FileRunEventView.ActionView::id)
.containsExactlyInAnyOrder("ACKNOWLEDGE", "DISMISS");
.containsExactly("VIEW_IN_PROCESSOR", "DISMISS");
assertThat(actions).allMatch(FileRunEventView.ActionView::enabled);
}
@Test
void carriesEnoughForAClientToRenderAndRouteAnActionItDoesNotKnow() {
// The English fallback, which side runs it, and where the kind wants it: everything a
// build with no copy for a newly shipped action still needs.
given(FailureKind.INPUT_PASSWORD_PROTECTED, TEAM, "f1");
assertThat(controller.list(null, null, null).events().getFirst().actions())
.allSatisfy(
action -> {
assertThat(action.defaultLabel()).isNotBlank();
assertThat(action.execution()).isNotNull();
})
.filteredOn(action -> "VIEW_IN_PROCESSOR".equals(action.id()))
.singleElement()
.satisfies(
action -> {
assertThat(action.execution())
.isEqualTo(FailureActionId.Execution.CLIENT);
assertThat(action.defaultLabel()).isEqualTo("View in processor");
});
}
@Test
void showsAClosedRowsActionsDisabledWithAReasonRatherThanHidingThem() {
// Only visible by asking for the closed status: the default queue drops it.
@@ -162,14 +186,18 @@ class FileRunEventControllerTest {
void filtersByStatusAndByKind() {
FileRunEvent locked = given(FailureKind.INPUT_PASSWORD_PROTECTED, TEAM, "locked");
given(FailureKind.UNKNOWN, TEAM, "open");
controller.act(locked.id(), "ACKNOWLEDGE", null);
controller.act(locked.id(), "DISMISS", null);
assertThat(controller.list(FileRunEventStatus.ACKNOWLEDGED, null, null).events())
.hasSize(1);
assertThat(controller.list(null, "INPUT_PASSWORD_PROTECTED", null).events())
assertThat(controller.list(FileRunEventStatus.DISMISSED, null, null).events())
.extracting(FileRunEventView::fileId)
.containsExactly("locked");
// Acknowledged is still open work, so it stays in the default queue.
// A dismissed row is decided, so the default queue holds only the other one.
assertThat(controller.list(null, null, null).events())
.extracting(FileRunEventView::fileId)
.containsExactly("open");
assertThat(controller.list(null, "INPUT_PASSWORD_PROTECTED", null).events())
.extracting(FileRunEventView::fileId)
.isEmpty();
assertThat(controller.list(null, "NO_SUCH_KIND", null).events()).isEmpty();
}
@@ -211,12 +239,31 @@ class FileRunEventControllerTest {
void appliesADeclaredActionAndReturnsTheUpdatedRow() {
FileRunEvent event = given(FailureKind.INPUT_PASSWORD_PROTECTED, TEAM, "f1");
FileRunEventView updated = controller.act(event.id(), "ACKNOWLEDGE", null);
FileRunEventView updated = controller.act(event.id(), "DISMISS", null);
assertThat(updated.status()).isEqualTo(FileRunEventStatus.ACKNOWLEDGED);
assertThat(updated.status()).isEqualTo(FileRunEventStatus.DISMISSED);
assertThat(updated.statusActor()).isEqualTo("reviewer@example.com");
}
@Test
void anActionTheClientRunsIsABadRequest() {
// Offered, and still not the server's to perform: the document is in the browser.
FileRunEvent event = given(FailureKind.UNKNOWN, TEAM, "f1");
assertThat(statusOf(() -> controller.act(event.id(), "VIEW_FILE", null)))
.isEqualTo(HttpStatus.BAD_REQUEST);
}
@Test
void anActionNoKindOffersAnyMoreIsABadRequest() {
// ACKNOWLEDGE is still in the vocabulary for the rows that carry it, and still not
// something any kind offers, so posting it is refused rather than applied.
FileRunEvent event = given(FailureKind.UNKNOWN, TEAM, "f1");
assertThat(statusOf(() -> controller.act(event.id(), "ACKNOWLEDGE", null)))
.isEqualTo(HttpStatus.BAD_REQUEST);
}
@Test
void acceptsAnAbsentBodyBecauseTheseActionsNeedNoInput() {
FileRunEvent event = given(FailureKind.UNKNOWN, TEAM, "f1");
@@ -238,7 +285,7 @@ class FileRunEventControllerTest {
// 404 rather than 403, so the response does not confirm the row exists.
FileRunEvent theirs = given(FailureKind.UNKNOWN, 99L, "f1");
assertThat(statusOf(() -> controller.act(theirs.id(), "ACKNOWLEDGE", null)))
assertThat(statusOf(() -> controller.act(theirs.id(), "DISMISS", null)))
.isEqualTo(HttpStatus.NOT_FOUND);
}
@@ -248,7 +295,7 @@ class FileRunEventControllerTest {
FileRunEvent event = given(FailureKind.INPUT_PASSWORD_PROTECTED, TEAM, "f1");
controller.act(event.id(), "DISMISS", null);
assertThat(statusOf(() -> controller.act(event.id(), "ACKNOWLEDGE", null)))
assertThat(statusOf(() -> controller.act(event.id(), "DISMISS", null)))
.isEqualTo(HttpStatus.CONFLICT);
}
}
@@ -276,7 +323,7 @@ class FileRunEventControllerTest {
assertThat(locked.actions())
.extracting(FailureKindView.ActionDeclaration::labelKey)
.contains("portal.failures.action.dismissSkipFile");
.contains("portal.failures.action.viewFile", "portal.failures.action.dismiss");
}
@Test
@@ -120,13 +120,20 @@ class FileRunEventHttpIntegrationTest {
// Epoch millis, not an ISO string: the client renders relative times from a number.
assertThat(row.get("lastSeenAt").isNumber()).isTrue();
// Resolved for this reader: a leader looking at a colleague's password failure is
// offered the run and a way to close the row, not a password they do not have.
JsonNode actions = row.get("actions");
assertThat(actions).hasSize(2);
assertThat(actions.get(0).get("id").asString()).isEqualTo("ACKNOWLEDGE");
assertThat(actions.get(0).get("id").asString()).isEqualTo("VIEW_IN_PROCESSOR");
assertThat(actions.get(0).get("labelKey").asString())
.isEqualTo("portal.failures.action.acknowledge");
.isEqualTo("portal.failures.action.viewInProcessor");
assertThat(actions.get(0).get("defaultLabel").asString())
.isEqualTo("View in processor");
assertThat(actions.get(0).get("execution").asString()).isEqualTo("CLIENT");
assertThat(actions.get(0).get("enabled").asBoolean()).isTrue();
assertThat(actions.get(0).get("disabledReasonKey").isNull()).isTrue();
assertThat(actions.get(1).get("id").asString()).isEqualTo("DISMISS");
assertThat(actions.get(1).get("execution").asString()).isEqualTo("SERVER");
}
@Test
@@ -154,16 +161,17 @@ class FileRunEventHttpIntegrationTest {
void coercesQueryParametersAndFiltersOnThem() throws Exception {
String locked = seed(FailureKind.INPUT_PASSWORD_PROTECTED, TEAM, "locked", "b");
seed(FailureKind.UNKNOWN, TEAM, "open", "a");
post("/api/v1/file-run-events/" + locked + "/actions/ACKNOWLEDGE", "{\"inputs\":{}}");
post("/api/v1/file-run-events/" + locked + "/actions/DISMISS", "{\"inputs\":{}}");
JsonNode acknowledged =
mapper.readTree(get("/api/v1/file-run-events?status=ACKNOWLEDGED").body())
JsonNode dismissed =
mapper.readTree(get("/api/v1/file-run-events?status=DISMISSED").body())
.get("events");
assertThat(acknowledged).hasSize(1);
assertThat(dismissed).hasSize(1);
JsonNode byKind =
mapper.readTree(
get("/api/v1/file-run-events?kindId=INPUT_PASSWORD_PROTECTED")
get("/api/v1/file-run-events?status=DISMISSED"
+ "&kindId=INPUT_PASSWORD_PROTECTED")
.body())
.get("events");
assertThat(byKind).hasSize(1);
@@ -269,25 +277,23 @@ class FileRunEventHttpIntegrationTest {
String id = seed(FailureKind.INPUT_PASSWORD_PROTECTED, TEAM, "f1", "boom");
HttpResponse<String> response =
post(
"/api/v1/file-run-events/" + id + "/actions/ACKNOWLEDGE",
"{\"inputs\":{}}");
post("/api/v1/file-run-events/" + id + "/actions/DISMISS", "{\"inputs\":{}}");
assertThat(response.statusCode()).isEqualTo(200);
JsonNode row = mapper.readTree(response.body());
assertThat(row.get("status").asString()).isEqualTo("ACKNOWLEDGED");
assertThat(row.get("status").asString()).isEqualTo("DISMISSED");
assertThat(row.get("statusActor").asString()).isEqualTo(ACTOR);
}
@Test
void acceptsAPopulatedInputsMap() throws Exception {
// Nothing consumes inputs yet, but the shape must bind so the first action that needs
// one (a password) does not discover a broken contract.
// No server action consumes inputs, but the shape must still bind rather than 400, so a
// client that posts an empty or stale map is not refused over its body.
String id = seed(FailureKind.INPUT_PASSWORD_PROTECTED, TEAM, "f1", "locked");
HttpResponse<String> response =
post(
"/api/v1/file-run-events/" + id + "/actions/ACKNOWLEDGE",
"/api/v1/file-run-events/" + id + "/actions/DISMISS",
"{\"inputs\":{\"password\":\"hunter2\"}}");
assertThat(response.statusCode()).isEqualTo(200);
@@ -315,15 +321,27 @@ class FileRunEventHttpIntegrationTest {
.isEqualTo(400);
}
@Test
void mapsAnActionTheClientRunsToBadRequest() throws Exception {
// Declared by the kind, refused here: over the wire, so a client that posts a retry
// gets a refusal rather than a 200 implying the server did something.
String id = seed(FailureKind.UNKNOWN, TEAM, "f1", "boom");
assertThat(
post(
"/api/v1/file-run-events/" + id + "/actions/VIEW_FILE",
"{\"inputs\":{}}")
.statusCode())
.isEqualTo(400);
}
@Test
void mapsAnotherTeamsRowToNotFound() throws Exception {
String id = seed(FailureKind.UNKNOWN, 999L, "theirs", "boom");
assertThat(
post(
"/api/v1/file-run-events/"
+ id
+ "/actions/ACKNOWLEDGE",
"/api/v1/file-run-events/" + id + "/actions/DISMISS",
"{\"inputs\":{}}")
.statusCode())
.isEqualTo(404);
@@ -336,9 +354,7 @@ class FileRunEventHttpIntegrationTest {
assertThat(
post(
"/api/v1/file-run-events/"
+ id
+ "/actions/ACKNOWLEDGE",
"/api/v1/file-run-events/" + id + "/actions/DISMISS",
"{\"inputs\":{}}")
.statusCode())
.isEqualTo(409);
@@ -1,6 +1,7 @@
package stirling.software.proprietary.failure;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatCode;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.Mockito.lenient;
import static org.mockito.Mockito.when;
@@ -60,14 +61,20 @@ class FileRunEventServiceTest {
}
private FileRunEvent given(FailureKind kind, Long teamId, String fileId) {
return givenHitBy("author@example.com", kind, teamId, fileId);
}
/** As {@link #given} but naming who the incident belongs to, which decides its ownership. */
private FileRunEvent givenHitBy(String actor, FailureKind kind, Long teamId, String fileId) {
return store.record(
new RecordFailure(
kind,
FailureOrigin.POLICY,
teamId,
"author@example.com",
actor,
"policy-1",
"run-1",
// Distinct per file, so a RUN-scoped kind does not fold two rows into one.
"run-" + fileId,
null,
fileId,
"detail"));
@@ -77,11 +84,28 @@ class FileRunEventServiceTest {
@DisplayName("acknowledge")
class Acknowledge {
/**
* No kind offers it, so it cannot be dispatched; exercised directly for rows that have it.
*/
private FileRunEvent acknowledge(FileRunEvent event, String actor) {
return new AcknowledgeAction(store).execute(event, Map.of(), actor);
}
@Test
void isNoLongerOfferedSoItCannotBeDispatched() {
FileRunEvent event = given(FailureKind.INPUT_PASSWORD_PROTECTED, TEAM, "f1");
assertThatThrownBy(() -> service.dispatch(event.id(), "ACKNOWLEDGE", Map.of()))
.isInstanceOf(FailureActionException.class)
.extracting(e -> ((FailureActionException) e).getReason())
.isEqualTo(FailureActionException.Reason.ACTION_NOT_DECLARED);
}
@Test
void movesANewEventToAcknowledgedAndStampsTheActor() {
FileRunEvent event = given(FailureKind.INPUT_PASSWORD_PROTECTED, TEAM, "f1");
FileRunEvent updated = service.dispatch(event.id(), "ACKNOWLEDGE", Map.of());
FileRunEvent updated = acknowledge(event, ACTOR);
assertThat(updated.status()).isEqualTo(FileRunEventStatus.ACKNOWLEDGED);
assertThat(updated.statusActor()).isEqualTo(ACTOR);
@@ -91,16 +115,24 @@ class FileRunEventServiceTest {
@Test
void isANoOpWhenAlreadyAcknowledgedSoOwnershipIsNotStolen() {
FileRunEvent event = given(FailureKind.INPUT_PASSWORD_PROTECTED, TEAM, "f1");
FileRunEvent first = service.dispatch(event.id(), "ACKNOWLEDGE", Map.of());
Instant originalAt = first.statusAt();
Instant originalAt = acknowledge(event, ACTOR).statusAt();
when(userService.getCurrentUsername()).thenReturn("someone-else@example.com");
FileRunEvent second = service.dispatch(event.id(), "ACKNOWLEDGE", Map.of());
FileRunEvent second = acknowledge(event, "someone-else@example.com");
assertThat(second.status()).isEqualTo(FileRunEventStatus.ACKNOWLEDGED);
assertThat(second.statusActor()).isEqualTo(ACTOR);
assertThat(second.statusAt()).isEqualTo(originalAt);
}
@Test
void anAlreadyAcknowledgedRowStaysReadableAndClosable() {
FileRunEvent event = given(FailureKind.INPUT_PASSWORD_PROTECTED, TEAM, "f1");
acknowledge(event, ACTOR);
assertThat(service.list(FileRunEventStatus.ACKNOWLEDGED, null, 10)).hasSize(1);
assertThat(service.dispatch(event.id(), "DISMISS", Map.of()).status())
.isEqualTo(FileRunEventStatus.DISMISSED);
}
}
@Nested
@@ -118,7 +150,7 @@ class FileRunEventServiceTest {
@Test
void closesAnAcknowledgedEvent() {
FileRunEvent event = given(FailureKind.INPUT_PASSWORD_PROTECTED, TEAM, "f1");
service.dispatch(event.id(), "ACKNOWLEDGE", Map.of());
new AcknowledgeAction(store).execute(event, Map.of(), ACTOR);
assertThat(service.dispatch(event.id(), "DISMISS", Map.of()).status())
.isEqualTo(FileRunEventStatus.DISMISSED);
@@ -180,7 +212,7 @@ class FileRunEventServiceTest {
void anotherTeamsEventIsNotFound() {
FileRunEvent theirs = given(FailureKind.UNKNOWN, 99L, "f1");
assertThatThrownBy(() -> service.dispatch(theirs.id(), "ACKNOWLEDGE", Map.of()))
assertThatThrownBy(() -> service.dispatch(theirs.id(), "DISMISS", Map.of()))
.isInstanceOf(FailureActionException.class)
.extracting(e -> ((FailureActionException) e).getReason())
.isEqualTo(FailureActionException.Reason.EVENT_NOT_FOUND);
@@ -188,12 +220,44 @@ class FileRunEventServiceTest {
@Test
void anUnknownEventIdIsNotFound() {
assertThatThrownBy(() -> service.dispatch("nope", "ACKNOWLEDGE", Map.of()))
assertThatThrownBy(() -> service.dispatch("nope", "DISMISS", Map.of()))
.isInstanceOf(FailureActionException.class)
.extracting(e -> ((FailureActionException) e).getReason())
.isEqualTo(FailureActionException.Reason.EVENT_NOT_FOUND);
}
@Test
void anActionTheClientRunsIsRefusedRatherThanPretendedTo() {
// Answering 200 would tell the client something happened when nothing did.
FileRunEvent event = given(FailureKind.UNKNOWN, TEAM, "f1");
assertThatThrownBy(() -> service.dispatch(event.id(), "VIEW_FILE", Map.of()))
.isInstanceOf(FailureActionException.class)
.extracting(e -> ((FailureActionException) e).getReason())
.isEqualTo(FailureActionException.Reason.ACTION_NOT_DISPATCHABLE);
assertThat(store.find(event.id(), TEAM).orElseThrow().status())
.isEqualTo(FileRunEventStatus.NEW);
}
@Test
void everyClientActionIsRefusedWhicheverKindDeclaresIt() {
// Over the whole vocabulary, so a client action added later cannot arrive dispatchable.
for (FailureKind kind : FailureKind.values()) {
FileRunEvent event = given(kind, TEAM, "f-" + kind.getId());
for (FailureActionId action : kind.getActions()) {
if (action.runsOnServer()) {
continue;
}
assertThatThrownBy(() -> service.dispatch(event.id(), action.name(), Map.of()))
.as("%s offers %s", kind.getId(), action)
.isInstanceOf(FailureActionException.class)
.extracting(e -> ((FailureActionException) e).getReason())
.isEqualTo(FailureActionException.Reason.ACTION_NOT_DISPATCHABLE);
}
}
}
@Test
void anUnknownActionIdIsRejected() {
FileRunEvent event = given(FailureKind.UNKNOWN, TEAM, "f1");
@@ -231,11 +295,6 @@ class FileRunEventServiceTest {
FileRunEvent event = given(FailureKind.INPUT_PASSWORD_PROTECTED, TEAM, "f1");
service.dispatch(event.id(), "DISMISS", Map.of());
assertThatThrownBy(() -> service.dispatch(event.id(), "ACKNOWLEDGE", Map.of()))
.isInstanceOf(FailureActionException.class)
.extracting(e -> ((FailureActionException) e).getReason())
.isEqualTo(FailureActionException.Reason.ALREADY_CLOSED);
assertThatThrownBy(() -> service.dispatch(event.id(), "DISMISS", Map.of()))
.isInstanceOf(FailureActionException.class)
.extracting(e -> ((FailureActionException) e).getReason())
@@ -244,18 +303,184 @@ class FileRunEventServiceTest {
}
@Nested
@DisplayName("available actions are resolved per row")
class Availability {
@DisplayName("ownership is derived against whoever is reading")
class OwnershipDerivation {
@Test
void openRowOffersEveryDeclaredActionEnabled() {
FileRunEvent event = given(FailureKind.INPUT_PASSWORD_PROTECTED, TEAM, "f1");
void theCallersOwnFailureIsMine() {
FileRunEvent mine = givenHitBy(ACTOR, FailureKind.UNKNOWN, TEAM, "f1");
List<FileRunEventService.AvailableAction> actions = service.availableActions(event);
assertThat(service.ownershipOf(mine)).isEqualTo(Ownership.MINE);
}
assertThat(actions).hasSize(2);
assertThat(actions).allMatch(FileRunEventService.AvailableAction::enabled);
assertThat(actions).allMatch(action -> action.disabledReasonKey() == null);
@Test
void aColleaguesIsTheirs() {
FileRunEvent theirs =
givenHitBy("colleague@example.com", FailureKind.UNKNOWN, TEAM, "f1");
assertThat(service.ownershipOf(theirs)).isEqualTo(Ownership.THEIRS);
}
@Test
void anUnattendedRunsIsNobodys() {
// A trigger-fired run has no user to name, so there is nobody to hand the fix to.
FileRunEvent unattended = givenHitBy(null, FailureKind.UNKNOWN, TEAM, "f1");
assertThat(service.ownershipOf(unattended)).isEqualTo(Ownership.UNOWNED);
}
@Test
void theSameRowIsMineToOnePersonAndTheirsToAnother() {
// Why it is derived: a stored answer would be wrong for everyone but one person.
FileRunEvent event = givenHitBy(ACTOR, FailureKind.UNKNOWN, TEAM, "f1");
assertThat(service.ownershipOf(event)).isEqualTo(Ownership.MINE);
when(userService.getCurrentUsername()).thenReturn("colleague@example.com");
assertThat(service.ownershipOf(event)).isEqualTo(Ownership.THEIRS);
}
}
@Nested
@DisplayName("available actions are resolved per row and per reader")
class Availability {
private List<FailureActionId> offeredFor(FileRunEvent event) {
return service.availableActions(event).stream()
.map(FileRunEventService.AvailableAction::id)
.toList();
}
@Test
void theOwnerIsOfferedTheirDocumentAndNotTheReviewersView() {
// The document is theirs to open; the processor view is for whoever reviews the team.
when(authority.canEditPolicies()).thenReturn(false);
FileRunEvent mine = givenHitBy(ACTOR, FailureKind.INPUT_PASSWORD_PROTECTED, TEAM, "f1");
assertThat(offeredFor(mine))
.containsExactly(FailureActionId.VIEW_FILE, FailureActionId.DISMISS);
assertThat(service.availableActions(mine))
.allMatch(FileRunEventService.AvailableAction::enabled);
}
@Test
void aReviewerReadingAColleaguesIsNotOfferedTheDocumentTheyDoNotHave() {
// Dropped, not disabled: greyed out would read as their permission problem.
FileRunEvent theirs =
givenHitBy(
"colleague@example.com",
FailureKind.INPUT_PASSWORD_PROTECTED,
TEAM,
"f1");
assertThat(offeredFor(theirs))
.containsExactly(FailureActionId.VIEW_IN_PROCESSOR, FailureActionId.DISMISS);
}
@Test
void aReviewerInheritsTheOwnerActionsOnAnUnattendedRow() {
// Nobody owns it, so without the inheritance the row could only ever be dismissed.
FileRunEvent unattended =
givenHitBy(null, FailureKind.INPUT_PASSWORD_PROTECTED, TEAM, "f1");
assertThat(offeredFor(unattended))
.containsExactly(
FailureActionId.VIEW_FILE,
FailureActionId.VIEW_IN_PROCESSOR,
FailureActionId.DISMISS);
}
@Test
void inheritedOwnerActionsComeBackDisabledWithTheReasonWhy() {
// No browser holds a source-fed file, so it is stated rather than offered as a dead
// button.
FileRunEvent unattended =
givenHitBy(null, FailureKind.INPUT_PASSWORD_PROTECTED, TEAM, "f1");
assertThat(service.availableActions(unattended))
.filteredOn(action -> action.id() != FailureActionId.DISMISS)
.filteredOn(action -> action.id() != FailureActionId.VIEW_IN_PROCESSOR)
.isNotEmpty()
.allSatisfy(
action -> {
assertThat(action.enabled()).isFalse();
assertThat(action.disabledReasonKey())
.isEqualTo("portal.failures.disabled.unattended");
});
}
@Test
void theReviewersOwnActionsStayUsableOnAnUnattendedRow() {
FileRunEvent unattended =
givenHitBy(null, FailureKind.INPUT_PASSWORD_PROTECTED, TEAM, "f1");
assertThat(service.availableActions(unattended))
.filteredOn(
action ->
action.id() == FailureActionId.DISMISS
|| action.id() == FailureActionId.VIEW_IN_PROCESSOR)
.hasSize(2)
.allMatch(FileRunEventService.AvailableAction::enabled);
}
@Test
void theOwnersActionsAreDisabledWhenTheRowNamesNoDocument() {
// Answered here, or the client calls it "not on this device" while it sits in their
// own workbench.
FileRunEvent documentless =
givenHitBy(ACTOR, FailureKind.INPUT_PASSWORD_PROTECTED, TEAM, null);
assertThat(service.ownershipOf(documentless)).isEqualTo(Ownership.MINE);
assertThat(service.availableActions(documentless))
.filteredOn(action -> action.id() != FailureActionId.DISMISS)
.filteredOn(action -> action.id() != FailureActionId.VIEW_IN_PROCESSOR)
.isNotEmpty()
.allSatisfy(
action -> {
assertThat(action.enabled()).isFalse();
assertThat(action.disabledReasonKey())
.isEqualTo("portal.failures.disabled.noDocument");
});
}
@Test
void aRowThatNamesADocumentKeepsItsOwnerActionsUsable() {
FileRunEvent withDocument =
givenHitBy(ACTOR, FailureKind.INPUT_PASSWORD_PROTECTED, TEAM, "f1");
assertThat(service.availableActions(withDocument))
.isNotEmpty()
.allMatch(FileRunEventService.AvailableAction::enabled);
}
@Test
void aMemberIsNotOfferedTheOwnerActionsOnAnUnattendedRow() {
// The inheritance is the reviewer's: a member has no claim on a run nobody attended.
when(authority.canEditPolicies()).thenReturn(false);
FileRunEvent unattended =
givenHitBy(null, FailureKind.INPUT_PASSWORD_PROTECTED, TEAM, "f1");
assertThat(offeredFor(unattended)).containsExactly(FailureActionId.DISMISS);
}
@Test
void aLoginDisabledOperatorKeepsTheirOwnActions() {
// Unowned for want of users, not because nothing attended: the one operator holds the
// file.
ApplicationProperties props = new ApplicationProperties();
props.getSecurity().setEnableLogin(false);
FileRunEventService unsecured =
new FileRunEventService(
store,
new FailureActionRegistry(List.of(new DismissAction(store))),
authority,
userService,
props);
FileRunEvent event = givenHitBy(null, FailureKind.INPUT_PASSWORD_PROTECTED, null, "f1");
assertThat(unsecured.availableActions(event))
.extracting(FileRunEventService.AvailableAction::enabled)
.containsOnly(true);
}
@Test
@@ -275,21 +500,14 @@ class FileRunEventServiceTest {
}
@Test
void carriesTheKindsOverriddenLabelWhereItHasOne() {
FileRunEvent event = given(FailureKind.INPUT_PASSWORD_PROTECTED, TEAM, "f1");
assertThat(service.availableActions(event))
.extracting(FileRunEventService.AvailableAction::labelKey)
.contains("portal.failures.action.dismissSkipFile");
}
@Test
void fallsBackToTheGenericLabelWhereTheKindDeclaresNoOverride() {
void carriesTheLabelKeyForEachOffer() {
FileRunEvent event = given(FailureKind.UNKNOWN, TEAM, "f1");
assertThat(service.availableActions(event))
.extracting(FileRunEventService.AvailableAction::labelKey)
.containsExactly("portal.failures.action.dismiss");
.containsExactly(
"portal.failures.action.viewInProcessor",
"portal.failures.action.dismiss");
}
}
@@ -450,7 +668,43 @@ class FileRunEventServiceTest {
complete.verifyEveryDeclaredActionHasAHandler();
for (FailureActionId id : FailureActionId.values()) {
assertThat(complete.find(id)).isPresent();
// Only server actions need a handler, which is why the boot check ignores the rest.
assertThat(complete.find(id).isPresent()).isEqualTo(id.runsOnServer());
}
}
@Test
void doesNotAskForAHandlerForAnActionTheClientRuns() {
// Otherwise every client action would need an empty handler beside it.
FailureActionRegistry serverOnly =
new FailureActionRegistry(
List.of(new AcknowledgeAction(store), new DismissAction(store)));
assertThatCode(serverOnly::verifyEveryDeclaredActionHasAHandler)
.doesNotThrowAnyException();
}
@Test
void refusesAHandlerForAnActionTheClientRuns() {
// Dispatch refuses the id before resolving a handler, so the bean reads as live and is
// not.
assertThatThrownBy(() -> new FailureActionRegistry(List.of(new ClientSideAction())))
.isInstanceOf(IllegalStateException.class)
.hasMessageContaining("VIEW_FILE");
}
/** A handler for a client action, which is exactly what must not be registered. */
private static final class ClientSideAction implements FailureAction {
@Override
public FailureActionId id() {
return FailureActionId.VIEW_FILE;
}
@Override
public FileRunEvent execute(
FileRunEvent event, Map<String, String> inputs, String actor) {
throw new UnsupportedOperationException();
}
}
}
@@ -233,7 +233,7 @@ class FileRunEventStoreDbTest {
}
@Test
@DisplayName("closing deleted files touches only that owner's own open editor rows")
@DisplayName("deleting a document closes every incident about it that the deleter caused")
void markFilesRemovedIsScopedBySqlNotByTheCaller() {
// The scoping is entirely in the JPQL, so the in-memory fake proves nothing about it:
// it implements the same rules by hand and would agree with a wrong query.
@@ -241,6 +241,10 @@ class FileRunEventStoreDbTest {
store.record(
RecordFailure.forEditor(
FailureKind.UNKNOWN, TEAM, "owner@example.com", "f-1", "boom"));
// Recorded by the processor, about the document they just deleted. Keying on origin left
// these in the queue.
FileRunEvent myPolicyRun =
store.record(failure(FailureKind.UNKNOWN, TEAM, "owner@example.com", "f-1"));
FileRunEvent theirs =
store.record(
RecordFailure.forEditor(
@@ -253,21 +257,45 @@ class FileRunEventStoreDbTest {
"owner@example.com",
"f-1",
"boom"));
FileRunEvent fromProcessor = store.record(failure(FailureKind.UNKNOWN, TEAM, "f-1"));
int closed = store.markFilesRemoved(TEAM, "owner@example.com", List.of("f-1"));
assertThat(closed).isEqualTo(1);
assertThat(closed).isEqualTo(2);
assertThat(store.find(mine.id(), TEAM).orElseThrow().status())
.isEqualTo(FileRunEventStatus.FILE_REMOVED);
assertThat(store.find(myPolicyRun.id(), TEAM).orElseThrow().status())
.as("their upload, their document, now deleted")
.isEqualTo(FileRunEventStatus.FILE_REMOVED);
assertThat(store.find(theirs.id(), TEAM).orElseThrow().status())
.as("another person's incident about their own file")
.isEqualTo(FileRunEventStatus.NEW);
assertThat(store.find(otherTeam.id(), OTHER_TEAM).orElseThrow().status())
.as("another team entirely")
.isEqualTo(FileRunEventStatus.NEW);
assertThat(store.find(fromProcessor.id(), TEAM).orElseThrow().status())
.as("nothing was deleted from an editor here")
}
@Test
@DisplayName("a source-fed incident survives a client naming its file id")
void markFilesRemovedLeavesSourceFedRowsAlone() {
// With login disabled the actor is null on both sides, so the absence of a source is all
// that stands between a local delete and a sweep's incidents.
FileRunEvent sweep =
store.record(
new RecordFailure(
FailureKind.UNKNOWN,
FailureOrigin.POLICY,
null,
null,
"policy-1",
"run-1",
"src-watched-folder",
"collides-with-a-client-id",
"detail"));
int closed = store.markFilesRemoved(null, null, List.of("collides-with-a-client-id"));
assertThat(closed).isZero();
assertThat(store.find(sweep.id(), null).orElseThrow().status())
.isEqualTo(FileRunEventStatus.NEW);
}
@@ -134,7 +134,8 @@ class InMemoryFileRunEventRepository implements FileRunEventRepository {
Collection<FileRunEventStatus> allowedFrom) {
int closed = 0;
for (FileRunEventEntity entity : rows.values()) {
if (entity.getOrigin() != FailureOrigin.TOOL
// Mirrors the real query: scoped by the absence of a source, not by origin.
if (entity.getSourceId() != null
|| !sameTeam(entity, teamId)
|| !Objects.equals(entity.getActor(), actor)
|| entity.getFileId() == null
@@ -0,0 +1,168 @@
package stirling.software.proprietary.failure;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.lenient;
import java.util.List;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Nested;
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.ApplicationProperties;
import stirling.software.common.service.UserServiceInterface;
import stirling.software.proprietary.notification.NotificationController;
import stirling.software.proprietary.notification.NotificationService;
import stirling.software.proprietary.notification.NotificationSource;
import stirling.software.proprietary.notification.NotificationView;
import stirling.software.proprietary.policy.config.PolicyManagementAuthority;
/**
* What the bell is given to render: never a raw event id, and only the actions the client itself
* runs, resolved for this reader by the same service that scopes the queue.
*/
@ExtendWith(MockitoExtension.class)
class NotificationProjectionTest {
private static final Long TEAM = 7L;
private static final String ACTOR = "reviewer@example.com";
@Mock private PolicyManagementAuthority authority;
@Mock private UserServiceInterface userService;
private FileRunEventStore store;
private FileRunEventService failures;
private NotificationController controller;
@BeforeEach
void setUp() {
ApplicationProperties props = new ApplicationProperties();
props.getSecurity().setEnableLogin(true);
store = new FileRunEventStore(new InMemoryFileRunEventRepository());
failures =
new FileRunEventService(
store,
new FailureActionRegistry(
List.of(new AcknowledgeAction(store), new DismissAction(store))),
authority,
userService,
props);
controller = new NotificationController(new NotificationService(failures));
lenient().when(authority.currentUserTeamId()).thenReturn(TEAM);
lenient().when(authority.canEditPolicies()).thenReturn(true);
lenient().when(userService.getCurrentUsername()).thenReturn(ACTOR);
}
private FileRunEvent given(FailureKind kind, String actor, String fileId) {
return store.record(RecordFailure.forEditor(kind, TEAM, actor, fileId, "boom"));
}
@Nested
@DisplayName("the bell holds a prefixed id and nothing else")
class Ids {
@Test
void everyNotificationIsKeyedByItsSourceAndRowId() {
FileRunEvent event = given(FailureKind.UNKNOWN, ACTOR, "f-1");
NotificationView notification = controller.list(null).notifications().getFirst();
assertThat(notification.id()).isEqualTo("failure:" + event.id());
assertThat(notification.source()).isEqualTo(NotificationSource.FAILURE);
}
}
@Nested
@DisplayName("what the bell is given to render")
class Projection {
@Test
void carriesTheKindOriginOwnershipAndTheQueuesClientActions() {
FileRunEvent mine = given(FailureKind.INPUT_PASSWORD_PROTECTED, ACTOR, "f-1");
NotificationView notification = controller.list(null).notifications().getFirst();
assertThat(notification.kindId()).isEqualTo("INPUT_PASSWORD_PROTECTED");
assertThat(notification.origin()).isEqualTo(FailureOrigin.TOOL);
assertThat(notification.ownership()).isEqualTo(Ownership.MINE);
assertThat(notification.severity()).isEqualTo(FailureSeverity.ERROR);
assertThat(notification.status()).isEqualTo(FileRunEventStatus.NEW);
assertThat(notification.fileId()).isEqualTo("f-1");
assertThat(notification.policyId()).isNull();
// How the client knows the fileId above is one of its own and worth looking up.
assertThat(notification.sourceId()).isNull();
assertThat(notification.defaultTitle()).isNotBlank();
// The queue's own offers minus the server's: a bell offering different ones would lie.
assertThat(notification.actions())
.containsExactlyElementsOf(
FileRunEventView.of(mine, failures.availableActions(mine))
.actions()
.stream()
.filter(
action ->
action.execution()
== FailureActionId.Execution.CLIENT)
.toList());
}
@Test
void offersNoActionTheServerRunsBecauseDispositionsBelongToTheQueue() {
// Deciding a failure's fate belongs to the review surface, not the panel.
given(FailureKind.INPUT_PASSWORD_PROTECTED, ACTOR, "f-1");
assertThat(controller.list(null).notifications().getFirst().actions())
.isNotEmpty()
.allMatch(action -> action.execution() == FailureActionId.Execution.CLIENT);
}
@Test
void namesTheSourceThatFedAnUnattendedRunSoItsFileIdIsNotMistakenForAClientsOwn() {
// Without the source a client looks up a hash it can never resolve and calls it
// missing.
store.record(
RecordFailure.forRun(
FailureKind.INPUT_PASSWORD_PROTECTED,
TEAM,
null,
"policy-1",
"run-1",
"source-7",
"hashed-identity",
"boom"));
NotificationView notification = controller.list(null).notifications().getFirst();
assertThat(notification.sourceId()).isEqualTo("source-7");
assertThat(notification.fileId()).isEqualTo("hashed-identity");
}
@Test
void aColleaguesNotificationOffersTheReviewersActionsOnly() {
// A leader sees the team's failures, so audience filtering has to reach the bell too.
given(FailureKind.INPUT_PASSWORD_PROTECTED, "colleague@example.com", "f-1");
assertThat(controller.list(null).notifications().getFirst().actions())
.extracting(FileRunEventView.ActionView::id)
.containsExactly("VIEW_IN_PROCESSOR");
}
@Test
void carriesWhatAClientNeedsToRenderAnActionItDoesNotKnow() {
given(FailureKind.INPUT_PASSWORD_PROTECTED, ACTOR, "f-1");
assertThat(controller.list(null).notifications().getFirst().actions())
.isNotEmpty()
.allSatisfy(
action -> {
assertThat(action.labelKey()).startsWith("portal.failures.action.");
assertThat(action.defaultLabel()).isNotBlank();
assertThat(action.execution()).isNotNull();
});
}
}
}
@@ -0,0 +1,272 @@
package stirling.software.proprietary.failure;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyInt;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.lenient;
import static org.mockito.Mockito.when;
import java.nio.file.Path;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.concurrent.TimeUnit;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Nested;
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.slf4j.MDC;
import org.springframework.core.io.ByteArrayResource;
import stirling.software.common.model.ApplicationProperties;
import stirling.software.common.service.FileStorage;
import stirling.software.common.service.InternalApiClient;
import stirling.software.common.service.JobOwnershipService;
import stirling.software.common.service.JobQueue;
import stirling.software.common.service.ResourceMonitor;
import stirling.software.common.service.TaskManager;
import stirling.software.common.service.ToolMetadataService;
import stirling.software.common.service.UserServiceInterface;
import stirling.software.common.util.TempFileManager;
import stirling.software.common.util.TempFileRegistry;
import stirling.software.proprietary.policy.asset.InProcessPolicyAssetStore;
import stirling.software.proprietary.policy.asset.PolicyAssetResolver;
import stirling.software.proprietary.policy.config.PolicyManagementAuthority;
import stirling.software.proprietary.policy.engine.PolicyEngine;
import stirling.software.proprietary.policy.engine.PolicyExecutor;
import stirling.software.proprietary.policy.engine.PolicyRunRegistry;
import stirling.software.proprietary.policy.model.OutputSpec;
import stirling.software.proprietary.policy.model.PipelineStep;
import stirling.software.proprietary.policy.model.Policy;
import stirling.software.proprietary.policy.model.PolicyInputs;
import stirling.software.proprietary.policy.output.InlineOutputSink;
import stirling.software.proprietary.policy.output.PolicyOutputResolver;
import stirling.software.proprietary.policy.progress.PolicyProgressListener;
import stirling.software.proprietary.policy.source.InProcessSourceStore;
import stirling.software.proprietary.policy.store.PolicyStore;
import tools.jackson.databind.json.JsonMapper;
/**
* What a reader is offered on a real recorded row, every collaborator being the real one. Both
* directions are asserted: offered to the wrong reader is either a dead button or a leaked
* document.
*/
@ExtendWith(MockitoExtension.class)
class PolicyFailureOwnershipTest {
private static final String ROTATE = "/api/v1/general/rotate-pdf";
private static final Long TEAM = 3L;
@Mock private InternalApiClient internalApiClient;
@Mock private ToolMetadataService toolMetadataService;
@Mock private TaskManager taskManager;
@Mock private FileStorage fileStorage;
@Mock private JobOwnershipService jobOwnershipService;
@Mock private ResourceMonitor resourceMonitor;
@Mock private JobQueue jobQueue;
@Mock private PolicyStore policyStore;
@Mock private PolicyManagementAuthority authority;
@Mock private UserServiceInterface userService;
@TempDir Path tempDir;
private PolicyEngine engine;
private FileRunEventService service;
@BeforeEach
void setUp() {
ApplicationProperties props = new ApplicationProperties();
props.getSecurity().setEnableLogin(true);
props.getSystem().getTempFileManagement().setBaseTmpDir(tempDir.toString());
props.getSystem().getTempFileManagement().setPrefix("failure-ownership-test-");
FileRunEventStore store = new FileRunEventStore(new InMemoryFileRunEventRepository());
service =
new FileRunEventService(
store,
new FailureActionRegistry(
List.of(new AcknowledgeAction(store), new DismissAction(store))),
authority,
userService,
props);
PolicyFailureRecorder recorder =
new PolicyFailureRecorder(
new FailureClassifier(JsonMapper.builder().build()), store, policyStore);
PolicyExecutor executor =
new PolicyExecutor(
internalApiClient,
toolMetadataService,
new TempFileManager(new TempFileRegistry(), props),
JsonMapper.builder().build());
engine =
new PolicyEngine(
executor,
taskManager,
new PolicyRunRegistry(new ApplicationProperties()),
recorder,
fileStorage,
jobOwnershipService,
List.of(new InlineOutputSink(fileStorage)),
new PolicyOutputResolver(new InProcessSourceStore()),
resourceMonitor,
jobQueue,
new PolicyAssetResolver(new InProcessPolicyAssetStore()));
lenient()
.when(jobOwnershipService.createScopedJobKey(anyString()))
.thenAnswer(invocation -> invocation.getArgument(0));
lenient().when(resourceMonitor.shouldQueueJob(anyInt())).thenReturn(false);
lenient().when(toolMetadataService.isMultiInput(anyString())).thenReturn(false);
// The team is resolved from the policy, so the recorded row lands in the reader's team.
lenient().when(policyStore.get(anyString())).thenReturn(Optional.of(sharedPolicy()));
lenient().when(authority.currentUserTeamId()).thenReturn(TEAM);
}
/** Alice's policy, shared with her team. Bob is a member of it and does not own it. */
private static Policy sharedPolicy() {
return new Policy(
"p1",
"rotate",
"alice",
true,
List.of(),
List.of(new PipelineStep(ROTATE, Map.of())),
OutputSpec.inline(),
TEAM);
}
/** Fails the policy's single tool step as {@code triggeredBy} (null = sweep). */
private void runAndFail(String triggeredBy, String sourceId, String fileIdentity)
throws Exception {
when(internalApiClient.post(eq(ROTATE), any())).thenThrow(new RuntimeException("boom"));
if (triggeredBy != null) {
MDC.put("auditPrincipal", triggeredBy);
}
try {
engine.runPolicy(
sharedPolicy(),
PolicyInputs.of(List.of(pdf())),
PolicyProgressListener.NOOP,
sourceId,
fileIdentity)
.completion()
.get(10, TimeUnit.SECONDS);
} finally {
MDC.remove("auditPrincipal");
}
}
private static ByteArrayResource pdf() {
return new ByteArrayResource("input".getBytes()) {
@Override
public String getFilename() {
return "input.pdf";
}
};
}
/**
* Lenient because a leader's scope and an UNOWNED check both answer without asking who reads,
* so whether the name is consulted is the behaviour under test.
*/
private FileRunEvent asMember(String reader) {
lenient().when(userService.getCurrentUsername()).thenReturn(reader);
lenient().when(authority.canEditPolicies()).thenReturn(false);
List<FileRunEvent> visible = service.list(null, null, 10);
return visible.isEmpty() ? null : visible.getFirst();
}
/** Read as a team leader, who reviews the whole team's incidents. See {@link #asMember}. */
private FileRunEvent asReviewer(String reader) {
lenient().when(userService.getCurrentUsername()).thenReturn(reader);
lenient().when(authority.canEditPolicies()).thenReturn(true);
return service.list(null, null, 10).getFirst();
}
private List<FailureActionId> offeredTo(FileRunEvent event) {
return service.availableActions(event).stream()
.map(FileRunEventService.AvailableAction::id)
.toList();
}
@Nested
@DisplayName("a non-owner runs a shared policy on their own upload")
class AttendedByANonOwner {
@Test
void theTriggeringUserHoldsItAndIsOfferedTheDocument() throws Exception {
runAndFail("bob", null, "bob-doc-1");
FileRunEvent mine = asMember("bob");
assertThat(service.ownershipOf(mine)).isEqualTo(Ownership.MINE);
assertThat(offeredTo(mine))
.as("he is holding the document, so opening it is his to do")
.contains(FailureActionId.VIEW_FILE);
assertThat(service.availableActions(mine))
.filteredOn(action -> action.id() == FailureActionId.VIEW_FILE)
.singleElement()
.satisfies(action -> assertThat(action.enabled()).isTrue());
}
@Test
void thePolicyOwnerIsNotHandedADocumentSheNeverTouched() throws Exception {
runAndFail("bob", null, "bob-doc-1");
// She owns the policy and pays for the run, and still has no copy of Bob's file.
FileRunEvent theirs = asReviewer("alice");
assertThat(service.ownershipOf(theirs)).isEqualTo(Ownership.THEIRS);
assertThat(offeredTo(theirs)).doesNotContain(FailureActionId.VIEW_FILE);
}
@Test
void theReviewerIsStillOfferedWhatReviewingNeeds() throws Exception {
runAndFail("bob", null, "bob-doc-1");
// Not her document, still her team's incident.
assertThat(offeredTo(asReviewer("alice")))
.contains(FailureActionId.VIEW_IN_PROCESSOR, FailureActionId.DISMISS);
}
}
@Nested
@DisplayName("an unattended sweep pulls a file from a source")
class UnattendedSweep {
@Test
void theRowIsOwnedByNobodySoTheReviewerInheritsTheOwnerActions() throws Exception {
runAndFail(null, "src-watched-folder", "file-hash-1");
FileRunEvent unattended = asReviewer("alice");
assertThat(service.ownershipOf(unattended)).isEqualTo(Ownership.UNOWNED);
// No browser holds this document, so the offer is stated and disabled, not dropped.
assertThat(offeredTo(unattended)).contains(FailureActionId.VIEW_FILE);
assertThat(service.availableActions(unattended))
.filteredOn(action -> action.id() == FailureActionId.VIEW_FILE)
.singleElement()
.satisfies(
action -> {
assertThat(action.enabled()).isFalse();
assertThat(action.disabledReasonKey())
.isEqualTo("portal.failures.disabled.unattended");
});
}
@Test
void thePolicyOwnerDoesNotInheritItAsHerOwn() throws Exception {
// Being billed for the sweep must not become ownership: she gets these as reviewer
// only.
runAndFail(null, "src-watched-folder", "file-hash-1");
assertThat(service.ownershipOf(asReviewer("alice"))).isNotEqualTo(Ownership.MINE);
}
}
}
@@ -39,6 +39,7 @@ import tools.jackson.databind.json.JsonMapper;
class PolicyFailureRecorderTest {
private static final Long TEAM = 11L;
private static final String ACTOR = "dana@example.com";
@Mock private PolicyStore policyStore;
@@ -379,5 +380,36 @@ class PolicyFailureRecorderTest {
assertThat(store.list(TEAM, null, null, null, 10)).hasSize(2);
}
@Test
void theSameDocumentFailingInTwoAttendedRunsIsOneIncident() {
// Every upload is a new run, so with no reference the run id stands in for the document
// and the same broken file reads as a second incident rather than a second occurrence.
when(policyStore.get("policy-1")).thenReturn(Optional.of(policy("policy-1", TEAM)));
recorder.recordRunFailure(
"run-1", "policy-1", null, "editor-file-1", ACTOR, "locked", passwordFailure());
recorder.recordRunFailure(
"run-2", "policy-1", null, "editor-file-1", ACTOR, "locked", passwordFailure());
List<FileRunEvent> events = store.list(TEAM, null, null, null, 10);
assertThat(events).hasSize(1);
assertThat(events.getFirst().occurrences()).isEqualTo(2);
}
@Test
void twoDocumentsFailingTheSameWayStaySeparateIncidents() {
// Folding is per document, so neither row is credited with the other's occurrence.
when(policyStore.get("policy-1")).thenReturn(Optional.of(policy("policy-1", TEAM)));
recorder.recordRunFailure(
"run-1", "policy-1", null, "editor-file-1", ACTOR, "locked", passwordFailure());
recorder.recordRunFailure(
"run-2", "policy-1", null, "editor-file-2", ACTOR, "locked", passwordFailure());
assertThat(store.list(TEAM, null, null, null, 10))
.hasSize(2)
.allMatch(event -> event.occurrences() == 1);
}
}
}
@@ -11,6 +11,8 @@ import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import stirling.software.proprietary.policy.controller.PolicyRunFiles;
/**
* The privacy contract: a recorded failure carries no document identity of its own. There is no
* name column and the dedup key is built only from opaque ids, so nothing here derives from what a
@@ -53,6 +55,16 @@ class RecordFailurePrivacyTest {
.doesNotContain("fileName");
}
@Test
void theRunRequestThatSuppliesADocumentReferenceCarriesNoNameEither() {
// The same discipline at the door as in the row: an id and nothing else, or a document name
// reaches a table that deliberately has nowhere to put it.
assertThat(List.of(PolicyRunFiles.class.getDeclaredFields()))
.extracting(Field::getName)
.contains("fileId")
.doesNotContain("fileName", "documentName", "name");
}
@Test
void dedupKeyIsBuiltOnlyFromOpaqueIdentifiers() {
// Two files under the same policy hash differently (so they stay separate incidents), but
@@ -29,6 +29,7 @@ import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.mock.web.MockMultipartFile;
import org.springframework.web.server.ResponseStatusException;
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
@@ -39,6 +40,7 @@ import stirling.software.common.model.job.JobResponse;
import stirling.software.common.model.tool.ToolDiagnostic;
import stirling.software.common.service.JobOwnershipService;
import stirling.software.common.util.TempFileManager;
import stirling.software.common.util.TempFileRegistry;
import stirling.software.proprietary.policy.config.PolicyAccessGuard;
import stirling.software.proprietary.policy.config.PolicyManagementAuthority;
import stirling.software.proprietary.policy.engine.PolicyRunHandle;
@@ -87,7 +89,10 @@ class PolicyControllerTest {
@Mock private ProcessedLedger processedLedger;
@Mock private TempFileManager tempFileManager;
// Real, not mocked: the run endpoints spool uploads through it.
private final TempFileManager tempFileManager =
new TempFileManager(new TempFileRegistry(), new ApplicationProperties());
@Mock private JobOwnershipService jobOwnershipService;
private ApplicationProperties applicationProperties;
@@ -700,13 +705,46 @@ class PolicyControllerTest {
@DisplayName("runStoredPolicy")
class RunStoredPolicy {
/** What an editor sends: the documents, plus its own id for a single one of them. */
private PolicyRunFiles filesWith(String fileId, int documents) {
PolicyRunFiles files = new PolicyRunFiles();
files.setFileId(fileId);
files.setFileInput(
java.util.stream.IntStream.range(0, documents)
.mapToObj(
i ->
(org.springframework.web.multipart.MultipartFile)
new MockMultipartFile(
"fileInput",
"doc" + i + ".pdf",
"application/pdf",
("pdf-" + i).getBytes()))
.toList());
return files;
}
private String documentReferenceOf(PolicyRunFiles files) throws Exception {
Policy p = policy("a", 1L);
when(policyStore.get("a")).thenReturn(Optional.of(p));
when(policyAccessGuard.canAccess(p)).thenReturn(true);
when(policyRunner.runWith(eq(p), any(), eq(PolicyProgressListener.NOOP), any()))
.thenReturn(handle("run-9"));
controller.runStoredPolicy("a", files);
ArgumentCaptor<String> reference = ArgumentCaptor.forClass(String.class);
verify(policyRunner)
.runWith(eq(p), any(), eq(PolicyProgressListener.NOOP), reference.capture());
return reference.getValue();
}
@Test
@DisplayName("runs a stored, accessible policy")
void runsStored() throws Exception {
Policy p = policy("a", 1L);
when(policyStore.get("a")).thenReturn(Optional.of(p));
when(policyAccessGuard.canAccess(p)).thenReturn(true);
when(policyRunner.runWith(eq(p), any(), eq(PolicyProgressListener.NOOP)))
when(policyRunner.runWith(eq(p), any(), eq(PolicyProgressListener.NOOP), any()))
.thenReturn(handle("run-9"));
ResponseEntity<JobResponse<Void>> response =
@@ -716,6 +754,35 @@ class PolicyControllerTest {
assertThat(response.getBody().getJobId()).isEqualTo("run-9");
}
@Test
@DisplayName("records the caller's own id for a single-document run")
void carriesTheCallersDocumentReference() throws Exception {
// The point of the field: a failure names a document the client that started it can
// resolve.
assertThat(documentReferenceOf(filesWith("editor-file-1", 1)))
.isEqualTo("editor-file-1");
}
@Test
@DisplayName("records nothing when the run carries several documents")
void refusesToGuessWhichOfSeveralDocumentsItIs() throws Exception {
// One incident, one reference: naming one of several would attribute it to whichever
// bound first.
assertThat(documentReferenceOf(filesWith("editor-file-1", 3))).isNull();
}
@Test
@DisplayName("records nothing when the caller sent no id")
void toleratesACallerThatSendsNoReference() throws Exception {
assertThat(documentReferenceOf(filesWith(null, 1))).isNull();
}
@Test
@DisplayName("records nothing for a blank id")
void treatsABlankReferenceAsNone() throws Exception {
assertThat(documentReferenceOf(filesWith(" ", 1))).isNull();
}
@Test
@DisplayName("not found when the stored policy is inaccessible")
void notFound() {
@@ -838,7 +905,7 @@ class PolicyControllerTest {
Policy p = policy("a", 1L);
when(policyStore.get("a")).thenReturn(Optional.of(p));
when(policyAccessGuard.canAccess(p)).thenReturn(true);
when(policyRunner.runWith(eq(p), any(), eq(PolicyProgressListener.NOOP)))
when(policyRunner.runWith(eq(p), any(), eq(PolicyProgressListener.NOOP), any()))
.thenReturn(handle("run-9"));
ResponseEntity<JobResponse<Void>> response =
@@ -32,6 +32,7 @@ import org.springframework.core.io.ByteArrayResource;
import stirling.software.proprietary.policy.input.InputSource;
import stirling.software.proprietary.policy.input.ResolveContext;
import stirling.software.proprietary.policy.input.ResolvedInput;
import stirling.software.proprietary.policy.ledger.IdentityHasher;
import stirling.software.proprietary.policy.ledger.InProcessProcessedLedger;
import stirling.software.proprietary.policy.ledger.ProcessedLedger;
import stirling.software.proprietary.policy.model.InputSpec;
@@ -293,13 +294,56 @@ class PolicyRunnerTest {
Policy policy = policy(List.of(InputSpec.folder("/in")));
PolicyInputs inputs = PolicyInputs.of(List.of());
PolicyRunHandle handle = new PolicyRunHandle("r", new CompletableFuture<>());
when(policyEngine.runPolicy(policy, inputs, PolicyProgressListener.NOOP))
when(policyEngine.runPolicy(policy, inputs, PolicyProgressListener.NOOP, null, null))
.thenReturn(handle);
assertSame(handle, runner.runWith(policy, inputs, PolicyProgressListener.NOOP));
assertSame(handle, runner.runWith(policy, inputs, PolicyProgressListener.NOOP, null));
verifyNoInteractions(folderSource);
}
@Test
void anAttendedRunCarriesTheClientsOwnDocumentReferenceAndNoSource() {
// A failure of this run can then name the document the user is still holding, and the null
// sourceId is what marks the reference as the client's own rather than a source's hash.
Policy policy = policy(List.of());
PolicyInputs inputs = PolicyInputs.of(List.of(new ByteArrayResource("a".getBytes())));
when(policyEngine.runPolicy(
policy, inputs, PolicyProgressListener.NOOP, null, "editor-file-1"))
.thenReturn(new PolicyRunHandle("r", new CompletableFuture<>()));
runner.runWith(policy, inputs, PolicyProgressListener.NOOP, "editor-file-1");
verify(policyEngine)
.runPolicy(policy, inputs, PolicyProgressListener.NOOP, null, "editor-file-1");
}
@Test
void anUnattendedRunStillCarriesItsSourcesHashedIdentity() throws Exception {
// The other id space, unchanged: a folder identity is a path, and a path is a filename, so
// what reaches the run is the one-way hash and never the client-minted kind of reference.
InputSpec spec = InputSpec.folder("/in");
Policy policy = policy(List.of(spec));
String sourceId = policy.inputs().getFirst().sourceId();
when(folderSource.supports(spec)).thenReturn(true);
when(folderSource.resolve(eq(spec), any()))
.thenReturn(
List.of(
ResolvedInput.forFile(
PolicyInputs.of(List.of()), "/in/doc.pdf", success -> {})));
when(policyEngine.runPolicy(any(), any(), any(), any(), any()))
.thenReturn(new PolicyRunHandle("r", new CompletableFuture<>()));
runner.run(policy);
verify(policyEngine)
.runPolicy(
eq(policy),
any(),
any(),
eq(sourceId),
eq(IdentityHasher.identityHash("/in/doc.pdf")));
}
@Test
void runWithRecordsSuppliedDocsAgainstTheEditorSourceForThePolicyTeam() {
Policy policy =
@@ -317,10 +361,11 @@ class PolicyRunnerTest {
List.of(
new ByteArrayResource("a".getBytes()),
new ByteArrayResource("b".getBytes())));
when(policyEngine.runPolicy(policy, inputs, PolicyProgressListener.NOOP))
when(policyEngine.runPolicy(
policy, inputs, PolicyProgressListener.NOOP, null, "editor-file-1"))
.thenReturn(new PolicyRunHandle("r", new CompletableFuture<>()));
runner.runWith(policy, inputs, PolicyProgressListener.NOOP);
runner.runWith(policy, inputs, PolicyProgressListener.NOOP, "editor-file-1");
String key = EditorSource.counterKey(7L);
assertEquals(2, docCounter.statsFor(List.of(key)).get(key).total());
@@ -4,14 +4,12 @@ import java.util.List;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.context.annotation.Profile;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.Authentication;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@@ -19,25 +17,9 @@ import io.swagger.v3.oas.annotations.Hidden;
import lombok.extern.slf4j.Slf4j;
import stirling.software.common.model.enumeration.TeamRole;
import stirling.software.proprietary.model.TeamMembership;
import stirling.software.proprietary.security.database.repository.UserRepository;
import stirling.software.proprietary.security.model.User;
import stirling.software.proprietary.security.repository.TeamMembershipRepository;
import stirling.software.saas.util.AuthenticationUtils;
import stirling.software.saas.accountlink.LeaderTeamResolver.LeaderTeam;
/**
* Account-link registration surface (combined-billing "Mode A").
*
* <p>A self-hosted instance's local backend calls {@code POST /register} with the admin's
* short-lived Supabase JWT (validated by the existing {@code SupabaseSecurityConfig} chain — no new
* auth here). We resolve the caller's team, mint a device credential bound to it, and return the
* secret exactly once. Ongoing entitlement reads authenticate with that device credential, not this
* JWT.
*
* <p>Whole surface gated behind {@code stirling.billing.account-link.enabled}: off → beans absent →
* 404. Leader-only, and the team is always derived from the caller (never the request body).
*/
/** Team-wide management of linked instances (combined billing). */
@Slf4j
@Hidden
@RestController
@@ -47,25 +29,13 @@ import stirling.software.saas.util.AuthenticationUtils;
public class AccountLinkController {
private final AccountLinkService service;
private final TeamMembershipRepository memberRepo;
private final UserRepository userRepository;
private final LeaderTeamResolver leaderTeams;
public AccountLinkController(
AccountLinkService service,
TeamMembershipRepository memberRepo,
UserRepository userRepository) {
public AccountLinkController(AccountLinkService service, LeaderTeamResolver leaderTeams) {
this.service = service;
this.memberRepo = memberRepo;
this.userRepository = userRepository;
this.leaderTeams = leaderTeams;
}
/** Optional display name for the instance (hostname / label). */
public record RegisterRequest(String name) {}
/** {@code deviceSecret} is plaintext and returned exactly once — the caller must store it. */
public record RegisterResponse(
Long instanceId, Long teamId, String deviceId, String deviceSecret, String name) {}
public record InstanceRow(
Long instanceId,
String deviceId,
@@ -74,31 +44,10 @@ public class AccountLinkController {
String lastSeenAt,
boolean revoked) {}
@PostMapping("/register")
@PreAuthorize("isAuthenticated()")
public ResponseEntity<RegisterResponse> register(
@RequestBody(required = false) RegisterRequest req, Authentication auth) {
LeaderTeam lt = resolveLeaderTeam(auth);
if (lt.error() != null) {
return ResponseEntity.status(lt.error()).build();
}
String name = req != null ? req.name() : null;
AccountLinkService.RegisteredInstance reg =
service.register(lt.teamId(), lt.userId(), name);
return ResponseEntity.status(HttpStatus.CREATED)
.body(
new RegisterResponse(
reg.instanceId(),
lt.teamId(),
reg.deviceId(),
reg.deviceSecret(),
reg.name()));
}
@GetMapping("/instances")
@PreAuthorize("isAuthenticated()")
public ResponseEntity<List<InstanceRow>> list(Authentication auth) {
LeaderTeam lt = resolveLeaderTeam(auth);
LeaderTeam lt = leaderTeams.resolve(auth);
if (lt.error() != null) {
return ResponseEntity.status(lt.error()).build();
}
@@ -124,38 +73,11 @@ public class AccountLinkController {
@PostMapping("/instances/{instanceId}/revoke")
@PreAuthorize("isAuthenticated()")
public ResponseEntity<Void> revoke(@PathVariable Long instanceId, Authentication auth) {
LeaderTeam lt = resolveLeaderTeam(auth);
LeaderTeam lt = leaderTeams.resolve(auth);
if (lt.error() != null) {
return ResponseEntity.status(lt.error()).build();
}
boolean ok = service.revoke(lt.teamId(), instanceId);
return ok ? ResponseEntity.noContent().build() : ResponseEntity.notFound().build();
}
// ---------------------------------------------------------------------------------------
// Helpers — team always derived from the caller; instance linking is a leader (billing) action.
// ---------------------------------------------------------------------------------------
/**
* Resolved caller team, or an {@code error} status to return (teamId/userId null when error).
*/
private record LeaderTeam(Long teamId, Long userId, HttpStatus error) {}
private LeaderTeam resolveLeaderTeam(Authentication auth) {
User user;
try {
user = AuthenticationUtils.getCurrentUser(auth, userRepository);
} catch (SecurityException e) {
return new LeaderTeam(null, null, HttpStatus.UNAUTHORIZED);
}
List<TeamMembership> rows = memberRepo.findPrimaryMembership(user.getId());
if (rows.isEmpty()) {
return new LeaderTeam(null, null, HttpStatus.FORBIDDEN);
}
TeamMembership m = rows.getFirst();
if (m.getRole() != TeamRole.LEADER) {
return new LeaderTeam(null, null, HttpStatus.FORBIDDEN);
}
return new LeaderTeam(m.getTeam().getId(), user.getId(), null);
}
}
@@ -18,16 +18,7 @@ import org.springframework.transaction.annotation.Transactional;
import lombok.extern.slf4j.Slf4j;
/**
* Account-link instance registration + lifecycle (combined-billing "Mode A").
*
* <p>Mints a {@code device_id} (public) + {@code device_secret} (high-entropy, returned once) bound
* to a team, persisting only the SHA-256 hash of the secret. The instance authenticates its
* unattended entitlement reads with that credential.
*
* <p>Gated behind {@code stirling.billing.account-link.enabled}: when off the bean is absent, so
* {@link AccountLinkController} (which depends on it) drops out too and its endpoints 404.
*/
/** Account-link instance registration + lifecycle (combined billing). */
@Slf4j
@Service
@Profile("saas")
@@ -80,10 +71,7 @@ public class AccountLinkService {
return repo.findByTeamIdOrderByCreatedAtDesc(teamId);
}
/**
* Revokes an instance iff it belongs to {@code teamId}. Returns false if not found or owned by
* a different team (so a caller can never revoke another team's instance). Idempotent.
*/
/** Revokes an instance iff it belongs to {@code teamId}. */
@Transactional
public boolean revoke(Long teamId, Long instanceId) {
Optional<LinkedInstance> found = repo.findById(instanceId);
@@ -99,13 +87,30 @@ public class AccountLinkService {
return true;
}
/**
* Resolves an active instance from a device credential, or empty if it does not authenticate.
*/
@Transactional(readOnly = true)
public Optional<LinkedInstance> resolveActiveInstance(String deviceId, String deviceSecret) {
if (deviceId == null || deviceSecret == null) {
return Optional.empty();
}
return repo.findByDeviceIdAndRevokedAtIsNull(deviceId)
.filter(
instance ->
MessageDigest.isEqual(
sha256Hex(deviceSecret).getBytes(StandardCharsets.UTF_8),
instance.getDeviceSecretHash()
.getBytes(StandardCharsets.UTF_8)));
}
private String randomSecret() {
byte[] buf = new byte[SECRET_BYTES];
random.nextBytes(buf);
return Base64.getUrlEncoder().withoutPadding().encodeToString(buf);
}
/** SHA-256 hex of a value. The device secret is high-entropy, so no salt is required. */
/** SHA-256 hex of a value. */
static String sha256Hex(String value) {
try {
MessageDigest md = MessageDigest.getInstance("SHA-256");
@@ -0,0 +1,277 @@
package stirling.software.saas.accountlink;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.util.Map;
import java.util.Optional;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.context.annotation.Profile;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.Authentication;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import io.swagger.v3.oas.annotations.Hidden;
import jakarta.servlet.http.HttpServletRequest;
import lombok.extern.slf4j.Slf4j;
import stirling.software.common.model.ApplicationProperties;
import stirling.software.saas.accountlink.LeaderTeamResolver.LeaderTeam;
/** Browser-mediated "connect this server" handshake. */
@Slf4j
@Hidden
@RestController
@RequestMapping("/api/v1/account-link/connect")
@Profile("saas")
@ConditionalOnProperty(name = "stirling.billing.account-link.enabled", havingValue = "true")
public class ConnectController {
/** Same headers the device-credential filter uses on the {@code /api/v1/instance} paths. */
static final String HEADER_DEVICE_ID = "X-Device-Id";
static final String HEADER_DEVICE_SECRET = "X-Device-Secret";
/** Frontend route serving the approval page. */
static final String LINK_PATH = "/link";
private final ConnectRequestService service;
private final LeaderTeamResolver leaderTeams;
private final AccountLinkService accountLinkService;
private final ApplicationProperties applicationProperties;
public ConnectController(
ConnectRequestService service,
LeaderTeamResolver leaderTeams,
AccountLinkService accountLinkService,
ApplicationProperties applicationProperties) {
this.service = service;
this.leaderTeams = leaderTeams;
this.accountLinkService = accountLinkService;
this.applicationProperties = applicationProperties;
}
/** Sent by the instance's own backend, before it holds any credential. */
public record CreateBody(String name, String callbackUrl, String nonce, String claimSecret) {}
/** {@code authorizeUrl} is where the instance should send its admin. */
public record CreateResponse(String requestId, int expiresIn, String authorizeUrl) {}
/** What the approval page renders. */
public record ViewResponse(
String requestId,
String name,
String callbackOrigin,
boolean insecureTransport,
String mode,
String status) {}
/** Where the approver's browser goes next, and the correlator the instance is waiting on. */
public record ApproveResponse(String callbackUrl, String nonce) {}
public record ClaimBody(String requestId, String claimSecret) {}
public record ClaimResponse(String deviceId, String deviceSecret, Long teamId) {}
/** Opens a handshake. */
@PostMapping("/request")
public ResponseEntity<?> request(
@RequestBody(required = false) CreateBody body, HttpServletRequest http) {
if (body == null) {
return ResponseEntity.badRequest().body(Map.of("error", "BAD_REQUEST"));
}
String deviceId = http.getHeader(HEADER_DEVICE_ID);
String deviceSecret = http.getHeader(HEADER_DEVICE_SECRET);
boolean reauthRequested = deviceId != null || deviceSecret != null;
ConnectRequestService.CreateResult result;
if (reauthRequested) {
Long pinnedTeamId =
accountLinkService
.resolveActiveInstance(deviceId, deviceSecret)
.map(LinkedInstance::getTeamId)
.orElse(null);
result =
service.createReauth(
body.name(),
body.callbackUrl(),
body.nonce(),
body.claimSecret(),
clientIp(http),
pinnedTeamId);
} else {
result =
service.create(
body.name(),
body.callbackUrl(),
body.nonce(),
body.claimSecret(),
clientIp(http));
}
if (result.isRejected()) {
return switch (result.rejection()) {
case RATE_LIMITED ->
ResponseEntity.status(HttpStatus.TOO_MANY_REQUESTS)
.body(Map.of("error", "RATE_LIMITED"));
case BAD_CALLBACK ->
ResponseEntity.badRequest().body(Map.of("error", "BAD_CALLBACK"));
case BAD_NONCE -> ResponseEntity.badRequest().body(Map.of("error", "BAD_NONCE"));
case BAD_SECRET -> ResponseEntity.badRequest().body(Map.of("error", "BAD_SECRET"));
// A credential was offered and did not authenticate. Same answer as any other bad
// credential, and deliberately not distinguishable from "revoked".
case NOT_LINKED ->
ResponseEntity.status(HttpStatus.UNAUTHORIZED)
.body(Map.of("error", "NOT_LINKED"));
};
}
return ResponseEntity.status(HttpStatus.CREATED)
.body(
new CreateResponse(
result.requestId(),
result.expiresInSeconds(),
authorizeUrl(result.requestId(), http)));
}
/**
* Where to send the admin to approve a handshake. {@code system.frontendUrl} is the web app's
* own base URL, including any base path; without it the API's origin has to serve the app too.
*/
private String authorizeUrl(String requestId, HttpServletRequest http) {
String frontendUrl = applicationProperties.getSystem().getFrontendUrl();
String base =
frontendUrl != null && !frontendUrl.isBlank()
? frontendUrl.strip().replaceAll("/+$", "")
: requestOrigin(http);
return base
+ LINK_PATH
+ "?request="
+ URLEncoder.encode(requestId, StandardCharsets.UTF_8);
}
/** Scheme, host and context path as the browser reached us, honouring a reverse proxy. */
private static String requestOrigin(HttpServletRequest request) {
String proto = firstHop(request.getHeader("X-Forwarded-Proto"));
String host = firstHop(request.getHeader("X-Forwarded-Host"));
String scheme = proto != null ? proto : request.getScheme();
// A forwarded host already carries its own port, if it needs one.
String hostPort =
host != null
? host
: Origins.hostPort(
scheme, request.getServerName(), request.getServerPort());
String context = request.getContextPath() == null ? "" : request.getContextPath();
return scheme + "://" + hostPort + context;
}
private static String firstHop(String headerValue) {
if (headerValue == null || headerValue.isBlank()) {
return null;
}
String first = headerValue.split(",")[0].strip();
return first.isEmpty() ? null : first;
}
/** Detail for the approval page. */
@GetMapping("/{requestId}")
@PreAuthorize("isAuthenticated()")
public ResponseEntity<ViewResponse> view(@PathVariable String requestId) {
return service.lookup(requestId)
.map(
v ->
ResponseEntity.ok(
new ViewResponse(
v.requestId(),
v.name(),
v.callbackOrigin(),
v.insecureTransport(),
v.mode().name(),
v.status().name())))
.orElseGet(() -> ResponseEntity.notFound().build());
}
/** Approves a handshake. */
@PostMapping("/{requestId}/approve")
@PreAuthorize("isAuthenticated()")
public ResponseEntity<?> approve(@PathVariable String requestId, Authentication auth) {
Optional<ConnectRequestService.ConnectView> view = service.lookup(requestId);
if (view.isEmpty()) {
return ResponseEntity.notFound().build();
}
boolean reauth = view.get().mode() == ConnectRequest.Mode.REAUTH;
LeaderTeam lt = reauth ? leaderTeams.resolveMember(auth) : leaderTeams.resolve(auth);
if (lt.isError()) {
return ResponseEntity.status(lt.error()).build();
}
ConnectRequestService.ApproveResult result =
service.approve(requestId, lt.teamId(), lt.userId());
if (result.isRejected()) {
return switch (result.rejection()) {
// Named separately so the page can say "you are signed in to a different account"
// rather than implying the request itself was bad.
case WRONG_TEAM ->
ResponseEntity.status(HttpStatus.CONFLICT)
.body(Map.of("error", "WRONG_TEAM"));
case UNAVAILABLE -> ResponseEntity.notFound().build();
};
}
return ResponseEntity.ok(
new ApproveResponse(result.target().callbackUrl(), result.target().nonce()));
}
@PostMapping("/{requestId}/deny")
@PreAuthorize("isAuthenticated()")
public ResponseEntity<Void> deny(@PathVariable String requestId, Authentication auth) {
LeaderTeam lt = leaderTeams.resolve(auth);
if (lt.isError()) {
return ResponseEntity.status(lt.error()).build();
}
return service.deny(requestId)
? ResponseEntity.noContent().build()
: ResponseEntity.notFound().build();
}
/** Collects the device credential. */
@PostMapping("/claim")
public ResponseEntity<?> claim(@RequestBody(required = false) ClaimBody body) {
if (body == null) {
return ResponseEntity.badRequest().body(Map.of("error", "BAD_REQUEST"));
}
ConnectRequestService.ClaimResult result =
service.claim(body.requestId(), body.claimSecret());
return switch (result.outcome()) {
case GRANTED ->
ResponseEntity.ok(
new ClaimResponse(
result.deviceId(), result.deviceSecret(), result.teamId()));
// A re-authentication carries no credential: the instance already has one. It only
// needs to know the browser leg succeeded, and which team it was confirmed against.
case CONFIRMED ->
ResponseEntity.ok(Map.of("status", "confirmed", "teamId", result.teamId()));
case PENDING ->
ResponseEntity.status(HttpStatus.ACCEPTED).body(Map.of("status", "pending"));
case REJECTED -> ResponseEntity.badRequest().body(Map.of("error", "CONNECT_REJECTED"));
};
}
/**
* Source address for the creation cap.
*
* <p>Deliberately not reading {@code X-Forwarded-For}: the caller sets it, so keying a cap on
* it lets one rotate fake addresses and have no cap at all. {@code
* server.forward-headers-strategy} is NATIVE, so the container has already resolved the real
* client from trusted proxies.
*/
private static String clientIp(HttpServletRequest request) {
String remote = request.getRemoteAddr();
return remote == null || remote.length() <= 45 ? remote : remote.substring(0, 45);
}
}
@@ -0,0 +1,103 @@
package stirling.software.saas.accountlink;
import java.time.LocalDateTime;
import org.hibernate.annotations.CreationTimestamp;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.EnumType;
import jakarta.persistence.Enumerated;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Index;
import jakarta.persistence.Table;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
/** One in-flight "connect this server" handshake. Short lived and single use. */
@Entity
@Table(
name = "account_link_connect_request",
indexes = @Index(name = "idx_alcr_ip_created", columnList = "requester_ip,created_at"))
@Getter
@Setter
@NoArgsConstructor
public class ConnectRequest {
public enum Mode {
LINK,
REAUTH
}
public enum Status {
PENDING,
APPROVED,
DENIED,
CONSUMED
}
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "request_id", nullable = false, unique = true, length = 64)
private String requestId;
@Column(name = "name", length = 255)
private String name;
/**
* Read back from here on approval, never from the request: that is what stops an open redirect.
*/
@Column(name = "callback_url", nullable = false, length = 2048)
private String callbackUrl;
@Column(name = "callback_origin", nullable = false, length = 255)
private String callbackOrigin;
@Column(name = "nonce", nullable = false, length = 128)
private String nonce;
/** SHA-256; the secret itself is never stored. */
@Column(name = "claim_secret_hash", nullable = false, length = 64)
private String claimSecretHash;
@Enumerated(EnumType.STRING)
@Column(name = "mode", nullable = false, length = 16)
private Mode mode = Mode.LINK;
@Enumerated(EnumType.STRING)
@Column(name = "status", nullable = false, length = 16)
private Status status = Status.PENDING;
/** LINK: set on approval. REAUTH: pinned at creation, so approval can only confirm it. */
@Column(name = "team_id")
private Long teamId;
@Column(name = "approved_by_user_id")
private Long approvedByUserId;
@Column(name = "requester_ip", length = 45)
private String requesterIp;
@CreationTimestamp
@Column(name = "created_at", nullable = false, updatable = false)
private LocalDateTime createdAt;
@Column(name = "expires_at", nullable = false)
private LocalDateTime expiresAt;
@Column(name = "approved_at")
private LocalDateTime approvedAt;
@Column(name = "consumed_at")
private LocalDateTime consumedAt;
public boolean isExpired(LocalDateTime now) {
return expiresAt != null && expiresAt.isBefore(now);
}
}
@@ -0,0 +1,47 @@
package stirling.software.saas.accountlink;
import java.time.LocalDateTime;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.context.annotation.Profile;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
/**
* Removes connect requests that are past use.
*
* <p>Needed rather than merely tidy: anyone can create a row on {@code POST /connect/request}, and
* nothing else deletes one. Requests hold a callback URL and the requester's address, so they are
* swept soon after expiry rather than kept.
*/
@Slf4j
@Service
@Profile("saas")
@ConditionalOnProperty(name = "stirling.billing.account-link.enabled", havingValue = "true")
@RequiredArgsConstructor
public class ConnectRequestCleanupService {
/** Long enough to answer "what happened to my link?" the next morning, and no longer. */
private static final int RETAIN_HOURS = 24;
private final ConnectRequestRepository repo;
@Scheduled(cron = "0 30 3 * * *")
@Transactional
public void purgeExpired() {
try {
LocalDateTime cutoff = LocalDateTime.now().minusHours(RETAIN_HOURS);
int deleted = repo.deleteByExpiresAtBefore(cutoff);
if (deleted > 0) {
log.info("Account-link connect: purged {} expired requests", deleted);
}
} catch (Exception e) {
// A failed sweep must not take the scheduler down; the next run retries.
log.error("Account-link connect: purge failed", e);
}
}
}
@@ -0,0 +1,28 @@
package stirling.software.saas.accountlink;
import java.time.LocalDateTime;
import java.util.Optional;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Lock;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import jakarta.persistence.LockModeType;
/** Data access for {@link ConnectRequest}. */
public interface ConnectRequestRepository extends JpaRepository<ConnectRequest, Long> {
Optional<ConnectRequest> findByRequestId(String requestId);
/** Row-locking read used by approve, deny and claim. */
@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("SELECT r FROM ConnectRequest r WHERE r.requestId = :requestId")
Optional<ConnectRequest> findByRequestIdForUpdate(@Param("requestId") String requestId);
/** Backs the per-IP creation cap, since creating a request needs no authentication. */
long countByRequesterIpAndCreatedAtAfter(String requesterIp, LocalDateTime after);
/** Sweeps rows past use, whatever they settled as. Anyone can create these. */
int deleteByExpiresAtBefore(LocalDateTime cutoff);
}
@@ -0,0 +1,391 @@
package stirling.software.saas.accountlink;
import java.net.URI;
import java.net.URISyntaxException;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.SecureRandom;
import java.time.LocalDateTime;
import java.util.Base64;
import java.util.Locale;
import java.util.Optional;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.context.annotation.Profile;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import lombok.extern.slf4j.Slf4j;
/** The "connect this server" handshake, SaaS side. */
@Slf4j
@Service
@Profile("saas")
@ConditionalOnProperty(name = "stirling.billing.account-link.enabled", havingValue = "true")
public class ConnectRequestService {
/**
* Long enough for the approver to sign in, pick the right account and read the origin. Sized
* for the slowest real route: signing up, waiting for a confirmation email, and coming back.
*/
static final int LIFETIME_MINUTES = 30;
/** Creating a request needs no authentication, so the only brake is per-source volume. */
static final int MAX_REQUESTS_PER_IP = 10;
private static final int REQUEST_ID_BYTES = 32;
private static final int MAX_NONCE_LENGTH = 128;
private static final int MAX_CALLBACK_LENGTH = 2048;
private static final int MAX_NAME_LENGTH = 255;
private final ConnectRequestRepository repo;
private final AccountLinkService accountLinkService;
private final SecureRandom random = new SecureRandom();
public ConnectRequestService(
ConnectRequestRepository repo, AccountLinkService accountLinkService) {
this.repo = repo;
this.accountLinkService = accountLinkService;
}
/** Rejected creation attempts, so the controller can pick a status without parsing messages. */
public enum CreateRejection {
BAD_CALLBACK,
BAD_NONCE,
BAD_SECRET,
RATE_LIMITED,
/**
* A re-authentication was asked for by something that could not prove it is a linked
* instance.
*/
NOT_LINKED
}
/** Either a created request id, or the reason we would not create one. */
public record CreateResult(String requestId, int expiresInSeconds, CreateRejection rejection) {
static CreateResult ok(String requestId, int expiresInSeconds) {
return new CreateResult(requestId, expiresInSeconds, null);
}
static CreateResult rejected(CreateRejection rejection) {
return new CreateResult(null, 0, rejection);
}
public boolean isRejected() {
return rejection != null;
}
}
/** What the approval page shows. */
public record ConnectView(
String requestId,
String name,
String callbackOrigin,
boolean insecureTransport,
ConnectRequest.Mode mode,
ConnectRequest.Status status) {}
/** Where to send the browser once approved, plus the correlator the instance is expecting. */
public record ApprovalTarget(String callbackUrl, String nonce) {}
public enum ClaimOutcome {
/** Approved and collected; {@code credential} is populated. */
GRANTED,
/** A re-authentication was approved. */
CONFIRMED,
/** Still waiting on a human. */
PENDING,
/** Declined, expired, unknown, already collected, or a bad claim secret. */
REJECTED
}
public record ClaimResult(
ClaimOutcome outcome, String deviceId, String deviceSecret, Long teamId) {
static ClaimResult of(ClaimOutcome outcome) {
return new ClaimResult(outcome, null, null, null);
}
}
/** Records a handshake on behalf of an instance that has no credential yet. */
@Transactional
public CreateResult create(
String name, String callbackUrl, String nonce, String claimSecret, String requesterIp) {
return create(name, callbackUrl, nonce, claimSecret, requesterIp, null);
}
/**
* As {@link #create}, but for an instance that is already linked and only needs its admin's
* browser signed in again.
*/
@Transactional
public CreateResult createReauth(
String name,
String callbackUrl,
String nonce,
String claimSecret,
String requesterIp,
Long pinnedTeamId) {
if (pinnedTeamId == null) {
return CreateResult.rejected(CreateRejection.NOT_LINKED);
}
return create(name, callbackUrl, nonce, claimSecret, requesterIp, pinnedTeamId);
}
private CreateResult create(
String name,
String callbackUrl,
String nonce,
String claimSecret,
String requesterIp,
Long pinnedTeamId) {
if (nonce == null || nonce.isBlank() || nonce.length() > MAX_NONCE_LENGTH) {
return CreateResult.rejected(CreateRejection.BAD_NONCE);
}
if (claimSecret == null || claimSecret.isBlank()) {
return CreateResult.rejected(CreateRejection.BAD_SECRET);
}
Optional<URI> parsed = validateCallback(callbackUrl);
if (parsed.isEmpty()) {
return CreateResult.rejected(CreateRejection.BAD_CALLBACK);
}
LocalDateTime now = LocalDateTime.now();
if (requesterIp != null
&& repo.countByRequesterIpAndCreatedAtAfter(requesterIp, now.minusHours(1))
>= MAX_REQUESTS_PER_IP) {
return CreateResult.rejected(CreateRejection.RATE_LIMITED);
}
URI uri = parsed.get();
ConnectRequest request = new ConnectRequest();
request.setRequestId(randomToken());
request.setName(trim(name, MAX_NAME_LENGTH));
request.setCallbackUrl(uri.toString());
request.setCallbackOrigin(originOf(uri));
request.setNonce(nonce);
request.setClaimSecretHash(sha256Hex(claimSecret));
request.setStatus(ConnectRequest.Status.PENDING);
request.setMode(
pinnedTeamId == null ? ConnectRequest.Mode.LINK : ConnectRequest.Mode.REAUTH);
request.setTeamId(pinnedTeamId);
request.setRequesterIp(requesterIp);
request.setExpiresAt(now.plusMinutes(LIFETIME_MINUTES));
repo.save(request);
// Never log the nonce or the claim secret; both are live. The request id is the safe
// handle for correlating a support request against this row.
log.info(
"Account-link connect: request {} created for origin {}",
request.getRequestId(),
request.getCallbackOrigin());
return CreateResult.ok(request.getRequestId(), LIFETIME_MINUTES * 60);
}
/** The approver's view of a handshake. */
@Transactional(readOnly = true)
public Optional<ConnectView> lookup(String requestId) {
return repo.findByRequestId(requestId)
.filter(r -> !r.isExpired(LocalDateTime.now()))
.map(
r ->
new ConnectView(
r.getRequestId(),
r.getName(),
r.getCallbackOrigin(),
!"https".equals(schemeOf(r.getCallbackOrigin())),
r.getMode(),
r.getStatus()));
}
/** Why an approval was refused, so the page can say something useful. */
public enum ApproveRejection {
/** Unknown, expired, or already settled. */
UNAVAILABLE,
/** The approver's team is not the team this server already belongs to. */
WRONG_TEAM
}
public record ApproveResult(ApprovalTarget target, ApproveRejection rejection) {
public boolean isRejected() {
return target == null;
}
}
/** Binds a pending handshake to the approver's team and returns where to send them next. */
@Transactional
public ApproveResult approve(String requestId, Long teamId, Long userId) {
Optional<ConnectRequest> found = repo.findByRequestIdForUpdate(requestId);
if (found.isEmpty()) {
return new ApproveResult(null, ApproveRejection.UNAVAILABLE);
}
ConnectRequest request = found.get();
LocalDateTime now = LocalDateTime.now();
if (request.isExpired(now) || request.getStatus() != ConnectRequest.Status.PENDING) {
return new ApproveResult(null, ApproveRejection.UNAVAILABLE);
}
Long pinned = request.getTeamId();
if (pinned != null && !pinned.equals(teamId)) {
log.warn(
"Account-link connect: request {} approved by team {} but is pinned to team {};"
+ " refusing",
requestId,
teamId,
pinned);
return new ApproveResult(null, ApproveRejection.WRONG_TEAM);
}
request.setStatus(ConnectRequest.Status.APPROVED);
request.setTeamId(teamId);
request.setApprovedByUserId(userId);
request.setApprovedAt(now);
repo.save(request);
log.info(
"Account-link connect: request {} approved for team {} ({})",
requestId,
teamId,
request.getMode());
return new ApproveResult(
new ApprovalTarget(request.getCallbackUrl(), request.getNonce()), null);
}
/** Declines a pending handshake. */
@Transactional
public boolean deny(String requestId) {
Optional<ConnectRequest> found = repo.findByRequestIdForUpdate(requestId);
if (found.isEmpty()) {
return false;
}
ConnectRequest request = found.get();
if (request.getStatus() != ConnectRequest.Status.PENDING) {
return false;
}
request.setStatus(ConnectRequest.Status.DENIED);
repo.save(request);
log.info("Account-link connect: request {} denied", requestId);
return true;
}
/** Collects the device credential for an approved handshake. */
@Transactional
public ClaimResult claim(String requestId, String claimSecret) {
if (requestId == null || claimSecret == null) {
return ClaimResult.of(ClaimOutcome.REJECTED);
}
Optional<ConnectRequest> found = repo.findByRequestIdForUpdate(requestId);
if (found.isEmpty()) {
return ClaimResult.of(ClaimOutcome.REJECTED);
}
ConnectRequest request = found.get();
if (!secretMatches(claimSecret, request.getClaimSecretHash())) {
// Same answer as an unknown id: a caller probing ids learns nothing from the
// difference.
log.warn("Account-link connect: claim for request {} had a bad secret", requestId);
return ClaimResult.of(ClaimOutcome.REJECTED);
}
if (request.isExpired(LocalDateTime.now())) {
return ClaimResult.of(ClaimOutcome.REJECTED);
}
return switch (request.getStatus()) {
case PENDING -> ClaimResult.of(ClaimOutcome.PENDING);
case APPROVED -> mint(request);
case DENIED, CONSUMED -> ClaimResult.of(ClaimOutcome.REJECTED);
};
}
/** Settles an approved handshake. */
private ClaimResult mint(ConnectRequest request) {
if (request.getMode() == ConnectRequest.Mode.REAUTH) {
request.setStatus(ConnectRequest.Status.CONSUMED);
request.setConsumedAt(LocalDateTime.now());
repo.save(request);
log.info(
"Account-link connect: request {} re-authenticated for team {}",
request.getRequestId(),
request.getTeamId());
return new ClaimResult(ClaimOutcome.CONFIRMED, null, null, request.getTeamId());
}
AccountLinkService.RegisteredInstance registered =
accountLinkService.register(
request.getTeamId(), request.getApprovedByUserId(), request.getName());
request.setStatus(ConnectRequest.Status.CONSUMED);
request.setConsumedAt(LocalDateTime.now());
repo.save(request);
log.info(
"Account-link connect: request {} claimed, instance {} bound to team {}",
request.getRequestId(),
registered.instanceId(),
request.getTeamId());
return new ClaimResult(
ClaimOutcome.GRANTED,
registered.deviceId(),
registered.deviceSecret(),
request.getTeamId());
}
/** Absolute http(s) URL, with a host, no credentials and no fragment of its own. */
static Optional<URI> validateCallback(String candidate) {
if (candidate == null || candidate.isBlank() || candidate.length() > MAX_CALLBACK_LENGTH) {
return Optional.empty();
}
URI uri;
try {
uri = new URI(candidate.strip());
} catch (URISyntaxException e) {
return Optional.empty();
}
if (!uri.isAbsolute() || uri.getScheme() == null) {
return Optional.empty();
}
String scheme = uri.getScheme().toLowerCase(Locale.ROOT);
if (!"http".equals(scheme) && !"https".equals(scheme)) {
return Optional.empty();
}
if (uri.getHost() == null || uri.getHost().isBlank()) {
return Optional.empty();
}
if (uri.getUserInfo() != null || uri.getFragment() != null) {
return Optional.empty();
}
return Optional.of(uri);
}
/** Scheme, host and port, with the default port omitted so origins compare cleanly. */
static String originOf(URI uri) {
String scheme = uri.getScheme().toLowerCase(Locale.ROOT);
return scheme + "://" + Origins.hostPort(scheme, uri.getHost(), uri.getPort());
}
private static String schemeOf(String origin) {
int sep = origin.indexOf("://");
return sep < 0 ? "" : origin.substring(0, sep);
}
private static String trim(String value, int max) {
if (value == null) {
return null;
}
String stripped = value.strip();
if (stripped.isEmpty()) {
return null;
}
return stripped.length() <= max ? stripped : stripped.substring(0, max);
}
private String randomToken() {
byte[] buf = new byte[REQUEST_ID_BYTES];
random.nextBytes(buf);
return Base64.getUrlEncoder().withoutPadding().encodeToString(buf);
}
/** Constant-time comparison so a claim cannot be brute-forced a byte at a time. */
private static boolean secretMatches(String candidate, String expectedHash) {
if (expectedHash == null) {
return false;
}
return MessageDigest.isEqual(
sha256Hex(candidate).getBytes(StandardCharsets.UTF_8),
expectedHash.getBytes(StandardCharsets.UTF_8));
}
private static String sha256Hex(String value) {
return AccountLinkService.sha256Hex(value);
}
}
@@ -19,7 +19,7 @@ import jakarta.servlet.http.HttpServletResponse;
import lombok.extern.slf4j.Slf4j;
/**
* Authenticates a linked self-hosted instance by its device credential (combined-billing "Mode A").
* Authenticates a linked self-hosted instance by its device credential (combined billing).
*
* <p>Reads {@code X-Device-Id} + {@code X-Device-Secret}, looks up the active {@link
* LinkedInstance}, and constant-time compares the SHA-256 of the presented secret against the
@@ -32,9 +32,9 @@ import stirling.software.saas.payg.policy.PricingPolicy;
import stirling.software.saas.payg.policy.PricingPolicyService;
/**
* Instance-facing surface (combined-billing "Mode A"), authenticated by the <b>device
* credential</b> — not a user JWT. Separate path prefix ({@code /api/v1/instance/**}) so the device
* credential is scoped here and nowhere else.
* Instance-facing surface (combined billing), authenticated by the <b>device credential</b> — not a
* user JWT. Separate path prefix ({@code /api/v1/instance/**}) so the device credential is scoped
* here and nowhere else.
*
* <p>{@code GET /whoami} is the MVP round-trip proof: a registered instance presenting a valid
* device credential gets back its resolved {@code instanceId} + {@code teamId}. {@code GET
@@ -0,0 +1,68 @@
package stirling.software.saas.accountlink;
import java.util.List;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.context.annotation.Profile;
import org.springframework.http.HttpStatus;
import org.springframework.security.core.Authentication;
import org.springframework.stereotype.Component;
import stirling.software.common.model.enumeration.TeamRole;
import stirling.software.proprietary.model.TeamMembership;
import stirling.software.proprietary.security.database.repository.UserRepository;
import stirling.software.proprietary.security.model.User;
import stirling.software.proprietary.security.repository.TeamMembershipRepository;
import stirling.software.saas.util.AuthenticationUtils;
/** Who is allowed to bind a self-hosted instance to a team. */
@Component
@Profile("saas")
@ConditionalOnProperty(name = "stirling.billing.account-link.enabled", havingValue = "true")
public class LeaderTeamResolver {
private final TeamMembershipRepository memberRepo;
private final UserRepository userRepository;
public LeaderTeamResolver(TeamMembershipRepository memberRepo, UserRepository userRepository) {
this.memberRepo = memberRepo;
this.userRepository = userRepository;
}
/**
* Resolved caller, or an {@code error} status to return ({@code teamId}/{@code userId} null).
*/
public record LeaderTeam(Long teamId, Long userId, HttpStatus error) {
public boolean isError() {
return error != null;
}
}
/** Caller must lead their team. */
public LeaderTeam resolve(Authentication auth) {
return resolve(auth, true);
}
/** Caller need only belong to a team. */
public LeaderTeam resolveMember(Authentication auth) {
return resolve(auth, false);
}
private LeaderTeam resolve(Authentication auth, boolean requireLeader) {
User user;
try {
user = AuthenticationUtils.getCurrentUser(auth, userRepository);
} catch (SecurityException e) {
return new LeaderTeam(null, null, HttpStatus.UNAUTHORIZED);
}
List<TeamMembership> rows = memberRepo.findPrimaryMembership(user.getId());
if (rows.isEmpty()) {
return new LeaderTeam(null, null, HttpStatus.FORBIDDEN);
}
TeamMembership membership = rows.getFirst();
if (requireLeader && membership.getRole() != TeamRole.LEADER) {
return new LeaderTeam(null, null, HttpStatus.FORBIDDEN);
}
return new LeaderTeam(membership.getTeam().getId(), user.getId(), null);
}
}
@@ -16,7 +16,7 @@ import lombok.NoArgsConstructor;
import lombok.Setter;
/**
* One self-hosted instance that has linked a SaaS account (combined-billing "Mode A", {@code
* One self-hosted instance that has linked a SaaS account (combined billing, {@code
* linked_instance}, V22).
*
* <p>Created by {@code POST /api/v1/account-link/register}, authenticated with the admin's
@@ -6,7 +6,7 @@ import org.springframework.security.authentication.AbstractAuthenticationToken;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
/**
* Authentication for a linked self-hosted instance (combined-billing "Mode A").
* Authentication for a linked self-hosted instance (combined billing).
*
* <p>Deliberately <em>not</em> a user: the principal is the instance ({@code instanceId}) bound to
* a {@code teamId}, with the single authority {@code ROLE_LINKED_INSTANCE}. It carries no {@code
@@ -0,0 +1,22 @@
package stirling.software.saas.accountlink;
/**
* Origin formatting shared by the connect handshake.
*
* <p>One place on purpose: the origin a request arrives on and the origin parsed out of a callback
* URL are compared with each other, so if either side stopped omitting the default port the
* comparison would start failing quietly.
*/
final class Origins {
private Origins() {}
/** {@code host} or {@code host:port}, dropping a port that is the scheme's default. */
static String hostPort(String scheme, String host, int port) {
boolean isDefault =
port <= 0
|| ("http".equals(scheme) && port == 80)
|| ("https".equals(scheme) && port == 443);
return isDefault ? host : host + ":" + port;
}
}
@@ -41,6 +41,7 @@ public final class SaasSchemaOwnership {
*/
public static final Set<String> MIGRATION_OWNED =
Set.of(
"account_link_connect_request",
"ai_create_sessions",
"audit_events",
"authorities",
@@ -78,6 +79,7 @@ public final class SaasSchemaOwnership {
*/
public static final Set<String> HIBERNATE_MANAGED =
Set.of(
"account_link_connect_state",
"account_link_device_credential",
"account_link_metered_signature",
"account_link_sync_state",
@@ -18,12 +18,12 @@ import stirling.software.saas.payg.model.ProcessType;
import stirling.software.saas.payg.repository.PaygInstanceUsageRepository;
/**
* Ingests a linked instance's daily usage sync (combined-billing "Mode A"). The instance reports a
* monotonic cumulative unit total per {@link BillingCategory}; we bill only the delta since the
* last sync via {@link JobChargeService#chargeStandalone} (reusing the in-cloud free-grant split,
* ledger DEBIT, Stripe meter and idempotency). Idempotent (a resend → delta 0 → no charge) and
* tamper-evident (a backwards total is refused; a monotonic {@code syncSeq} dedups replays). The
* cap is enforced at the instance gate, not here. Gated behind {@code account-link.enabled}.
* Ingests a linked instance's daily usage sync (combined billing). The instance reports a monotonic
* cumulative unit total per {@link BillingCategory}; we bill only the delta since the last sync via
* {@link JobChargeService#chargeStandalone} (reusing the in-cloud free-grant split, ledger DEBIT,
* Stripe meter and idempotency). Idempotent (a resend → delta 0 → no charge) and tamper-evident (a
* backwards total is refused; a monotonic {@code syncSeq} dedups replays). The cap is enforced at
* the instance gate, not here. Gated behind {@code account-link.enabled}.
*/
@Slf4j
@Service
@@ -19,9 +19,9 @@ import lombok.Setter;
/**
* Last-seen cumulative usage a linked self-hosted instance has reported for one {@code (team,
* billing period, category)} (combined-billing "Mode A"). The instance reports monotonic cumulative
* unit totals on its daily sync; SaaS bills {@code reportedCumulative - lastCumulativeUnits} via
* the standard charge path and advances this row. {@code lastSyncSeq} dedups replays.
* billing period, category)} (combined billing). The instance reports monotonic cumulative unit
* totals on its daily sync; SaaS bills {@code reportedCumulative - lastCumulativeUnits} via the
* standard charge path and advances this row. {@code lastSyncSeq} dedups replays.
*/
@Entity
@Table(
@@ -16,6 +16,7 @@ import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Profile;
import org.springframework.core.annotation.Order;
import org.springframework.core.env.Environment;
import org.springframework.http.HttpMethod;
import org.springframework.security.authentication.AbstractAuthenticationToken;
import org.springframework.security.config.Customizer;
@@ -71,6 +72,7 @@ public class SupabaseSecurityConfig {
private final SaasTeamService saasTeamService;
private final ApplicationProperties applicationProperties;
private final ApiKeyAuthenticationService apiKeyAuthenticationService;
private final Environment environment;
@Value("${app.supabase.issuer:}")
private String issuer;
@@ -105,6 +107,17 @@ public class SupabaseSecurityConfig {
.permitAll()
.requestMatchers("/actuator/health", "/api/v1/config/**")
.permitAll()
// Account-link connect handshake: an instance calls these
// before it holds any credential, so there is nothing to
// authenticate with yet. Neither grants anything on its
// own — /request records an intent a human must approve,
// and /claim requires a secret only the instance that
// created the request has ever held.
.requestMatchers(
HttpMethod.POST,
"/api/v1/account-link/connect/request",
"/api/v1/account-link/connect/claim")
.permitAll()
.requestMatchers(
req ->
RequestUriUtils.isStaticResource(
@@ -144,7 +157,7 @@ public class SupabaseSecurityConfig {
SupabaseSecurityConfig
::toAuthentication)));
// Device-credential auth for linked self-hosted instances (combined-billing Mode A).
// Device-credential auth for linked self-hosted instances (combined billing).
// The filter bean exists only when stirling.billing.account-link.enabled=true; when off it
// is absent here, so the instance surface cannot authenticate at all until release.
DeviceCredentialAuthenticationFilter deviceFilter =
@@ -268,6 +281,28 @@ public class SupabaseSecurityConfig {
}
}
/**
* Loopback on any port, as Spring origin patterns. Only added outside production; see {@link
* #corsConfigurationSource()}.
*/
private static final List<String> LOOPBACK_ANY_PORT =
List.of("http://localhost:[*]", "http://127.0.0.1:[*]");
/**
* Profiles that mean "a developer's machine or a preview environment", never the production
* deployment. Production runs the bare {@code saas} profile.
*/
private static final List<String> NON_PRODUCTION_PROFILES = List.of("dev", "staging", "local");
private boolean isNonProduction() {
for (String profile : environment.getActiveProfiles()) {
if (NON_PRODUCTION_PROFILES.contains(profile)) {
return true;
}
}
return false;
}
@Bean
CorsConfigurationSource corsConfigurationSource() {
CorsConfiguration cfg = new CorsConfiguration();
@@ -297,7 +332,23 @@ public class SupabaseSecurityConfig {
origins.add(desktopOrigin);
}
}
if (origins.stream().anyMatch(o -> o.contains("*"))) {
// Outside production, allow loopback on ANY port. Several dev servers run side by side
// (editor, saas web app, one per flavour under test) and their ports move, so pinning a
// list means every new local environment shows up as an opaque CORS failure. Unlike a
// wildcard subdomain, a wildcard port on loopback cannot be taken over: nothing but this
// machine can answer on it, so there is no lapsed-DNS or abandoned-vhost risk. Absent in
// production, where the profile check below is false.
if (!operatorOverride && isNonProduction()) {
origins.addAll(LOOPBACK_ANY_PORT);
log.info(
"Non-production profile active: allowing loopback CORS origins on any port {}",
LOOPBACK_ANY_PORT);
}
// Loopback port wildcards are exempt: the warning below is about hostname takeover, which
// does not apply to an origin only this machine can serve.
if (origins.stream()
.filter(o -> !LOOPBACK_ANY_PORT.contains(o))
.anyMatch(o -> o.contains("*"))) {
log.warn(
"CORS origins contain a wildcard paired with allowCredentials=true: {}."
+ " Wildcard subdomains can be taken over by an attacker (lapsed DNS,"
@@ -519,8 +519,8 @@ public class SaasTeamService {
* membership and its wallet) rather than deleting it, so a plain team is never orphaned. The
* only real hazard is a team the user is the <em>last</em> leader of that still carries live
* billing: an active paid/PAYG subscription, or a non-revoked linked self-hosted instance
* ("Mode A"). Those block the join until the plan is cancelled / leadership transferred /
* instances revoked. An unpaid, unlinked team (personal or shared) no longer blocks.
* (combined billing). Those block the join until the plan is cancelled / leadership transferred
* / instances revoked. An unpaid, unlinked team (personal or shared) no longer blocks.
*
* <p>The home team and the team being joined are excluded: neither is left by the join (home is
* parked, the joined team is kept), so their live billing cannot be stranded.
@@ -1,6 +1,7 @@
package stirling.software.saas.accountlink;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyNoInteractions;
import static org.mockito.Mockito.when;
@@ -23,8 +24,7 @@ import stirling.software.proprietary.model.TeamMembership;
import stirling.software.proprietary.security.database.repository.UserRepository;
import stirling.software.proprietary.security.model.User;
import stirling.software.proprietary.security.repository.TeamMembershipRepository;
import stirling.software.saas.accountlink.AccountLinkController.RegisterRequest;
import stirling.software.saas.accountlink.AccountLinkController.RegisterResponse;
import stirling.software.saas.accountlink.AccountLinkController.InstanceRow;
import stirling.software.saas.util.AuthenticationUtils;
/**
@@ -44,20 +44,27 @@ class AccountLinkControllerTest {
@BeforeEach
void setUp() {
controller = new AccountLinkController(service, memberRepo, userRepository);
// Real resolver over the mocked repositories: the leader ladder moved into
// LeaderTeamResolver, and these tests are still asserting that ladder's behaviour
// through the controller.
controller =
new AccountLinkController(
service, new LeaderTeamResolver(memberRepo, userRepository));
auth =
new AnonymousAuthenticationToken(
"k", "anonymousUser", List.of(new SimpleGrantedAuthority("ROLE_USER")));
}
// The leader ladder used to be asserted through POST /register, which has been removed along
// with the JWT relay. It is exercised through /instances instead: same resolver, same rungs.
@Test
void register_unauthenticated_returns401() {
void list_unauthenticated_returns401() {
try (var mocked = org.mockito.Mockito.mockStatic(AuthenticationUtils.class)) {
mocked.when(() -> AuthenticationUtils.getCurrentUser(auth, userRepository))
.thenThrow(new SecurityException("not authenticated"));
ResponseEntity<RegisterResponse> resp =
controller.register(new RegisterRequest("host"), auth);
ResponseEntity<List<InstanceRow>> resp = controller.list(auth);
assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED);
verifyNoInteractions(service);
@@ -65,14 +72,14 @@ class AccountLinkControllerTest {
}
@Test
void register_noMembership_returns403() {
void list_noMembership_returns403() {
User user = mockUser(42L);
try (var mocked = org.mockito.Mockito.mockStatic(AuthenticationUtils.class)) {
mocked.when(() -> AuthenticationUtils.getCurrentUser(auth, userRepository))
.thenReturn(user);
when(memberRepo.findPrimaryMembership(42L)).thenReturn(List.of());
ResponseEntity<RegisterResponse> resp = controller.register(null, auth);
ResponseEntity<List<InstanceRow>> resp = controller.list(auth);
assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.FORBIDDEN);
verifyNoInteractions(service);
@@ -80,7 +87,7 @@ class AccountLinkControllerTest {
}
@Test
void register_nonLeader_returns403() {
void list_nonLeader_returns403() {
User user = mockUser(42L);
TeamMembership member = membership(7L, TeamRole.MEMBER);
try (var mocked = org.mockito.Mockito.mockStatic(AuthenticationUtils.class)) {
@@ -88,7 +95,7 @@ class AccountLinkControllerTest {
.thenReturn(user);
when(memberRepo.findPrimaryMembership(42L)).thenReturn(List.of(member));
ResponseEntity<RegisterResponse> resp = controller.register(null, auth);
ResponseEntity<List<InstanceRow>> resp = controller.list(auth);
assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.FORBIDDEN);
verifyNoInteractions(service);
@@ -96,27 +103,20 @@ class AccountLinkControllerTest {
}
@Test
void register_leader_mintsCredentialForCallerTeam() {
void list_leader_readsOnlyTheCallersTeam() {
User user = mockUser(42L);
TeamMembership leader = membership(7L, TeamRole.LEADER);
when(service.register(7L, 42L, "host"))
.thenReturn(
new AccountLinkService.RegisteredInstance(99L, "dev-x", "sec-x", "host"));
when(service.list(7L)).thenReturn(List.of());
try (var mocked = org.mockito.Mockito.mockStatic(AuthenticationUtils.class)) {
mocked.when(() -> AuthenticationUtils.getCurrentUser(auth, userRepository))
.thenReturn(user);
when(memberRepo.findPrimaryMembership(42L)).thenReturn(List.of(leader));
ResponseEntity<RegisterResponse> resp =
controller.register(new RegisterRequest("host"), auth);
ResponseEntity<List<InstanceRow>> resp = controller.list(auth);
assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.CREATED);
RegisterResponse body = resp.getBody();
assertThat(body).isNotNull();
// Team comes from the caller's membership and is surfaced in the response.
assertThat(body.teamId()).isEqualTo(7L);
assertThat(body.instanceId()).isEqualTo(99L);
assertThat(body.deviceSecret()).isEqualTo("sec-x");
assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.OK);
// The team comes from the caller's membership, never from the request.
verify(service).list(7L);
}
}
@@ -0,0 +1,129 @@
package stirling.software.saas.accountlink;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.isNull;
import static org.mockito.Mockito.when;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.mockito.junit.jupiter.MockitoSettings;
import org.mockito.quality.Strictness;
import org.springframework.mock.web.MockHttpServletRequest;
import stirling.software.common.model.ApplicationProperties;
import stirling.software.saas.accountlink.ConnectController.CreateBody;
import stirling.software.saas.accountlink.ConnectController.CreateResponse;
/**
* The authorize URL the instance is told to send its admin to. Everything else on this controller
* delegates; this is the only decision it makes on its own.
*/
@ExtendWith(MockitoExtension.class)
@MockitoSettings(strictness = Strictness.LENIENT)
class ConnectControllerTest {
private static final CreateBody BODY =
new CreateBody("prod-1", "https://pdf.example.com/account-link/callback", "n", "s");
@Mock private ConnectRequestService service;
@Mock private LeaderTeamResolver leaderTeams;
@Mock private AccountLinkService accountLinkService;
private ApplicationProperties applicationProperties;
private ConnectController controller;
@BeforeEach
void setUp() {
applicationProperties = new ApplicationProperties();
controller =
new ConnectController(
service, leaderTeams, accountLinkService, applicationProperties);
when(service.create(anyString(), anyString(), anyString(), anyString(), any()))
.thenReturn(ConnectRequestService.CreateResult.ok("req-1", 1800));
}
private String authorizeUrl(MockHttpServletRequest request) {
Object body = controller.request(BODY, request).getBody();
assertThat(body).isInstanceOf(CreateResponse.class);
return ((CreateResponse) body).authorizeUrl();
}
private static MockHttpServletRequest request(String scheme, String host, int port) {
MockHttpServletRequest request = new MockHttpServletRequest();
request.setScheme(scheme);
request.setServerName(host);
request.setServerPort(port);
return request;
}
@Test
void prefersTheConfiguredFrontendUrl() {
applicationProperties.getSystem().setFrontendUrl("https://app.example.com/app/");
// Trailing slash trimmed, base path kept, and the API's own origin ignored.
assertThat(authorizeUrl(request("https", "api.example.com", 443)))
.isEqualTo("https://app.example.com/app/link?request=req-1");
}
@Test
void fallsBackToTheOriginTheApiWasReachedOn() {
assertThat(authorizeUrl(request("https", "api.example.com", 443)))
.isEqualTo("https://api.example.com/link?request=req-1");
}
@Test
void keepsANonDefaultPortAndTheContextPath() {
MockHttpServletRequest request = request("http", "localhost", 8081);
request.setContextPath("/stirling");
assertThat(authorizeUrl(request))
.isEqualTo("http://localhost:8081/stirling/link?request=req-1");
}
@Test
void honoursTheForwardedSchemeAndHost() {
MockHttpServletRequest request = request("http", "10.0.0.5", 8080);
request.addHeader("X-Forwarded-Proto", "https");
request.addHeader("X-Forwarded-Host", "api.example.com");
assertThat(authorizeUrl(request)).isEqualTo("https://api.example.com/link?request=req-1");
}
@Test
void takesOnlyTheFirstForwardedHop() {
MockHttpServletRequest request = request("http", "10.0.0.5", 8080);
request.addHeader("X-Forwarded-Proto", "https, http");
request.addHeader("X-Forwarded-Host", "api.example.com, evil.example.com");
assertThat(authorizeUrl(request)).isEqualTo("https://api.example.com/link?request=req-1");
}
@Test
void percentEncodesTheRequestId() {
when(service.create(anyString(), anyString(), anyString(), anyString(), any()))
.thenReturn(ConnectRequestService.CreateResult.ok("a b&c", 1800));
assertThat(authorizeUrl(request("https", "api.example.com", 443)))
.isEqualTo("https://api.example.com/link?request=a+b%26c");
}
@Test
void aBodylessRequestIsRejectedBeforeAnythingIsRecorded() {
assertThat(controller.request(null, request("https", "api.example.com", 443)).getBody())
.isEqualTo(java.util.Map.of("error", "BAD_REQUEST"));
}
@Test
void offeringNoCredentialTakesTheFirstLinkPath() {
authorizeUrl(request("https", "api.example.com", 443));
// createReauth is the credentialled path; a first link must not reach it.
org.mockito.Mockito.verify(service, org.mockito.Mockito.never())
.createReauth(anyString(), anyString(), anyString(), anyString(), any(), isNull());
}
}
@@ -0,0 +1,358 @@
package stirling.software.saas.accountlink;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyNoInteractions;
import static org.mockito.Mockito.when;
import java.time.LocalDateTime;
import java.util.Optional;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ValueSource;
import org.mockito.ArgumentCaptor;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.mockito.junit.jupiter.MockitoSettings;
import org.mockito.quality.Strictness;
import stirling.software.saas.accountlink.ConnectRequestService.ClaimOutcome;
import stirling.software.saas.accountlink.ConnectRequestService.CreateRejection;
/**
* Unit tests for the connect handshake's security properties, which are the reason this flow is
* safe rather than an open redirect: the callback is validated once and then read back from
* storage, the claim secret authenticates the collection, and one approval mints exactly one
* credential.
*/
@ExtendWith(MockitoExtension.class)
@MockitoSettings(strictness = Strictness.LENIENT)
class ConnectRequestServiceTest {
private static final String CALLBACK = "https://pdf.example.com/account-link/callback";
private static final String NONCE = "nonce-value";
private static final String CLAIM_SECRET = "claim-secret-value";
@Mock private ConnectRequestRepository repo;
@Mock private AccountLinkService accountLinkService;
private ConnectRequestService service;
@BeforeEach
void setUp() {
service = new ConnectRequestService(repo, accountLinkService);
}
@Test
void create_storesTheValidatedCallbackAndItsOrigin() {
ConnectRequestService.CreateResult result =
service.create("prod-1", CALLBACK, NONCE, CLAIM_SECRET, "10.0.0.1");
assertThat(result.isRejected()).isFalse();
assertThat(result.requestId()).isNotBlank();
ArgumentCaptor<ConnectRequest> saved = ArgumentCaptor.forClass(ConnectRequest.class);
verify(repo).save(saved.capture());
ConnectRequest row = saved.getValue();
assertThat(row.getCallbackUrl()).isEqualTo(CALLBACK);
assertThat(row.getCallbackOrigin()).isEqualTo("https://pdf.example.com");
assertThat(row.getStatus()).isEqualTo(ConnectRequest.Status.PENDING);
assertThat(row.getName()).isEqualTo("prod-1");
// The claim secret is only ever stored as a hash.
assertThat(row.getClaimSecretHash()).isNotEqualTo(CLAIM_SECRET).hasSize(64);
}
@Test
void create_keepsANonDefaultPortInTheOrigin() {
service.create(
null, "http://pdf.internal:8080/account-link/callback", NONCE, CLAIM_SECRET, null);
ArgumentCaptor<ConnectRequest> saved = ArgumentCaptor.forClass(ConnectRequest.class);
verify(repo).save(saved.capture());
assertThat(saved.getValue().getCallbackOrigin()).isEqualTo("http://pdf.internal:8080");
}
@ParameterizedTest
@ValueSource(
strings = {
"/account-link/callback", // not absolute
"ftp://pdf.example.com/cb", // wrong scheme
"javascript:alert(1)", // not a hierarchical http(s) URL
"https://user:pw@pdf.example.com/cb", // credentials in the URL
"https://pdf.example.com/cb#already", // would collide with our fragment
"https:///cb" // no host
})
void create_refusesCallbacksWeWouldNotWantToRedirectTo(String callback) {
ConnectRequestService.CreateResult result =
service.create(null, callback, NONCE, CLAIM_SECRET, null);
assertThat(result.rejection()).isEqualTo(CreateRejection.BAD_CALLBACK);
verify(repo, never()).save(any());
}
@Test
void create_refusesAMissingNonce() {
assertThat(service.create(null, CALLBACK, " ", CLAIM_SECRET, null).rejection())
.isEqualTo(CreateRejection.BAD_NONCE);
verify(repo, never()).save(any());
}
@Test
void create_namesTheSecretWhenTheSecretIsWhatIsMissing() {
assertThat(service.create(null, CALLBACK, NONCE, " ", null).rejection())
.isEqualTo(CreateRejection.BAD_SECRET);
verify(repo, never()).save(any());
}
@Test
void create_isCappedPerSourceAddress() {
when(repo.countByRequesterIpAndCreatedAtAfter(anyString(), any()))
.thenReturn((long) ConnectRequestService.MAX_REQUESTS_PER_IP);
ConnectRequestService.CreateResult result =
service.create(null, CALLBACK, NONCE, CLAIM_SECRET, "10.0.0.1");
assertThat(result.rejection()).isEqualTo(CreateRejection.RATE_LIMITED);
verify(repo, never()).save(any());
}
@Test
void lookup_flagsPlaintextTransportSoTheApproverCanSeeIt() {
ConnectRequest row = pending();
row.setCallbackOrigin("http://pdf.internal:8080");
when(repo.findByRequestId("req")).thenReturn(Optional.of(row));
assertThat(service.lookup("req")).get().extracting("insecureTransport").isEqualTo(true);
}
@Test
void lookup_hidesAnExpiredHandshake() {
ConnectRequest row = pending();
row.setExpiresAt(LocalDateTime.now().minusMinutes(1));
when(repo.findByRequestId("req")).thenReturn(Optional.of(row));
assertThat(service.lookup("req")).isEmpty();
}
@Test
void approve_bindsTheTeamAndReturnsTheStoredCallback() {
ConnectRequest row = pending();
when(repo.findByRequestIdForUpdate("req")).thenReturn(Optional.of(row));
ConnectRequestService.ApproveResult result = service.approve("req", 7L, 42L);
assertThat(result.isRejected()).isFalse();
// The destination comes from the row, never from the caller.
assertThat(result.target().callbackUrl()).isEqualTo(CALLBACK);
assertThat(result.target().nonce()).isEqualTo(NONCE);
assertThat(row.getStatus()).isEqualTo(ConnectRequest.Status.APPROVED);
assertThat(row.getTeamId()).isEqualTo(7L);
assertThat(row.getApprovedByUserId()).isEqualTo(42L);
// Approval on its own must not mint anything.
verifyNoInteractions(accountLinkService);
}
@Test
void approve_isSingleUse() {
ConnectRequest row = pending();
row.setStatus(ConnectRequest.Status.APPROVED);
when(repo.findByRequestIdForUpdate("req")).thenReturn(Optional.of(row));
assertThat(service.approve("req", 7L, 42L).isRejected()).isTrue();
}
@Test
void approve_refusesAnExpiredHandshake() {
ConnectRequest row = pending();
row.setExpiresAt(LocalDateTime.now().minusSeconds(1));
when(repo.findByRequestIdForUpdate("req")).thenReturn(Optional.of(row));
assertThat(service.approve("req", 7L, 42L).isRejected()).isTrue();
}
@Test
void createReauth_pinsTheTeamItWasToldByTheCredential() {
ConnectRequestService.CreateResult result =
service.createReauth(null, CALLBACK, NONCE, CLAIM_SECRET, null, 7L);
assertThat(result.isRejected()).isFalse();
ArgumentCaptor<ConnectRequest> saved = ArgumentCaptor.forClass(ConnectRequest.class);
verify(repo).save(saved.capture());
assertThat(saved.getValue().getMode()).isEqualTo(ConnectRequest.Mode.REAUTH);
assertThat(saved.getValue().getTeamId()).isEqualTo(7L);
}
@Test
void createReauth_withoutAnAuthenticatedInstanceIsRefused() {
// The controller passes null when the offered device credential did not authenticate.
assertThat(
service.createReauth(null, CALLBACK, NONCE, CLAIM_SECRET, null, null)
.rejection())
.isEqualTo(CreateRejection.NOT_LINKED);
verify(repo, never()).save(any());
}
@Test
void create_leavesTheTeamOpenForAFirstLink() {
service.create("n", CALLBACK, NONCE, CLAIM_SECRET, null);
ArgumentCaptor<ConnectRequest> saved = ArgumentCaptor.forClass(ConnectRequest.class);
verify(repo).save(saved.capture());
assertThat(saved.getValue().getMode()).isEqualTo(ConnectRequest.Mode.LINK);
// Approval is what decides the team on a first link.
assertThat(saved.getValue().getTeamId()).isNull();
}
@Test
void approve_refusesAnApproverFromADifferentTeam() {
ConnectRequest row = reauthPinnedTo(7L);
when(repo.findByRequestIdForUpdate("req")).thenReturn(Optional.of(row));
ConnectRequestService.ApproveResult result = service.approve("req", 99L, 42L);
// This is the "signed in to the wrong account" case, and it must not silently rebind.
assertThat(result.rejection()).isEqualTo(ConnectRequestService.ApproveRejection.WRONG_TEAM);
assertThat(row.getStatus()).isEqualTo(ConnectRequest.Status.PENDING);
assertThat(row.getTeamId()).isEqualTo(7L);
}
@Test
void approve_acceptsTheTeamTheServerAlreadyBelongsTo() {
ConnectRequest row = reauthPinnedTo(7L);
when(repo.findByRequestIdForUpdate("req")).thenReturn(Optional.of(row));
assertThat(service.approve("req", 7L, 42L).isRejected()).isFalse();
assertThat(row.getStatus()).isEqualTo(ConnectRequest.Status.APPROVED);
}
@Test
void claim_onAReauthConfirmsWithoutMintingASecondCredential() {
ConnectRequest row = reauthPinnedTo(7L);
row.setStatus(ConnectRequest.Status.APPROVED);
row.setApprovedByUserId(42L);
when(repo.findByRequestIdForUpdate("req")).thenReturn(Optional.of(row));
ConnectRequestService.ClaimResult result = service.claim("req", CLAIM_SECRET);
assertThat(result.outcome()).isEqualTo(ClaimOutcome.CONFIRMED);
assertThat(result.deviceId()).isNull();
assertThat(result.deviceSecret()).isNull();
assertThat(result.teamId()).isEqualTo(7L);
assertThat(row.getStatus()).isEqualTo(ConnectRequest.Status.CONSUMED);
// A second credential would orphan the one the instance already holds.
verifyNoInteractions(accountLinkService);
}
@Test
void claim_mintsOnceForAnApprovedHandshake() {
ConnectRequest row = approved();
when(repo.findByRequestIdForUpdate("req")).thenReturn(Optional.of(row));
when(accountLinkService.register(anyLong(), anyLong(), any()))
.thenReturn(
new AccountLinkService.RegisteredInstance(9L, "dev-id", "dev-secret", "n"));
ConnectRequestService.ClaimResult result = service.claim("req", CLAIM_SECRET);
assertThat(result.outcome()).isEqualTo(ClaimOutcome.GRANTED);
assertThat(result.deviceId()).isEqualTo("dev-id");
assertThat(result.deviceSecret()).isEqualTo("dev-secret");
assertThat(result.teamId()).isEqualTo(7L);
assertThat(row.getStatus()).isEqualTo(ConnectRequest.Status.CONSUMED);
verify(accountLinkService).register(7L, 42L, "n");
}
@Test
void claim_refusesASecondCollection() {
ConnectRequest row = approved();
row.setStatus(ConnectRequest.Status.CONSUMED);
when(repo.findByRequestIdForUpdate("req")).thenReturn(Optional.of(row));
assertThat(service.claim("req", CLAIM_SECRET).outcome()).isEqualTo(ClaimOutcome.REJECTED);
verifyNoInteractions(accountLinkService);
}
@Test
void claim_withTheWrongSecretMintsNothing() {
ConnectRequest row = approved();
when(repo.findByRequestIdForUpdate("req")).thenReturn(Optional.of(row));
assertThat(service.claim("req", "not-the-secret").outcome())
.isEqualTo(ClaimOutcome.REJECTED);
assertThat(row.getStatus()).isEqualTo(ConnectRequest.Status.APPROVED);
verifyNoInteractions(accountLinkService);
}
@Test
void claim_beforeApprovalTellsTheInstanceToWait() {
when(repo.findByRequestIdForUpdate("req")).thenReturn(Optional.of(pending()));
assertThat(service.claim("req", CLAIM_SECRET).outcome()).isEqualTo(ClaimOutcome.PENDING);
verifyNoInteractions(accountLinkService);
}
@Test
void claim_afterDenialIsTerminal() {
ConnectRequest row = pending();
row.setStatus(ConnectRequest.Status.DENIED);
when(repo.findByRequestIdForUpdate("req")).thenReturn(Optional.of(row));
assertThat(service.claim("req", CLAIM_SECRET).outcome()).isEqualTo(ClaimOutcome.REJECTED);
verifyNoInteractions(accountLinkService);
}
@Test
void claim_onAnExpiredHandshakeMintsNothing() {
ConnectRequest row = approved();
row.setExpiresAt(LocalDateTime.now().minusSeconds(1));
when(repo.findByRequestIdForUpdate("req")).thenReturn(Optional.of(row));
assertThat(service.claim("req", CLAIM_SECRET).outcome()).isEqualTo(ClaimOutcome.REJECTED);
verifyNoInteractions(accountLinkService);
}
@Test
void claim_forAnUnknownIdLooksTheSameAsABadSecret() {
when(repo.findByRequestIdForUpdate("nope")).thenReturn(Optional.empty());
assertThat(service.claim("nope", CLAIM_SECRET).outcome()).isEqualTo(ClaimOutcome.REJECTED);
}
// ---------------------------------------------------------------------------------------
private static ConnectRequest pending() {
ConnectRequest row = new ConnectRequest();
row.setRequestId("req");
row.setName("n");
row.setCallbackUrl(CALLBACK);
row.setCallbackOrigin("https://pdf.example.com");
row.setNonce(NONCE);
row.setClaimSecretHash(AccountLinkService.sha256Hex(CLAIM_SECRET));
row.setStatus(ConnectRequest.Status.PENDING);
row.setExpiresAt(LocalDateTime.now().plusMinutes(10));
return row;
}
/** A re-authentication whose team came from the instance's credential, not from a browser. */
private static ConnectRequest reauthPinnedTo(Long teamId) {
ConnectRequest row = pending();
row.setMode(ConnectRequest.Mode.REAUTH);
row.setTeamId(teamId);
return row;
}
private static ConnectRequest approved() {
ConnectRequest row = pending();
row.setStatus(ConnectRequest.Status.APPROVED);
row.setTeamId(7L);
row.setApprovedByUserId(42L);
row.setApprovedAt(LocalDateTime.now());
return row;
}
}
@@ -14,6 +14,8 @@ import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.core.env.Environment;
import org.springframework.mock.env.MockEnvironment;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.oauth2.jwt.Jwt;
import org.springframework.security.oauth2.jwt.JwtDecoder;
@@ -48,13 +50,19 @@ class SupabaseSecurityConfigMoreTest {
apiKeyAuthenticationService;
private SupabaseSecurityConfig config(ApplicationProperties props) {
return config(props, new MockEnvironment());
}
/** Loopback CORS origins are only added outside production, so the environment decides. */
private SupabaseSecurityConfig config(ApplicationProperties props, Environment environment) {
return new SupabaseSecurityConfig(
userService,
teamService,
supabaseUserService,
saasTeamService,
props,
apiKeyAuthenticationService);
apiKeyAuthenticationService,
environment);
}
@Nested
@@ -204,6 +212,50 @@ class SupabaseSecurityConfigMoreTest {
.hasSize(1);
}
@Test
@DisplayName("production does not allow loopback on arbitrary ports")
void productionHasNoLoopbackWildcard() {
CorsConfiguration cfg =
cors(config(new ApplicationProperties()).corsConfigurationSource());
assertThat(cfg.getAllowedOriginPatterns())
.doesNotContain("http://localhost:[*]", "http://127.0.0.1:[*]");
}
@Test
@DisplayName("non-production allows loopback on any port so dev servers can move")
void devAllowsAnyLoopbackPort() {
// Several dev servers run side by side and their ports change; pinning a list turns
// every new local environment into an opaque CORS failure.
MockEnvironment dev = new MockEnvironment();
dev.setActiveProfiles("saas", "dev");
CorsConfiguration cfg =
cors(config(new ApplicationProperties(), dev).corsConfigurationSource());
assertThat(cfg.getAllowedOriginPatterns())
.contains("http://localhost:[*]", "http://127.0.0.1:[*]")
// Still credentialed, which is the reason the pattern form matters.
.contains("https://stirling.com");
assertThat(cfg.getAllowCredentials()).isTrue();
}
@Test
@DisplayName("an operator origin list is respected verbatim even in dev")
void operatorOverrideSuppressesLoopbackWildcard() {
ApplicationProperties props = new ApplicationProperties();
props.getSystem().setCorsAllowedOrigins(List.of("https://custom.example.com"));
MockEnvironment dev = new MockEnvironment();
dev.setActiveProfiles("saas", "dev");
CorsConfiguration cfg = cors(config(props, dev).corsConfigurationSource());
// An operator who set the list meant it; we do not widen it behind their back.
assertThat(cfg.getAllowedOriginPatterns())
.contains("https://custom.example.com")
.doesNotContain("http://localhost:[*]");
}
@Test
@DisplayName("operator override replaces the default origin list")
void operatorOverrideUsed() {
+1 -1
View File
@@ -34,7 +34,7 @@ ext {
commonsIoVersion = "2.22.0"
commonsLang3 = "3.20.0"
rhinoVersion = "1.9.1"
okhttpBomVersion = "5.3.2"
okhttpBomVersion = "5.4.0"
gsonVersion = "2.14.0"
guavaVersion = "33.6.0-jre"
jinjavaVersion = "2.8.4"
+1 -1
View File
@@ -4,7 +4,7 @@ ARG BASE_VERSION=1.0.2@sha256:c7698687f486707ddef9e0298587ca8b44c4e96185e1bdb0c3
ARG BASE_IMAGE=stirlingtools/stirling-pdf-base:${BASE_VERSION}
# Stage 1: Build the Java application (backend only, no frontend)
FROM gradle:9.7.1-jdk25@sha256:a80276ab804c348989df46016e2b5d58cad07c5b29e06f2112434d28ca5b2844 AS app-build
FROM gradle:9.7.1-jdk25@sha256:d868117760a7c92214705f47ed173116a5d13e58d68702f974ff30acd062737e AS app-build
# JDK 25+: --add-exports is no longer accepted via JAVA_TOOL_OPTIONS; use JDK_JAVA_OPTIONS instead
ENV JDK_JAVA_OPTIONS="--add-exports=jdk.compiler/com.sun.tools.javac.api=ALL-UNNAMED \
+1 -1
View File
@@ -5,7 +5,7 @@ ARG BASE_VERSION=1.0.2@sha256:c7698687f486707ddef9e0298587ca8b44c4e96185e1bdb0c3
ARG BASE_IMAGE=stirlingtools/stirling-pdf-base:${BASE_VERSION}
# Stage 1: Build the Java application and frontend
FROM gradle:9.7.1-jdk25@sha256:a80276ab804c348989df46016e2b5d58cad07c5b29e06f2112434d28ca5b2844 AS app-build
FROM gradle:9.7.1-jdk25@sha256:d868117760a7c92214705f47ed173116a5d13e58d68702f974ff30acd062737e AS app-build
ARG TASK_VERSION=3.52.0
RUN apt-get update \
+1 -1
View File
@@ -8,7 +8,7 @@ ARG BASE_VERSION=1.0.2@sha256:c7698687f486707ddef9e0298587ca8b44c4e96185e1bdb0c3
ARG BASE_IMAGE=stirlingtools/stirling-pdf-base:${BASE_VERSION}
# Stage 1: Build the Java application and frontend
FROM gradle:9.7.1-jdk25@sha256:a80276ab804c348989df46016e2b5d58cad07c5b29e06f2112434d28ca5b2844 AS app-build
FROM gradle:9.7.1-jdk25@sha256:d868117760a7c92214705f47ed173116a5d13e58d68702f974ff30acd062737e AS app-build
ARG TASK_VERSION=3.52.0
RUN apt-get update \
+1 -1
View File
@@ -4,7 +4,7 @@
# Single JAR contains both frontend and backend with minimal dependencies
# Stage 1: Build application with embedded frontend
FROM gradle:9.7.1-jdk25@sha256:a80276ab804c348989df46016e2b5d58cad07c5b29e06f2112434d28ca5b2844 AS build
FROM gradle:9.7.1-jdk25@sha256:d868117760a7c92214705f47ed173116a5d13e58d68702f974ff30acd062737e AS build
# Install Node.js and npm for frontend build
ARG TASK_VERSION=3.52.0
@@ -41,7 +41,7 @@ false = "خطأ"
fileNotSavedToDisk = "لم يُحفَظ على القرص"
fileSavedToDisk = "تم حفظ الملف على القرص"
fileSelected = "المحدد: {{filename}}"
filesSelected = "الملفات المحددة"
filesSelected = "{{count}} ملفات"
font = "الخط"
fontSizeTooltip = "حجم نص رقم الصفحة بالنقاط. الأرقام الأكبر تنتج نصًا أكبر."
fontTypeTooltip = "عائلة الخط لأرقام الصفحات. اختر بناءً على نمط مستندك."
@@ -3969,6 +3969,7 @@ back = "رجوع"
backToFolder = "الرجوع إلى {{folder}}"
backToMyFiles = "الرجوع إلى ملفاتي"
breadcrumbs = "مسار المجلد"
bulkActions = "الإجراءات"
cancel = "إلغاء"
classification = "التصنيف"
clearSelection = "مسح التحديد"
@@ -4142,6 +4143,11 @@ totalSize = "الحجم الإجمالي"
type = "النوع"
versionHistory = "رحلة الإصدارات"
[filesPage.filters]
activeCount = "{{count}} عوامل تصفية نشطة"
clearAll = "مسح عوامل التصفية"
label = "عوامل التصفية"
[filesPage.folderName]
cancel = "إلغاء"
error = "تعذّر حفظ المجلد. حاول مرة أخرى."
@@ -4176,6 +4182,7 @@ label = "Filter files by name"
placeholder = "Filter files…"
[filesPage.sort]
label = "فرز الملفات"
modifiedAsc = "الأقدم أولاً"
modifiedDesc = "الأحدث أولاً"
nameAsc = "الاسم A→Z"
@@ -4305,11 +4312,14 @@ issues = "GitHub"
[formFill]
allSaved = "تم حفظ الكل"
analyzingFields = "Analysing form fields..."
applyFailed = "تعذّر تطبيق التغييرات"
extractCsvError = "فشل استخراج CSV"
extractXlsxError = "فشل استخراج XLSX"
filled = "filled"
flattenAfterFilling = "تسطيح بعد التعبئة"
goToPage = "الانتقال إلى هذه الصفحة"
noFields = "No fillable form fields found in this PDF."
page = "الصفحة"
placeholderEnter = "Enter"
placeholderSelect = "Select"
requiredAbbreviation = "مطلوب"
@@ -4318,8 +4328,83 @@ rescanFields = "إعادة فحص الحقول"
rescanFormFields = "إعادة فحص حقول النموذج"
save = "حفظ"
saveShortcut = "Ctrl+S للحفظ"
skippedEdits_one = "تعذّر تطبيق تغيير واحد:"
skippedEdits_other = "تعذّر تطبيق {{count}} تغييرات:"
skippedEditsTruncated = "{{count}} إضافية غير مدرجة."
unsavedChanges = "تغييرات غير محفوظة"
[formFill.create]
commit = "إضافة {{count}} من الحقول إلى PDF"
empty = "لم يتم رسم أي حقول بعد."
failed = "فشل في إضافة الحقول"
goToField = "الانتقال إلى هذا الحقل"
hint = "اختر نوع الحقل، ثم ارسمه على الصفحة."
placing = "ارسم حقل {{type}} على الصفحة. اضغط على Esc للتوقف."
preview = "اضغط مطولًا للمعاينة"
previewHelp = "اضغط مطولًا لرؤية الحقول كما ستبدو بعد إضافتها، دون حدود التحرير."
removeField = "إزالة الحقل"
[formFill.editor]
action = "إجراء الزر"
actionHelp = "ما يفعله الزر عند النقر عليه."
actionNone = "لا شيء"
actionPrint = "طباعة"
actionReset = "إعادة تعيين النموذج"
actionSubmit = "إرسال إلى URL"
actionUri = "فتح URL"
actionUrl = "URL"
actionUrlHelp = "العنوان الذي يفتحه الزر أو يرسل إليه."
addOption = "إضافة خيار"
caption = "نص الزر"
captionHelp = "النص المطبوع على واجهة الزر."
defaultValue = "القيمة الافتراضية"
defaultValueHelp = "ما يحتويه الحقل قبل أن يملأه أي شخص. اتركه فارغًا لحقل فارغ."
fontSize = "حجم الخط"
fontSizeHelp = "حجم النص داخل الحقل. اتركه فارغًا للسماح للقارئ بتغيير حجمه ليناسب المساحة."
label = "التسمية"
labelHelp = "النص المعروض لمن يملأ النموذج. اتركه فارغًا للرجوع إلى اسم الحقل."
maxLength = "الحد الأقصى للطول (comb)"
maxLengthHelp = "يحدد عدد الأحرف التي يمكن احتواؤها، مع رسمها كمربعات متباعدة بالتساوي."
multiline = "متعدد الأسطر"
multilineHelp = "يسمح بأكثر من سطر واحد من النص ويلتف عند حافة الحقل."
multiSelect = "السماح بتحديد متعدد"
multiSelectHelp = "يسمح باختيار أكثر من خيار واحد في الوقت نفسه."
name = "اسم الحقل"
nameHelp = "الاسم الداخلي للحقل. يُستخدم عند تصدير البيانات أو ملء النموذج من نظام آخر، لذا اجعله فريدًا وخاليًا من المسافات."
optionGap = "تباعد الخيارات"
optionGapHelp = "المسافة بين الأزرار، بالنقاط. اتركها فارغة لتوزيعها بالتساوي على طول المربع."
optionPlaceholder = "الخيار {{n}}"
options = "الخيارات"
optionsEmpty = "أضف خيارًا واحدًا على الأقل."
optionsHelp = "الاختيارات المعروضة في القائمة. يتم تخزين كل اختيار كما كُتب، لذا اجعلها قصيرة ومميزة."
optionSize = "حجم الخيار"
optionSizeHelp = "عرض وارتفاع كل زر، بالنقاط. اتركه فارغًا لملاءمتها مع المربع الذي رسمته."
readOnly = "للقراءة فقط"
readOnlyHelp = "يعرض قيمة لكنه يمنع أي شخص من تعديلها."
removeOption = "إزالة الخيار"
required = "مطلوب"
requiredHelp = "لا يمكن إرسال النموذج حتى يتم ملء هذا الحقل."
signatureNote = "عنصر نائب فقط - لا توقّع هنا. إنه يحدد مكان وجود التوقيع حتى يضع موقّع PDF (Adobe Acrobat، خدمة توقيع، وما إلى ذلك) التوقيع في هذا الموضع عند توقيع المستند."
tooltip = "تلميح"
tooltipHelp = "التلميح المعروض عندما يمرر شخص المؤشر فوق الحقل في قارئ PDF."
type = "النوع"
typeHelp = "نوع هذا الحقل. يؤدي تغييره إلى إعادة إنشاء الحقل، لذا لن يتم نقل قيمته الحالية."
[formFill.mode]
create = "إنشاء"
fill = "ملء"
label = "وضع محرر النماذج"
modify = "تعديل"
[formFill.modify]
commit = "حفظ {{count}} من التغييرات"
delete = "حذف"
empty = "لا يحتوي ملف PDF هذا على أي حقول نموذج بعد."
failed = "فشل في حفظ التغييرات"
groupSizeHint = "استخدم حجم الخيار"
hint = "حدد حقلًا لتعديل خصائصه، أو اسحبه على الصفحة، أو احذفه."
restore = "استعادة"
[formFill.sidebar]
close = "إغلاق الشريط الجانبي"
@@ -4585,6 +4670,10 @@ desc = "تغيير قيود المستند وأذوناته"
tags = "permissions,restrictions,rights,access control,allow,deny,printing,copying,editing,modify permissions,security settings,user rights"
title = "تغيير الأذونات"
[home.classify]
desc = "تحديد نوع هذا المستند ووضع علامة عليه."
title = "تصنيف"
[home.compare]
desc = "يقارن ويظهر الاختلافات بين مستندين PDF"
tags = "اختلاف"
@@ -5098,6 +5187,30 @@ openProcessor = "Open PDF Processor"
count = "{{remaining}} of {{total}}"
label = "Free credits"
[notifications]
empty = "لا يوجد ما يمكن الإبلاغ عنه."
handoffUnavailable = "لن يسمح هذا المتصفح للمعالج بتمرير المستند إلى المحرر. افتحه من المحرر بدلًا من ذلك."
noDocumentLinked = "هذا الفشل غير مرتبط بمستند محدد، لذا لا يوجد ما يمكن فتحه هنا."
notOnThisDevice = "هذا المستند غير موجود على هذا الجهاز، لذا لا يمكن فتحه هنا."
occurrences = "{{count}} مرات"
open = "الإشعارات"
title = "الإشعارات"
unread = "غير مقروء"
[notifications.action]
failed = "لم ينجح ذلك. حاول مرة أخرى بعد قليل."
unavailable = "غير متاح لهذا الإشعار."
[notifications.detail]
copied = "تم النسخ"
copy = "نسخ الخطأ"
less = "عرض أقل"
more = "عرض الرسالة كاملة"
[notifications.section]
earlier = "سابقًا"
new = "جديد"
[oauth.error]
message = "لم تتم المصادقة بنجاح. يمكنك إغلاق هذه النافذة والمحاولة مجدداً."
title = "فشلت المصادقة"
@@ -6735,8 +6848,8 @@ barAria = "Free PDFs remaining"
capSuffix_one = "من {{allowance}} ملفات PDF مجانية مستخدمة"
capSuffix_other = "من {{allowance}} ملفات PDF مجانية مستخدمة"
eyebrow = "تجربة Processor"
statusLabel_one = "متبقٍ {{remaining}}"
statusLabel_other = "متبقٍ {{remaining}}"
statusLabel_one = "تم استخدام {{used}}"
statusLabel_other = "تم استخدام {{used}}"
sub = "استخدم محرر PDF مجانًا. ادفع لمعالجة ملفات PDF تلقائيًا."
title_one = "عالِج {{allowance}} ملف PDF مجانًا"
title_other = "عالِج {{allowance}} ملفات PDF مجانًا"
@@ -7417,6 +7530,8 @@ acknowledge = "Acknowledge"
confirm = "Are you sure?"
dismiss = "Dismiss"
dismissSkipFile = "Skip this file"
viewFile = "عرض الملف"
viewInProcessor = "عرض في المعالج"
[portal.failures.debug]
copyJson = "Copy JSON"
@@ -7428,6 +7543,8 @@ showJson = "Show raw JSON ({{total}})"
[portal.failures.disabled]
closed = "This failure is already closed."
noDocument = "لم يتم تسجيل هذا الفشل مقابل مستند محدد، لذا لا يوجد شيء هنا لفتحه."
unattended = "تم إدخال هذا الملف بواسطة مجلد أو حاوية أو webhook، لذا لا يحتفظ به متصفح أي شخص لفتحه."
unavailable = "Not available for this failure."
[portal.failures.empty]
@@ -8716,7 +8833,9 @@ title = "المصادر"
connectSource = "توصيل مصدر"
[portal.sources.builder]
advanced = "متقدم"
back = "العودة إلى المصادر"
backToSource = "العودة إلى إعداد المصدر"
backToTypes = "كل أنواع المصادر"
cancel = "إلغاء"
chooseHint = "اختر من أين تأتي المستندات. الموصلات باللون الرمادي قادمة قريبًا."
@@ -8726,7 +8845,6 @@ create = "إنشاء مصدر"
createTitle = "اتصال بمصدر"
delete = "حذف"
editTitle = "تعديل المصدر"
enabled = "مفعل"
save = "حفظ التغييرات"
[portal.sources.builder.folderAccess]
@@ -8763,6 +8881,7 @@ consume = "استهلاك: معالجة كل ملف مرة واحدة"
snapshot = "لقطة: إعادة قراءة المجلد في كل تشغيل"
[portal.sources.networkFields.recursive]
helperText = "قم بتضمين المجلدات الفرعية إذا كانت ملفاتك منظمة داخل مجلدات متداخلة، أو راقب المستوى الأعلى فقط."
label = "عمق المجلد"
[portal.sources.networkFields.recursive.options]
@@ -8816,6 +8935,7 @@ hash = "الحجم والتاريخ والتحقق من المحتوى"
stat = "الحجم وتاريخ التعديل"
[portal.sources.types.folder.fields.mode]
helperText = "ما إذا كان سيتم ترك الملف الأصلي في مكانه بعد معالجته. إذا تُرك الملف الأصلي في مكانه، فستتم إعادة معالجته في المرة التالية التي يفحص فيها مسار المعالجة المصدر."
label = "وضع القراءة"
[portal.sources.types.folder.fields.mode.options]
@@ -8823,6 +8943,7 @@ consume = "استهلاك: معالجة كل ملف مرة واحدة"
snapshot = "لقطة: إعادة قراءة المجلد في كل تشغيل"
[portal.sources.types.folder.fields.recursive]
helperText = "قم بتضمين المجلدات الفرعية إذا كانت ملفاتك منظمة داخل مجلدات متداخلة، أو راقب المستوى الأعلى فقط."
label = "عمق المجلد"
[portal.sources.types.folder.fields.recursive.options]
@@ -9696,7 +9817,9 @@ toolNotAvailableLocally = "خادم Stirling-PDF الخاص بك غير متصل
expired = "لقد انتهت جلستك. يرجى تحديث الصفحة والمحاولة مرة أخرى"
[settings]
backToSections = "كل الإعدادات"
close = "إغلاق"
title = "الإعدادات"
[settings.ai]
documents = "المستندات و RAG"
@@ -10635,6 +10758,7 @@ ariaLabel = "Super search"
filtersAriaLabel = "Search filters"
hint = "Type to search"
placeholder = "Search Stirling"
placeholderShort = "بحث"
showLess = "Show less"
showMore = "Show {{count}} more"
@@ -10831,6 +10955,7 @@ searchPlaceholder = "ابحث عن الأدوات..."
[toolPicker.subcategories]
advancedFormatting = "تنسيق متقدم"
ai = "AI"
automation = "أتمتة"
developerTools = "أدوات المطوّر"
documentReview = "مراجعة المستند"
@@ -11335,8 +11460,11 @@ columnDefault = "العمود {{index}}"
convertToPdf = "التحويل إلى PDF"
csvStats = "{{rows}} صفوف · {{columns}} أعمدة · {{size}}"
emptyFile = "ملف فارغ"
htmlHidePreview = "إخفاء المعاينة"
htmlPreview = "معاينة HTML"
htmlPreviewMobileHidden = "تم تصميم صفحات HTML لعرض سطح المكتب، لذلك تكون المعاينة معطلة افتراضيًا هنا."
htmlPreviewWarning = "معاينة HTML — قد لا يتم تحميل الموارد الخارجية · {{size}}"
htmlShowPreview = "إظهار المعاينة على أي حال"
invalidJson = "JSON غير صالح — عرض المحتوى الخام"
lineNumbers = "أرقام الأسطر"
loading = "جارٍ التحميل..."
@@ -11683,6 +11811,7 @@ exportAll = "تصدير PDF"
exportSelected = "تصدير الصفحات المحددة"
formFill = "تعبئة النموذج"
hideToolbar = "Hide toolbar"
moreActions = "مزيد من الإجراءات"
multiTool = "الأداة المتعددة"
panMode = "وضع التحريك"
print = "طباعة PDF"
@@ -11703,6 +11832,8 @@ selectAll = "تحديد الكل"
selectByNumber = "تحديد حسب أرقام الصفحات"
selectLanguage = "اختر اللغة"
share = "مشاركة"
showAllTools = "إظهار كل الأدوات"
showFewerTools = "طي شريط الأدوات"
showToolbar = "Show toolbar"
toggleAnnotations = "تبديل ظهور الشروح"
toggleAttachments = "تبديل عرض المرفقات"
@@ -41,7 +41,7 @@ false = "Yanlış"
fileNotSavedToDisk = "Diskə yadda saxlanılmadı"
fileSavedToDisk = "Fayl diskə yadda saxlanıldı"
fileSelected = "Seçilən: {{filename}}"
filesSelected = "seçilmiş fayllar"
filesSelected = "{{count}} fayl"
font = "Şrift"
fontSizeTooltip = "Səhifə nömrəsi mətninin ölçüsü (pt). Daha böyük rəqəmlər daha böyük mətn yaradır."
fontTypeTooltip = "Səhifə nömrələri üçün şrift ailəsi. Sənəd üslubunuza uyğun seçin."
@@ -3969,6 +3969,7 @@ back = "Geri"
backToFolder = "{{folder}} qovluğuna geri"
backToMyFiles = "Mənim Fayllarıma geri"
breadcrumbs = "Qovluq yolu"
bulkActions = "Əməliyyatlar"
cancel = "Ləğv et"
classification = "Təsnifat"
clearSelection = "Seçimi təmizlə"
@@ -4142,6 +4143,11 @@ totalSize = "Ümumi ölçü"
type = "Növ"
versionHistory = "Versiya yolu"
[filesPage.filters]
activeCount = "{{count}} filtr aktivdir"
clearAll = "Filtrləri təmizlə"
label = "Filtrlər"
[filesPage.folderName]
cancel = "Ləğv et"
error = "Qovluğu saxlamaq mümkün olmadı. Yenidən cəhd edin."
@@ -4176,6 +4182,7 @@ label = "Filter files by name"
placeholder = "Filter files…"
[filesPage.sort]
label = "Faylları sırala"
modifiedAsc = "Ən köhnə əvvəlcə"
modifiedDesc = "Ən yenilər əvvəlcə"
nameAsc = "Ad A→Z"
@@ -4305,11 +4312,14 @@ issues = "GitHub"
[formFill]
allSaved = "Hamısı saxlanıldı"
analyzingFields = "Analysing form fields..."
applyFailed = "Dəyişiklikləri tətbiq etmək mümkün olmadı"
extractCsvError = "CSV çıxarmaq alınmadı"
extractXlsxError = "XLSX çıxarmaq alınmadı"
filled = "filled"
flattenAfterFilling = "Doldurduqdan sonra yastılaşdır"
goToPage = "Bu səhifəyə keç"
noFields = "No fillable form fields found in this PDF."
page = "Səhifə"
placeholderEnter = "Enter"
placeholderSelect = "Select"
requiredAbbreviation = "təl."
@@ -4318,8 +4328,83 @@ rescanFields = "Sahələri yenidən skan et"
rescanFormFields = "Forma sahələrini yenidən skan et"
save = "Saxla"
saveShortcut = "Saxlamaq üçün Ctrl+S"
skippedEdits_one = "1 dəyişiklik tətbiq edilə bilmədi:"
skippedEdits_other = "{{count}} dəyişiklik tətbiq edilə bilmədi:"
skippedEditsTruncated = "{{count}} əlavə qeyd göstərilmir."
unsavedChanges = "Saxlanılmamış dəyişikliklər"
[formFill.create]
commit = "PDF-ə {{count}} sahə əlavə et"
empty = "Hələ heç bir sahə çəkilməyib."
failed = "Sahələri əlavə etmək alınmadı"
goToField = "Bu sahəyə keç"
hint = "Sahə növünü seçin, sonra onu səhifədə çəkin."
placing = "Səhifədə {{type}} sahəsi çəkin. Dayandırmaq üçün Esc düyməsini basın."
preview = "Önizləmə üçün basılı saxlayın"
previewHelp = "Sahələrin əlavə edildikdən sonra necə görünəcəyini redaktə konturları olmadan görmək üçün basılı saxlayın."
removeField = "Sahəni sil"
[formFill.editor]
action = "Düymə əməliyyatı"
actionHelp = "Düyməyə kliklənəndə nə baş verəcəyi."
actionNone = "Heç nə"
actionPrint = "Çap et"
actionReset = "Formanı sıfırla"
actionSubmit = "URL-ə göndər"
actionUri = "URL aç"
actionUrl = "URL"
actionUrlHelp = "Düymənin açdığı və ya göndərdiyi ünvan."
addOption = "Seçim əlavə et"
caption = "Düymə başlığı"
captionHelp = "Düymənin üzərində göstərilən mətn."
defaultValue = "Standart dəyər"
defaultValueHelp = "Kimsə doldurmazdan əvvəl sahədə olan məzmun. Boş sahə üçün boş buraxın."
fontSize = "Şrift ölçüsü"
fontSizeHelp = "Sahə daxilində mətn ölçüsü. Oxuyucunun uyğunlaşdırmasına imkan vermək üçün boş buraxın."
label = "Etiket"
labelHelp = "Formanı dolduran şəxsə göstərilən mətn. Sahə adına qayıtmaq üçün boş buraxın."
maxLength = "Maks. uzunluq (comb)"
maxLengthHelp = "Neçə simvolun sığacağını məhdudlaşdırır, bərabər aralıqlı xanalar kimi çəkilir."
multiline = "Çoxsətirli"
multilineHelp = "Birdən çox mətn sətrinə icazə verir və sahənin kənarında sətirə keçirir."
multiSelect = "Çoxlu seçimə icazə ver"
multiSelectHelp = "Eyni anda birdən çox seçimin seçilməsinə imkan verir."
name = "Sahə adı"
nameHelp = "Sahənin daxili adı. Məlumat ixrac edilərkən və ya forma başqa sistemdən doldurularkən istifadə olunur, buna görə unikal və boşluqsuz saxlayın."
optionGap = "Seçim aralığı"
optionGapHelp = "Düymələr arasındakı məsafə, punktla. Onları qutuda bərabər yaymaq üçün boş buraxın."
optionPlaceholder = "Seçim {{n}}"
options = "Seçimlər"
optionsEmpty = "Ən azı bir seçim əlavə edin."
optionsHelp = "Siyahıda təqdim olunan seçimlər. Hər biri yazıldığı kimi saxlanılır, buna görə qısa və fərqli saxlayın."
optionSize = "Seçim ölçüsü"
optionSizeHelp = "Hər düymənin eni və hündürlüyü, punktla. Çəkdiyiniz qutuya uyğunlaşdırmaq üçün boş buraxın."
readOnly = "Yalnız oxuma"
readOnlyHelp = "Dəyəri göstərir, lakin heç kimin onu redaktə etməsinə imkan vermir."
removeOption = "Seçimi sil"
required = "Məcburi"
requiredHelp = "Bu sahə doldurulmayana qədər forma göndərilə bilməz."
signatureNote = "Yalnız yer tutucudur - burada imza atmırsınız. Bu, imzanın harada yerləşməli olduğunu göstərir ki, sənəd imzalananda PDF imzalayıcı (Adobe Acrobat, imzalama xidməti və s.) imzanı bu yerə yerləşdirsin."
tooltip = "İpucu"
tooltipHelp = "Kimsə PDF oxuyucusunda sahənin üzərinə gəldikdə göstərilən ipucu."
type = "Növ"
typeHelp = "Bu sahənin hansı növdə olduğunu göstərir. Dəyişdirildikdə sahə yenidən qurulur, buna görə cari dəyəri saxlanılmır."
[formFill.mode]
create = "Yarat"
fill = "Doldur"
label = "Forma redaktoru rejimi"
modify = "Dəyişdir"
[formFill.modify]
commit = "{{count}} dəyişikliyi saxla"
delete = "Sil"
empty = "Bu PDF-də hələ forma sahələri yoxdur."
failed = "Dəyişiklikləri saxlamaq alınmadı"
groupSizeHint = "Seçim ölçüsündən istifadə edin"
hint = "Xüsusiyyətlərini redaktə etmək üçün sahə seçin, onu səhifədə sürükləyin və ya silin."
restore = "Bərpa et"
[formFill.sidebar]
close = "Yan paneli bağla"
@@ -4585,6 +4670,10 @@ desc = "Sənəd məhdudiyyətlərini və icazələrini dəyişin"
tags = "permissions,restrictions,rights,access control,allow,deny,printing,copying,editing,modify permissions,security settings,user rights"
title = "İcazələri Dəyişdir"
[home.classify]
desc = "Bunun hansı növ sənəd olduğunu müəyyənləşdirin və etiketləyin."
title = "Təsnif et"
[home.compare]
desc = "2 PDF Sənədini müqayisə edir və fərqləri göstərir"
tags = "fərq"
@@ -5098,6 +5187,30 @@ openProcessor = "Open PDF Processor"
count = "{{remaining}} of {{total}}"
label = "Free credits"
[notifications]
empty = "Bildiriləcək heç nə yoxdur."
handoffUnavailable = "Bu brauzer emalçıya sənədi redaktora ötürməyə imkan verməyəcək. Bunun əvəzinə onu redaktordan açın."
noDocumentLinked = "Bu xəta konkret sənədlə əlaqəli deyil, buna görə burada açılacaq heç nə yoxdur."
notOnThisDevice = "Bu sənəd bu cihazda deyil, buna görə burada açıla bilməz."
occurrences = "{{count}} dəfə"
open = "Bildirişlər"
title = "Bildirişlər"
unread = "Oxunmamış"
[notifications.action]
failed = "Bu alınmadı. Bir azdan yenidən cəhd edin."
unavailable = "Bu bildiriş üçün əlçatan deyil."
[notifications.detail]
copied = "Kopyalandı"
copy = "Xətanı kopyala"
less = "Daha az göstər"
more = "Tam mesajı göstər"
[notifications.section]
earlier = "Əvvəlkilər"
new = "Yeni"
[oauth.error]
message = "Təsdiqləmə uğurlu olmadı. Bu pəncərəni bağlayıb yenidən cəhd edə bilərsiniz."
title = "Təsdiqləmə uğursuz oldu"
@@ -6735,8 +6848,8 @@ barAria = "Free PDFs remaining"
capSuffix_one = "{{allowance}} pulsuz PDF-dən istifadə olunub"
capSuffix_other = "{{allowance}} pulsuz PDF-dən istifadə olunub"
eyebrow = "Processor sınağı"
statusLabel_one = "{{remaining}} qalıb"
statusLabel_other = "{{remaining}} qalıb"
statusLabel_one = "{{used}} istifadə olunub"
statusLabel_other = "{{used}} istifadə olunub"
sub = "PDF Editor-dan pulsuz istifadə edin. PDF-ləri avtomatik emal etmək üçün ödəyin."
title_one = "{{allowance}} PDF-i pulsuz emal edin"
title_other = "{{allowance}} PDF-i pulsuz emal edin"
@@ -7417,6 +7530,8 @@ acknowledge = "Acknowledge"
confirm = "Are you sure?"
dismiss = "Dismiss"
dismissSkipFile = "Skip this file"
viewFile = "Fayla bax"
viewInProcessor = "Emalçıda bax"
[portal.failures.debug]
copyJson = "Copy JSON"
@@ -7428,6 +7543,8 @@ showJson = "Show raw JSON ({{total}})"
[portal.failures.disabled]
closed = "This failure is already closed."
noDocument = "Bu xəta konkret sənədə aid qeydə alınmayıb, buna görə burada açılacaq heç nə yoxdur."
unattended = "Bu fayl qovluq, bucket və ya webhook tərəfindən ötürülüb, buna görə onu açmaq üçün heç kimin brauzerində saxlanılmır."
unavailable = "Not available for this failure."
[portal.failures.empty]
@@ -8716,7 +8833,9 @@ title = "Mənbələr"
connectSource = "Mənbə qoş"
[portal.sources.builder]
advanced = "Qabaqcıl"
back = "Mənbələrə qayıt"
backToSource = "Mənbə qurulumuna qayıt"
backToTypes = "Bütün mənbə növləri"
cancel = "Ləğv et"
chooseHint = "Sənədlərin haradan gələcəyini seçin. Bozlaşdırılmış connector-lar yoldadır."
@@ -8726,7 +8845,6 @@ create = "Mənbə yarat"
createTitle = "Mənbə qoş"
delete = "Sil"
editTitle = "Mənbəni redaktə et"
enabled = "Aktivləşdirilib"
save = "Dəyişiklikləri saxla"
[portal.sources.builder.folderAccess]
@@ -8763,6 +8881,7 @@ consume = "Consume: hər faylı bir dəfə emal et"
snapshot = "Snapshot: hər icrada qovluğu yenidən oxu"
[portal.sources.networkFields.recursive]
helperText = "Fayllarınız iç-içə qovluqlarda təşkil olunubsa alt qovluqları daxil edin, ya da yalnız üst səviyyəni izləyin."
label = "Qovluq dərinliyi"
[portal.sources.networkFields.recursive.options]
@@ -8816,6 +8935,7 @@ hash = "Ölçü, tarix və məzmun yoxlaması"
stat = "Ölçü və dəyişdirilmə tarixi"
[portal.sources.types.folder.fields.mode]
helperText = "Emal edildikdən sonra orijinal faylın yerində saxlanılıb-saxlanılmayacağı. Orijinal fayl yerində saxlanılarsa, pipeline növbəti dəfə mənbəni skan etdikdə yenidən emal olunacaq."
label = "Oxuma rejimi"
[portal.sources.types.folder.fields.mode.options]
@@ -8823,6 +8943,7 @@ consume = "İstehlak: hər faylı bir dəfə emal et"
snapshot = "Snapshot: hər işə salmada qovluğu yenidən oxu"
[portal.sources.types.folder.fields.recursive]
helperText = "Fayllarınız iç-içə qovluqlarda təşkil olunubsa alt qovluqları daxil edin, ya da yalnız üst səviyyəni izləyin."
label = "Qovluq dərinliyi"
[portal.sources.types.folder.fields.recursive.options]
@@ -9696,7 +9817,9 @@ toolNotAvailableLocally = "Sizin Stirling-PDF serveriniz oflayndır və \"{{endp
expired = "Sessiyanızın vaxtı bitdi. Səhifəni yeniləyin və yenidən cəhd edin."
[settings]
backToSections = "Bütün ayarlar"
close = "Bağla"
title = "Ayarlar"
[settings.ai]
documents = "Sənədlər və RAG"
@@ -10635,6 +10758,7 @@ ariaLabel = "Super search"
filtersAriaLabel = "Search filters"
hint = "Type to search"
placeholder = "Search Stirling"
placeholderShort = "Axtar"
showLess = "Show less"
showMore = "Show {{count}} more"
@@ -10831,6 +10955,7 @@ searchPlaceholder = "Alətlərdə axtar..."
[toolPicker.subcategories]
advancedFormatting = "Qabaqcıl Formatlama"
ai = "AI"
automation = "Avtomatlaşdırma"
developerTools = "Tərtibatçı Alətləri"
documentReview = "Sənəd Baxışı"
@@ -11335,8 +11460,11 @@ columnDefault = "Sütun {{index}}"
convertToPdf = "PDF-ə çevir"
csvStats = "{{rows}} sətir · {{columns}} sütun · {{size}}"
emptyFile = "Boş fayl"
htmlHidePreview = "Önizləməni gizlət"
htmlPreview = "HTML ön baxış"
htmlPreviewMobileHidden = "HTML səhifələri masaüstü enləri üçün tərtib edilib, buna görə burada önizləmə standart olaraq söndürülüb."
htmlPreviewWarning = "HTML ön baxış — xarici resurslar yüklənməyə bilər · {{size}}"
htmlShowPreview = "Yenə də önizləməni göstər"
invalidJson = "Etibarsız JSON — xam məzmun göstərilir"
lineNumbers = "Sətir nömrələri"
loading = "Yüklənir..."
@@ -11683,6 +11811,7 @@ exportAll = "PDF-i ixrac et"
exportSelected = "Seçilmiş səhifələri ixrac et"
formFill = "Formu doldur"
hideToolbar = "Hide toolbar"
moreActions = "Daha çox əməliyyat"
multiTool = "Çoxfunksiyalı alət"
panMode = "Sürüşdürmə rejimi"
print = "PDF-i çap et"
@@ -11703,6 +11832,8 @@ selectAll = "Hamısını seç"
selectByNumber = "Səhifə nömrələrinə görə seç"
selectLanguage = "Dili seçin"
share = "Paylaş"
showAllTools = "Bütün alətləri göstər"
showFewerTools = "Alətlər panelini yığ"
showToolbar = "Show toolbar"
toggleAnnotations = "Annotasiyaların görünməsini dəyiş"
toggleAttachments = "Qoşmaları aç/bağla"

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