Compare commits

...
Author SHA1 Message Date
Connor Yoh 238a8bf81c Open processor "Open in browser" CTAs in a new tab
The home-hero and download-modal "Open in browser" buttons used
window.location.href, which replaced the current tab. Switch to
window.open(..., "_blank", "noopener,noreferrer") to match the
existing EditorStatusCard behaviour.
2026-07-15 18:09:51 +01:00
Anthony StirlingandGitHub bef83d80a1 Patch CVEs in engine Python dependencies (#6804)
# Description of Changes

- Patch CVEs in engine Python dependencies (43 alerts): `starlette`
1.3.1, `cryptography` 49.0.0, `pyjwt` 2.13.0, `urllib3` 2.7.0, `aiohttp`
3.14.1, `python-multipart` 0.0.32, `langchain-core` 1.4.8, `langsmith`
0.9.1, `authlib` 1.7.2, `requests` 2.34.2, `idna` 3.18, `pytest` 9.1.1,
`pygments` 2.20.0, `pydantic-settings` 2.14.2
- Cap `pydantic-ai` `<2.0.0` and bump to 1.107.0 (1.99.0 patches
CVE-2026-46678; 2.0 is a separate major migration)

---

## Checklist

### General

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

### Documentation

- [ ] I have updated relevant docs (if functionality has heavily
changed)
- [ ] I have read the section Add New Translation Tags (for new
translation tags only)

### UI Changes (if applicable)

- [ ] Screenshots or videos demonstrating the UI changes are attached

### Testing (if applicable)

- [ ] I have run `task check` to verify linters, typechecks, and tests
pass
- [ ] I have tested my changes locally
2026-07-15 14:49:33 +00:00
Anthony StirlingandGitHub 8ed1ff152b Hide processor pipelines stat boxes during loading/empty state (#6983)
# Description of Changes

Gate the processor Pipelines KPI strip on having real pipelines, so the
loading and empty states no longer flash a row of empty `—` stat boxes.
Matches the Policies view; Sources is intentionally left as-is.

---

## Checklist

### General

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

### Documentation

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

### Translations (if applicable)

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

### UI Changes (if applicable)

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

### Testing (if applicable)

- [x] I have run `task check` to verify linters, typechecks, and tests
pass
- [x] I have tested my changes locally. Refer to the [Testing
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md#7-testing)
for more details.
2026-07-15 14:49:25 +00:00
Anthony StirlingandGitHub d2c8bbfcb2 Portal Sources: consistent SVG source icons (#6995)
# Description of Changes

Replaces the odd glyph icons in the Sources connect flow with centred
stroke SVGs, and fixes the connect-modal type-tile alignment and the
inconsistent Folder chip width.

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

## Before / after

<img width="2816" height="1224" alt="before-sources-light"
src="https://github.com/user-attachments/assets/22814817-8123-4bc7-b40b-5c2ba2881415"
/>
<img width="2880" height="2000" alt="after-connect-modal"
src="https://github.com/user-attachments/assets/0e51ca78-cda0-4909-bff3-912ecd7925a3"
/>
<img width="2816" height="1224" alt="after-sources-light"
src="https://github.com/user-attachments/assets/b9fd4b8a-c45a-4518-9676-e871bbabd9b8"
/>
<img width="2880" height="2000" alt="before-connect-modal"
src="https://github.com/user-attachments/assets/55058c40-d152-4c5e-a8a0-1ca5549a121c"
/>


---

## Checklist

### General

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

### Documentation

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

### Translations (if applicable)

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

### UI Changes (if applicable)

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

### Testing (if applicable)

- [ ] I have run `task check` to verify linters, typechecks, and tests
pass
- [x] I have tested my changes locally. Refer to the [Testing
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md#7-testing)
for more details.
2026-07-15 14:49:21 +00:00
Anthony StirlingandGitHub da1be650ea Portal Billing: use design tokens for colours (#6996)
# Description of Changes

Swaps off-palette hard-coded blues/cyans on the usage & billing page for
shared design tokens, so button and meter colours match the rest of the
portal in both themes.

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

## Before / after
<img width="2816" height="1000" alt="after-billing-free"
src="https://github.com/user-attachments/assets/1f6acad3-56a1-47a8-b5f5-8cc25565492b"
/>
<img width="2816" height="2048" alt="after-billing-subscribed-dark"
src="https://github.com/user-attachments/assets/35a75057-bdf5-4143-bdfa-4dbe94b5429e"
/>
<img width="2816" height="2048" alt="after-billing-subscribed-light"
src="https://github.com/user-attachments/assets/f9e7eab8-7d01-4d29-b842-b5d38adf7780"
/>
<img width="2816" height="1000" alt="before-billing-free"
src="https://github.com/user-attachments/assets/c7e5eca6-a57a-4959-9781-fd664dbca576"
/>
<img width="2816" height="2004" alt="before-billing-subscribed-dark"
src="https://github.com/user-attachments/assets/d7fc08be-7b91-4803-bd50-3301b886e1c0"
/>
<img width="2816" height="2004" alt="before-billing-subscribed-light"
src="https://github.com/user-attachments/assets/ff430ec1-c3bb-4517-b6b5-2150c566eca4"
/>


---

## Checklist

### General

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

### Documentation

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

### Translations (if applicable)

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

### UI Changes (if applicable)

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

### Testing (if applicable)

- [ ] I have run `task check` to verify linters, typechecks, and tests
pass
- [x] I have tested my changes locally. Refer to the [Testing
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md#7-testing)
for more details.
2026-07-15 14:49:15 +00:00
Anthony StirlingandGitHub 5cb5a866ca Portal: remove unused Components section (#7005)
# Description of Changes

Deletes the unused Components catalogue section (view, cards, SDK mocks)
along with its sidebar entry and route.

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

## Before / after

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

---

## Checklist

### General

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

### Documentation

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

### Translations (if applicable)

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

### UI Changes (if applicable)

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

### Testing (if applicable)

- [ ] I have run `task check` to verify linters, typechecks, and tests
pass
- [x] I have tested my changes locally. Refer to the [Testing
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md#7-testing)
for more details.
2026-07-15 14:49:13 +00:00
Anthony StirlingandGitHub 83e5319661 Reduce CI cost: disable Depot, gate arm64/Tauri PR builds, self-testing CI routing (#7028)
## What

CI cost/routing cleanup. Four changes, each reversible with no code
deleted.

### 1. Disable Depot repo-wide (reversible)
Depot ran on trusted (non-fork) triggers via the `is_fork` output of
`_runner-pick.yml`, driving both the `depot-*` runner selection and the
Depot docker build actions. It's now disabled everywhere behind a single
kill-switch:

- `_runner-pick.yml` gains a dedicated `use_depot` output, forced
`false` via `DEPOT_ENABLED=false`. `is_fork` stays truthful for trust
gating (e.g. `build-enterprise` skipping on forks).
- All `runs-on:` and `USE_DEPOT:` expressions now key off `use_depot`,
so every job falls back to `ubuntu-latest` + buildx.
- `settings.gradle` Depot remote build cache (`cache.depot.dev`) gated
behind `depotCacheEnabled = false`.

**Switch back on:** set `DEPOT_ENABLED=true` in `_runner-pick.yml` (and
`depotCacheEnabled = true` in `settings.gradle`). Depot then reactivates
on trusted triggers exactly as before.

### 2. arm64 PR docker build only on Dockerfile changes
`test-build-docker.yml` was building `linux/amd64,linux/arm64/v8` on
every PR matching the broad `project` filter. With Depot off, the arm64
leg runs under slow QEMU emulation on every code PR. New `dockerfiles`
path filter (`docker/**/Dockerfile*`) gates the arm64 leg: normal code
PRs build amd64 only; PRs that touch a Dockerfile still build amd64 +
arm64. arm64 is still fully exercised on the base-image publish and on
release.

### 3. Tauri PR build -> Linux only, unsigned, deb-only
The PR path built the full 3-OS matrix (Windows + macOS-universal +
Linux), plus the flaky Linux AppImage pass (#6127). PRs now build Linux
only (fastest + cheapest to compile) via a new `minimal` input on
`tauri-build.yml`: Linux deb only, no rpm, no AppImage. The full signed
multi-OS matrix still runs on release, and nightly still warms the Rust
cache with all-OS defaults (unchanged).

Tradeoff: Windows/macOS desktop build breaks are caught by nightly
(all-OS) rather than the introducing PR.

### 4. CI self-testing routing
Editing `build.yml` only matched the `project` filter, so a change to
how e2e / enterprise / tauri / engine jobs are dispatched didn't
actually run those jobs. Added a `ci` anchor (`build.yml` +
`.github/config/.files.yaml`) that every job-gating area filter now
includes, so editing the router or the filter config runs every job.
Also added the orphaned reusable workflows (`e2e-*`,
`frontend-validation`, `docker-compose-tests`, `test-build-docker`,
`check-openapi`, `check-licence`) to their area filters so editing a
reusable workflow self-tests.

## Validation
- All workflow YAML + `.files.yaml` parse; anchor resolution verified
(every job-gating filter resolves to include the `ci` paths).
- Gradle evaluates `settings.gradle` cleanly; `spotlessGradleCheck`
passes.
2026-07-15 14:49:06 +00:00
James BruntonandGitHub ed58d90ab8 Remove policies feature flag (#7031)
# Description of Changes
Removes the feature flags for enabling policies on both the backend and
frontend. We shouldn't be releasing another self-hosted release that
doesn't include policies, so it makes sense to do this now. Builds that
don't have the Processor will just not run policies because they won't
have any. Beyond that, the API should always be available, but checks
whether the user actually has the entitlements to run policies (whether
they have credits/a payment method available)
2026-07-15 14:25:25 +00:00
James BruntonandGitHub 4ee54243f3 Remove encryption from stored policies JSON (#7035)
# Description of Changes
The policies stored in the DB are currently encrypted at rest, because
one version in the past included S3 keys. These are now stored properly
in the credentials system and I've manually removed the only policy that
used S3 (it was very recently released). Since there's no S3 (or other)
credentials in the policies stored JSON now, we might as well just
decrypt them. This PR pairs with #7034 to fix the issues - #7034 makes
it resilient to crashing when attempting to load encrypted JSON that's
been encrypted with the wrong key, and this makes it so if it does load
any encrypted policies, they'll be re-saved decrypted, so we should have
a vanishingly small number of encrypted policies over time.
2026-07-15 12:28:29 +00:00
James BruntonandGitHub 350c0b796e Upgrade to TS7 official release (#6958)
# Description of Changes
Convert from the TS7 release candidate to the TS7 official release,
keeping TS6 around in [the compatibility mode suggested by
Microsoft](https://devblogs.microsoft.com/typescript/announcing-typescript-7-0/#running-side-by-side-with-typescript-6.0)
so ESLint and our scripts which rely on the TS API still work.
2026-07-15 12:21:34 +00:00
Anthony StirlingandGitHub 663bb32b2c Skip unreadable policy source/policy rows instead of crashing :) (#7034)
# Description of Changes

Thanks james for the prod issue  :) 

---

## Checklist

### General

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

### Documentation

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

### Translations (if applicable)

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

### UI Changes (if applicable)

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

### Testing (if applicable)

- [ ] I have run `task check` to verify linters, typechecks, and tests
pass
- [ ] I have tested my changes locally. Refer to the [Testing
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md#7-testing)
for more details.
2026-07-15 12:19:06 +00:00
EthanHealy01andGitHub b86e963c9d initial colors and theme improvements (#7009)
## What this does

Consolidates the frontend's colour/theme system into a small,
well-defined token layer and reworks the theme picker. The goal was a
minimal, scalable set of semantic tokens that the editor **and** the
Processor/portal (and Storybook) all share, plus a theme model that's
easy to reason about.

## Token architecture (`core/theme/`)

A four-file layer, imported once via `index.css`:

| File | Role |
|---|---|
| `primitives.css` | The raw palette — the **only** place literal
colours live (neutral ramps `--p-gray-*`/`--p-zinc-*` + status hues). |
| `colors.css` | ~21 semantic `--c-*` tokens (surfaces, text, borders,
primary, status) mapped from primitives per theme. **Reference these.**
|
| `compat.css` | Legacy names (`--bg-*`, `--text-*`, `--color-*`)
aliased onto `--c-*` via `:root:root` so ~200 existing files keep
working. |
| `dimensions.css` | All non-colour tokens (spacing, radius, z-index,
type, motion) — single source, resolving prior collisions. |

A blocking linter (`scripts/lint/theme-lint.mjs`, run in
`frontend:lint`) enforces "literals only in `primitives.css`" within
`core/theme/`, and has a non-blocking WCAG contrast report. See
`core/theme/README.md`.

## Theme model

- **Mode** (`light` / `dark` / `system`) and **accent** are independent.
Each mode has its own accent (`lightPrimary` / `darkPrimary`).
- The editor is always `data-app-theme="custom"`; `ThemeProvider`
injects the accent as `--user-primary` and sets `data-accent`.
- **Two accent states:**
- A **colour** (preset or custom hex) → tints every surface that hue
(whole-app theming).
- The **`default`** sentinel → neutral surfaces (white/grey light, zinc
black/grey dark) with blue buttons, no tint. (`data-accent="default"`
opts surfaces out of the tint.)
- Accent contrast guardrails (`utils/customPrimary.ts`): lightness
clamps so an accent can't collapse into the base, a contrast-picked
on-primary foreground, and an accent-as-foreground variant so accent
text is never dark-on-dark.

## Theme picker (Settings → General)

- 3×5 grid: a distinct **Default** icon chip (not a colour) + 14 curated
accents, in a dropdown per mode.
- **Custom** colour via the shared `ColorInput`, with a live gamut clamp
(`clampValue`) that refuses white/grey/black — the picker handle sticks
at the boundary and preserves the working hue at achromatic extremes.
- "Restore theme to default" resets both modes.

## Other

- Dark mode is a true neutral zinc (no navy "midnight" tint); the
Mantine dark ramp and Tailwind dark channels were neutralised to match.
- Pre-paint inline script in `index.html` applies theme + accent before
first paint (no FOUC); portal and editor now share the same
`preferences.theme` source of truth.
- High-visibility surfaces migrated to tokens (FAB, landing upload
buttons, portal hero banners); scattered per-component colour swaps were
intentionally **left for a follow-up** to keep this PR focused.

## Testing

- `task frontend:check:all` (typecheck all variants + eslint + prettier
+ colour-lint) green.
- Verified light/dark, default vs tinted accents, and the custom clamp
via computed styles in the dev preview.

> Note: the `prerender-og` build step failing in the e2e/deploy jobs is
unrelated to this diff — it's in `vite.config.ts` (untouched here) and
builds cleanly locally.
2026-07-15 12:11:00 +00:00
189 changed files with 3202 additions and 5993 deletions
+29 -3
View File
@@ -1,16 +1,34 @@
# CI routing infra. Editing the top-level router (build.yml) or this filter
# config re-runs every area's jobs, so every job-gating filter below includes
# *ci. That makes a change to how jobs are dispatched actually exercise those
# jobs (self-testing), instead of a router edit only matching the project filter.
ci: &ci
- .github/workflows/build.yml
- .github/config/.files.yaml
build: &build
- *ci
- build.gradle
- app/(common|core|proprietary|saas)/build.gradle
- Taskfile.yml
- .taskfiles/backend.yml
- .github/workflows/check-licence.yml
openapi: &openapi
- *ci
- *build
- app/(common|core|proprietary|saas)/src/main/java/**
- .github/workflows/check-openapi.yml
docker-base: &docker-base
- docker/base/Dockerfile
# Dockerfiles only (base + embedded + unoserver). Gates the slow multi-arch
# (arm64) leg of the PR docker test build: arm64 is only rebuilt when a
# Dockerfile itself changes, not on every code PR.
dockerfiles: &dockerfiles
- docker/**/Dockerfile*
docker: &docker
- docker/embedded/Dockerfile
- docker/embedded/Dockerfile.fat
@@ -23,13 +41,11 @@ docker: &docker
- *docker-base
project: &project
- *ci
- app/(common|core|proprietary|saas)/src/(main|test)/java/**
- *build
- "app/(common|core|proprietary|saas)/src/(main|test)/resources/**/!(messages_*.properties|*.md)*"
- exampleYmlFiles/**
- gradle/**
- libs/**
- "testing/**/!(requirements*.txt|requirements*.in)*"
- *docker
- *docker-base
- gradle.properties
@@ -45,8 +61,11 @@ project: &project
- .taskfiles/docker.yml
- scripts/db-migration/**
- .github/workflows/db-migration-test.yml
- .github/workflows/docker-compose-tests.yml
- .github/workflows/test-build-docker.yml
frontend: &frontend
- *ci
- frontend/**
- .github/workflows/testdriver.yml
- testing/**
@@ -63,10 +82,14 @@ frontend: &frontend
- Taskfile.yml
- .taskfiles/frontend.yml
- .taskfiles/e2e.yml
- .github/workflows/frontend-validation.yml
- .github/workflows/e2e-stubbed.yml
- .github/workflows/e2e-live.yml
# Files that affect the Tauri desktop bundle. Gate the multi-OS Tauri build
# job on changes to any of these.
tauri: &tauri
- *ci
- frontend/editor/src-tauri/**
- frontend/editor/src/desktop/**
- frontend/editor/tsconfig.desktop.vite.json
@@ -81,6 +104,7 @@ tauri: &tauri
# the engine validation job on changes to engine sources or to the Java
# tool surfaces it generates models from.
engine: &engine
- *ci
- engine/**
- app/(common|core|proprietary|saas)/src/main/java/**
- .github/workflows/ai-engine.yml
@@ -93,6 +117,7 @@ engine: &engine
# tasks that drive generation. Deliberately excludes the broad frontend/docker/
# testing globs, so a CSS-only PR does not boot the backend to rebuild the spec.
generated-models: &generated-models
- *ci
- *openapi
- frontend/editor/scripts/generate-tool-api-types.mts
- frontend/editor/src/core/types/toolApiTypes.ts
@@ -115,6 +140,7 @@ licenses-backend: &licenses-backend
# Files that can affect premium / enterprise behaviour. Gate the enterprise
# Playwright job on changes to any of these on PRs.
proprietary: &proprietary
- *ci
- app/proprietary/**
- frontend/editor/src/proprietary/**
- frontend/editor/src/core/tests/enterprise/**
+4 -5
View File
@@ -29,7 +29,7 @@ jobs:
check-pr:
if: (github.event_name == 'pull_request' && github.event.action != 'closed') || github.event_name == 'workflow_dispatch'
needs: pick
runs-on: ${{ needs.pick.outputs.is_fork == 'true' && 'ubuntu-latest' || 'depot-ubuntu-24.04-4' }}
runs-on: ${{ needs.pick.outputs.use_depot == 'true' && 'depot-ubuntu-24.04-4' || 'ubuntu-latest' }}
outputs:
should_deploy: ${{ steps.decide.outputs.should_deploy }}
is_fork: ${{ steps.resolve.outputs.is_fork }}
@@ -102,7 +102,7 @@ jobs:
deploy-v2-pr:
needs: [pick, check-pr]
runs-on: ${{ needs.pick.outputs.is_fork == 'true' && 'ubuntu-latest' || 'depot-ubuntu-24.04-4' }}
runs-on: ${{ needs.pick.outputs.use_depot == 'true' && 'depot-ubuntu-24.04-4' || 'ubuntu-latest' }}
if: needs.check-pr.outputs.should_deploy == 'true' && (needs.check-pr.outputs.is_fork == 'false' || needs.check-pr.outputs.allow_fork == 'true')
# Concurrency control - only one deployment per PR at a time
concurrency:
@@ -114,7 +114,7 @@ jobs:
pull-requests: write
id-token: write
env:
USE_DEPOT: ${{ needs.pick.outputs.is_fork != 'true' }}
USE_DEPOT: ${{ needs.pick.outputs.use_depot == 'true' }}
DEPOT_TOKEN: ${{ secrets.DEPOT_TOKEN }}
# Single source of truth for whether this preview embeds the admin portal:
# drives the image build-arg and the deployment comment.
@@ -297,7 +297,6 @@ jobs:
- /stirling/V2-PR-${{ needs.check-pr.outputs.pr_number }}/storage:/storage:rw
environment:
DISABLE_ADDITIONAL_FEATURES: "false"
POLICIES_ENABLED: "true"
STIRLING_BILLING_ACCOUNT_LINK_ENABLED: "true"
SECURITY_ENABLELOGIN: "true"
SECURITY_INITIALLOGIN_USERNAME: "${{ secrets.TEST_LOGIN_USERNAME }}"
@@ -476,7 +475,7 @@ jobs:
cleanup-v2-deployment:
if: github.event.action == 'closed'
needs: pick
runs-on: ${{ needs.pick.outputs.is_fork == 'true' && 'ubuntu-latest' || 'depot-ubuntu-24.04-4' }}
runs-on: ${{ needs.pick.outputs.use_depot == 'true' && 'depot-ubuntu-24.04-4' || 'ubuntu-latest' }}
permissions:
contents: read
issues: write
@@ -39,7 +39,7 @@ jobs:
check-comment:
needs: pick
runs-on: ${{ needs.pick.outputs.is_fork == 'true' && 'ubuntu-latest' || 'depot-ubuntu-24.04-4' }}
runs-on: ${{ needs.pick.outputs.use_depot == 'true' && 'depot-ubuntu-24.04-4' || 'ubuntu-latest' }}
permissions:
issues: write
if: |
@@ -180,13 +180,13 @@ jobs:
deploy-pr:
needs: [pick, check-comment]
runs-on: ${{ needs.pick.outputs.is_fork == 'true' && 'ubuntu-latest' || 'depot-ubuntu-24.04-4' }}
runs-on: ${{ needs.pick.outputs.use_depot == 'true' && 'depot-ubuntu-24.04-4' || 'ubuntu-latest' }}
permissions:
issues: write
pull-requests: write
id-token: write
env:
USE_DEPOT: ${{ needs.pick.outputs.is_fork != 'true' }}
USE_DEPOT: ${{ needs.pick.outputs.use_depot == 'true' }}
DEPOT_TOKEN: ${{ secrets.DEPOT_TOKEN }}
steps:
@@ -511,7 +511,7 @@ jobs:
handle-label-commands:
if: ${{ github.event.issue.pull_request != null }}
needs: pick
runs-on: ${{ needs.pick.outputs.is_fork == 'true' && 'ubuntu-latest' || 'depot-ubuntu-24.04-4' }}
runs-on: ${{ needs.pick.outputs.use_depot == 'true' && 'depot-ubuntu-24.04-4' || 'ubuntu-latest' }}
steps:
- name: Harden Runner
uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3
+41 -17
View File
@@ -5,6 +5,11 @@ name: _runner-pick
# can pick a runner class without each one duplicating the 200-char gate
# expression in their own `runs-on:`.
#
# It also owns the single Depot kill-switch (use_depot). Depot is currently
# disabled repo-wide; downstream jobs gate their Depot runner/build usage on
# use_depot so nothing has to be deleted to turn Depot off. Flip DEPOT_ENABLED
# in the decide step to switch Depot back on.
#
# Caller pattern:
#
# jobs:
@@ -13,12 +18,15 @@ name: _runner-pick
#
# real-work:
# needs: pick
# runs-on: ${{ needs.pick.outputs.is_fork == 'true' && 'ubuntu-latest' || 'depot-ubuntu-24.04-8' }}
# runs-on: ${{ needs.pick.outputs.use_depot == 'true' && 'depot-ubuntu-24.04-8' || 'ubuntu-latest' }}
# steps: [...]
#
# Output:
# is_fork: "true" when the trigger is a pull_request from a fork or an
# untrusted author_association, "false" otherwise.
# Outputs:
# is_fork: "true" when the trigger is a pull_request from a fork or an
# untrusted author_association, "false" otherwise. Use this for
# trust gating (skipping secret-dependent jobs on forks).
# use_depot: "true" when downstream jobs should use Depot runners/builders.
# Currently forced "false" (Depot disabled repo-wide).
on:
workflow_call:
@@ -26,6 +34,9 @@ on:
is_fork:
description: '"true" if the trigger is an untrusted fork PR.'
value: ${{ jobs.pick.outputs.is_fork }}
use_depot:
description: '"true" when downstream jobs should use Depot. Currently forced off.'
value: ${{ jobs.pick.outputs.use_depot }}
permissions:
contents: read
@@ -36,6 +47,7 @@ jobs:
timeout-minutes: 1
outputs:
is_fork: ${{ steps.decide.outputs.is_fork }}
use_depot: ${{ steps.decide.outputs.use_depot }}
steps:
- name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3
@@ -50,21 +62,33 @@ jobs:
AUTHOR_ASSOC: ${{ github.event.pull_request.author_association }}
run: |
set -eu
# Depot kill-switch. Depot is disabled repo-wide: no job uses Depot
# runners or the Depot build actions while this is false. All the
# Depot wiring is left in place - set DEPOT_ENABLED=true to switch it
# back on (it then activates on trusted, non-fork triggers as before).
DEPOT_ENABLED=false
if [ -z "${PR_NUMBER:-}" ]; then
# Not a pull_request event at all (push, schedule, workflow_dispatch,
# workflow_call from a non-PR trigger) -> trusted by default.
echo "is_fork=false" >> "$GITHUB_OUTPUT"
exit 0
is_fork=false
elif [ "${HEAD_REPO_FORK}" = "true" ]; then
is_fork=true
else
case "${AUTHOR_ASSOC}" in
OWNER|MEMBER|COLLABORATOR) is_fork=false ;;
*) is_fork=true ;;
esac
fi
if [ "${HEAD_REPO_FORK}" = "true" ]; then
echo "is_fork=true" >> "$GITHUB_OUTPUT"
exit 0
# Depot only ever ran on trusted triggers, so gate it on both the
# kill-switch and is_fork.
if [ "${DEPOT_ENABLED}" = "true" ] && [ "${is_fork}" = "false" ]; then
use_depot=true
else
use_depot=false
fi
case "${AUTHOR_ASSOC}" in
OWNER|MEMBER|COLLABORATOR)
echo "is_fork=false" >> "$GITHUB_OUTPUT"
;;
*)
echo "is_fork=true" >> "$GITHUB_OUTPUT"
;;
esac
echo "is_fork=${is_fork}" >> "$GITHUB_OUTPUT"
echo "use_depot=${use_depot}" >> "$GITHUB_OUTPUT"
+1 -1
View File
@@ -24,7 +24,7 @@ jobs:
build:
needs: pick
runs-on: ${{ needs.pick.outputs.is_fork == 'true' && 'ubuntu-latest' || 'depot-ubuntu-24.04-8' }}
runs-on: ${{ needs.pick.outputs.use_depot == 'true' && 'depot-ubuntu-24.04-8' || 'ubuntu-latest' }}
env:
DEPOT_TOKEN: ${{ secrets.DEPOT_TOKEN }}
strategy:
+1 -1
View File
@@ -54,7 +54,7 @@ jobs:
# (nor DEPOT_TOKEN), so the suite can't boot premium and would fail. See the
# header comment. GitHub reports the skipped reusable workflow as success.
if: needs.pick.outputs.is_fork != 'true'
runs-on: ${{ needs.pick.outputs.is_fork == 'true' && 'ubuntu-latest' || format('depot-ubuntu-24.04-{0}', inputs.depot_cores || '8') }}
runs-on: ${{ needs.pick.outputs.use_depot == 'true' && format('depot-ubuntu-24.04-{0}', inputs.depot_cores || '8') || 'ubuntu-latest' }}
timeout-minutes: 45
env:
PREMIUM_KEY: ${{ secrets.PREMIUM_KEY_ENTERPRISE }}
+9
View File
@@ -41,6 +41,7 @@ jobs:
openapi: ${{ steps.changes.outputs.openapi }}
frontend: ${{ steps.changes.outputs.frontend }}
docker-base: ${{ steps.changes.outputs.docker-base }}
dockerfiles: ${{ steps.changes.outputs.dockerfiles }}
tauri: ${{ steps.changes.outputs.tauri }}
engine: ${{ steps.changes.outputs.engine }}
generated-models: ${{ steps.changes.outputs.generated-models }}
@@ -153,6 +154,7 @@ jobs:
secrets: inherit
with:
docker-base-changed: ${{ needs.files-changed.outputs.docker-base }}
dockerfiles-changed: ${{ needs.files-changed.outputs.dockerfiles }}
tauri-build:
if: needs.files-changed.outputs.tauri == 'true'
@@ -162,6 +164,13 @@ jobs:
pull-requests: write
uses: ./.github/workflows/tauri-build.yml
secrets: inherit
# PR smoke build: Linux only (fastest + cheapest to compile), unsigned,
# deb-only, no AppImage. The full signed multi-OS matrix runs on release;
# nightly still warms the Rust cache with all-OS defaults.
with:
platform: linux
sign: false
minimal: true
ai-engine:
if: needs.files-changed.outputs.engine == 'true'
+1 -1
View File
@@ -15,7 +15,7 @@ jobs:
check-generate-openapi-docs:
needs: pick
runs-on: ${{ needs.pick.outputs.is_fork == 'true' && 'ubuntu-latest' || 'depot-ubuntu-24.04-4' }}
runs-on: ${{ needs.pick.outputs.use_depot == 'true' && 'depot-ubuntu-24.04-4' || 'ubuntu-latest' }}
env:
DEPOT_TOKEN: ${{ secrets.DEPOT_TOKEN }}
steps:
+1 -1
View File
@@ -34,7 +34,7 @@ jobs:
aggregate:
needs: pick
runs-on: ${{ needs.pick.outputs.is_fork == 'true' && 'ubuntu-latest' || 'depot-ubuntu-24.04-4' }}
runs-on: ${{ needs.pick.outputs.use_depot == 'true' && 'depot-ubuntu-24.04-4' || 'ubuntu-latest' }}
timeout-minutes: 15
steps:
- name: Harden Runner
+1 -1
View File
@@ -17,7 +17,7 @@ jobs:
migration-test:
needs: pick
runs-on: ${{ needs.pick.outputs.is_fork == 'true' && 'ubuntu-latest' || 'depot-ubuntu-24.04-8' }}
runs-on: ${{ needs.pick.outputs.use_depot == 'true' && 'depot-ubuntu-24.04-8' || 'ubuntu-latest' }}
timeout-minutes: 30
env:
DEPOT_TOKEN: ${{ secrets.DEPOT_TOKEN }}
+2 -2
View File
@@ -15,7 +15,7 @@ jobs:
deploy-v2-on-push:
needs: pick
runs-on: ${{ needs.pick.outputs.is_fork == 'true' && 'ubuntu-latest' || 'depot-ubuntu-24.04-4' }}
runs-on: ${{ needs.pick.outputs.use_depot == 'true' && 'depot-ubuntu-24.04-4' || 'ubuntu-latest' }}
concurrency:
group: deploy-v2-push-V2
cancel-in-progress: true
@@ -23,7 +23,7 @@ jobs:
contents: read
id-token: write
env:
USE_DEPOT: ${{ needs.pick.outputs.is_fork != 'true' }}
USE_DEPOT: ${{ needs.pick.outputs.use_depot == 'true' }}
DEPOT_TOKEN: ${{ secrets.DEPOT_TOKEN }}
steps:
+1 -1
View File
@@ -26,7 +26,7 @@ jobs:
docker-compose-tests:
needs: pick
runs-on: ${{ needs.pick.outputs.is_fork == 'true' && 'ubuntu-latest' || format('depot-ubuntu-24.04-{0}', inputs.depot_cores || '4') }}
runs-on: ${{ needs.pick.outputs.use_depot == 'true' && format('depot-ubuntu-24.04-{0}', inputs.depot_cores || '4') || 'ubuntu-latest' }}
permissions:
actions: write
contents: read
+1 -1
View File
@@ -21,7 +21,7 @@ jobs:
playwright-e2e-live:
needs: pick
runs-on: ${{ needs.pick.outputs.is_fork == 'true' && 'ubuntu-latest' || format('depot-ubuntu-24.04-{0}', inputs.depot_cores || '8') }}
runs-on: ${{ needs.pick.outputs.use_depot == 'true' && format('depot-ubuntu-24.04-{0}', inputs.depot_cores || '8') || 'ubuntu-latest' }}
timeout-minutes: 30
steps:
- name: Harden Runner
+1 -1
View File
@@ -21,7 +21,7 @@ jobs:
playwright-e2e:
needs: pick
runs-on: ${{ needs.pick.outputs.is_fork == 'true' && 'ubuntu-latest' || format('depot-ubuntu-24.04-{0}', inputs.depot_cores || '8') }}
runs-on: ${{ needs.pick.outputs.use_depot == 'true' && format('depot-ubuntu-24.04-{0}', inputs.depot_cores || '8') || 'ubuntu-latest' }}
steps:
- name: Harden Runner
uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3
@@ -25,7 +25,7 @@ jobs:
files-changed:
name: detect what files changed
needs: pick
runs-on: ${{ needs.pick.outputs.is_fork == 'true' && 'ubuntu-latest' || 'depot-ubuntu-24.04-4' }}
runs-on: ${{ needs.pick.outputs.use_depot == 'true' && 'depot-ubuntu-24.04-4' || 'ubuntu-latest' }}
timeout-minutes: 3
outputs:
licenses-frontend: ${{ steps.changes.outputs.licenses-frontend }}
@@ -49,7 +49,7 @@ jobs:
if: needs.files-changed.outputs.licenses-frontend == 'true'
name: Generate Frontend License Report
needs: [pick, files-changed]
runs-on: ${{ needs.pick.outputs.is_fork == 'true' && 'ubuntu-latest' || 'depot-ubuntu-24.04-4' }}
runs-on: ${{ needs.pick.outputs.use_depot == 'true' && 'depot-ubuntu-24.04-4' || 'ubuntu-latest' }}
permissions:
contents: write
pull-requests: write
@@ -320,7 +320,7 @@ jobs:
if: needs.files-changed.outputs.licenses-backend == 'true'
needs: [pick, files-changed]
name: Generate Backend License Report
runs-on: ${{ needs.pick.outputs.is_fork == 'true' && 'ubuntu-latest' || 'depot-ubuntu-24.04-4' }}
runs-on: ${{ needs.pick.outputs.use_depot == 'true' && 'depot-ubuntu-24.04-4' || 'ubuntu-latest' }}
permissions:
contents: write
pull-requests: write
+1 -1
View File
@@ -16,7 +16,7 @@ jobs:
frontend-validation:
needs: pick
runs-on: ${{ needs.pick.outputs.is_fork == 'true' && 'ubuntu-latest' || 'depot-ubuntu-24.04-4' }}
runs-on: ${{ needs.pick.outputs.use_depot == 'true' && 'depot-ubuntu-24.04-4' || 'ubuntu-latest' }}
steps:
- name: Harden Runner
uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3
+3 -3
View File
@@ -42,7 +42,7 @@ jobs:
determine-matrix:
if: ${{ vars.CI_PROFILE != 'lite' }}
needs: pick
runs-on: ${{ needs.pick.outputs.is_fork == 'true' && 'ubuntu-latest' || 'depot-ubuntu-24.04-4' }}
runs-on: ${{ needs.pick.outputs.use_depot == 'true' && 'depot-ubuntu-24.04-4' || 'ubuntu-latest' }}
outputs:
matrix: ${{ steps.set-matrix.outputs.matrix }}
version: ${{ steps.versionNumber.outputs.versionNumber }}
@@ -113,7 +113,7 @@ jobs:
build-jars:
needs: [pick, determine-matrix]
runs-on: ${{ needs.pick.outputs.is_fork == 'true' && 'ubuntu-latest' || 'depot-ubuntu-24.04-4' }}
runs-on: ${{ needs.pick.outputs.use_depot == 'true' && 'depot-ubuntu-24.04-4' || 'ubuntu-latest' }}
env:
DEPOT_TOKEN: ${{ secrets.DEPOT_TOKEN }}
strategy:
@@ -639,7 +639,7 @@ jobs:
collect-and-release:
needs: [pick, determine-matrix, build, build-jars]
runs-on: ${{ needs.pick.outputs.is_fork == 'true' && 'ubuntu-latest' || 'depot-ubuntu-24.04-4' }}
runs-on: ${{ needs.pick.outputs.use_depot == 'true' && 'depot-ubuntu-24.04-4' || 'ubuntu-latest' }}
permissions:
contents: write
steps:
+1 -1
View File
@@ -19,7 +19,7 @@ jobs:
playwright-all-browsers:
name: Playwright (chromium + firefox + webkit)
needs: pick
runs-on: ${{ needs.pick.outputs.is_fork == 'true' && 'ubuntu-latest' || 'depot-ubuntu-24.04-4' }}
runs-on: ${{ needs.pick.outputs.use_depot == 'true' && 'depot-ubuntu-24.04-4' || 'ubuntu-latest' }}
steps:
- name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3
+1 -1
View File
@@ -28,7 +28,7 @@ jobs:
push:
if: ${{ vars.CI_PROFILE != 'lite' }}
needs: pick
runs-on: ${{ needs.pick.outputs.is_fork == 'true' && 'ubuntu-latest' || 'depot-ubuntu-24.04-4' }}
runs-on: ${{ needs.pick.outputs.use_depot == 'true' && 'depot-ubuntu-24.04-4' || 'ubuntu-latest' }}
env:
DEPOT_TOKEN: ${{ secrets.DEPOT_TOKEN }}
steps:
+20 -9
View File
@@ -21,6 +21,11 @@ on:
required: false
type: boolean
default: true
minimal:
description: "Fast smoke build: Linux deb only, skip rpm and the flaky AppImage pass. Used by PR builds."
required: false
type: boolean
default: false
workflow_dispatch:
inputs:
platform:
@@ -38,6 +43,11 @@ on:
required: false
default: true
type: boolean
minimal:
description: "Fast smoke build: Linux deb only, skip rpm and the flaky AppImage pass."
required: false
default: false
type: boolean
permissions:
contents: read
@@ -386,10 +396,10 @@ jobs:
with:
projectPath: ./frontend/editor
tauriScript: npx tauri
# Linux: build deb+rpm only here. AppImage runs in its own
# continue-on-error step below so its persistent linuxdeploy
# failure (#6127 onwards) does not tank deb/rpm uploads.
args: ${{ matrix.platform == 'ubuntu-22.04' && '--bundles deb,rpm' || matrix.args }}
# Linux: build deb+rpm only here (deb-only on minimal smoke builds).
# AppImage runs in its own continue-on-error step below so its
# persistent linuxdeploy failure (#6127 onwards) does not tank uploads.
args: ${{ matrix.platform == 'ubuntu-22.04' && (inputs.minimal && '--bundles deb' || '--bundles deb,rpm') || matrix.args }}
- name: Build Tauri app (unsigned)
if: ${{ !inputs.sign }}
@@ -406,15 +416,16 @@ jobs:
with:
projectPath: ./frontend/editor
tauriScript: npx tauri
# Linux: build deb+rpm only here. AppImage runs in its own
# continue-on-error step below so its persistent linuxdeploy
# failure (#6127 onwards) does not tank deb/rpm uploads.
args: ${{ matrix.platform == 'ubuntu-22.04' && '--bundles deb,rpm' || matrix.args }}
# Linux: build deb+rpm only here (deb-only on minimal smoke builds).
# AppImage runs in its own continue-on-error step below so its
# persistent linuxdeploy failure (#6127 onwards) does not tank uploads.
args: ${{ matrix.platform == 'ubuntu-22.04' && (inputs.minimal && '--bundles deb' || '--bundles deb,rpm') || matrix.args }}
# AppImage is decoupled so its linuxdeploy run gets a fresh process
# (rpm scratch state torn down) and its failure can't tank deb/rpm.
# Skipped on minimal smoke builds (flaky + slow, deb is enough to verify).
- name: Build Tauri app (Linux AppImage)
if: matrix.platform == 'ubuntu-22.04'
if: matrix.platform == 'ubuntu-22.04' && !inputs.minimal
continue-on-error: true
uses: tauri-apps/tauri-action@84b9d35b5fc46c1e45415bdb6144030364f7ebc5 # v0.6.2
env:
+19 -5
View File
@@ -12,6 +12,11 @@ on:
required: false
type: string
default: "false"
dockerfiles-changed:
description: "Whether any Dockerfile changed (forwarded from files-changed). Gates the slow arm64 build leg."
required: false
type: string
default: "false"
depot_cores:
description: "Depot runner vCPU count (used in runs-on). Override for benchmarking."
required: false
@@ -41,12 +46,12 @@ jobs:
# `task backend:build:ci` produce equivalent JARs (verify before wiring).
test-build-docker-images:
needs: pick
runs-on: ${{ needs.pick.outputs.is_fork == 'true' && 'ubuntu-latest' || format('depot-ubuntu-24.04-{0}', inputs.depot_cores || '8') }}
runs-on: ${{ needs.pick.outputs.use_depot == 'true' && format('depot-ubuntu-24.04-{0}', inputs.depot_cores || '8') || 'ubuntu-latest' }}
permissions:
contents: read
id-token: write
env:
USE_DEPOT: ${{ needs.pick.outputs.is_fork != 'true' && inputs.docker-base-changed != 'true' }}
USE_DEPOT: ${{ needs.pick.outputs.use_depot == 'true' && inputs.docker-base-changed != 'true' }}
DEPOT_TOKEN: ${{ secrets.DEPOT_TOKEN }}
strategy:
fail-fast: false
@@ -146,13 +151,22 @@ jobs:
# GITHUB_EVENT_NAME is already provided by the runner.
env:
DOCKER_BASE_CHANGED: ${{ inputs.docker-base-changed }}
DOCKERFILES_CHANGED: ${{ inputs.dockerfiles-changed }}
run: |
if [ "$GITHUB_EVENT_NAME" = "pull_request" ] && [ "$DOCKER_BASE_CHANGED" = "true" ]; then
# Base Dockerfile changed: build against the locally-built base,
# which only exists for amd64.
echo "base_image=stirling-pdf-base:pr-test" >> "$GITHUB_OUTPUT"
echo "platforms=linux/amd64" >> "$GITHUB_OUTPUT"
else
elif [ "$DOCKERFILES_CHANGED" = "true" ]; then
# A Dockerfile changed: also verify the arm64 build (slow QEMU leg).
echo "base_image=stirlingtools/stirling-pdf-base:latest" >> "$GITHUB_OUTPUT"
echo "platforms=linux/amd64,linux/arm64/v8" >> "$GITHUB_OUTPUT"
else
# No Dockerfile change: amd64 only. arm64 is exercised on the base
# image publish and on release, not on every code PR.
echo "base_image=stirlingtools/stirling-pdf-base:latest" >> "$GITHUB_OUTPUT"
echo "platforms=linux/amd64" >> "$GITHUB_OUTPUT"
fi
# Base-changed PRs build the embedded image with the local docker driver
@@ -214,12 +228,12 @@ jobs:
test-build-unoserver-image:
needs: pick
runs-on: ${{ needs.pick.outputs.is_fork == 'true' && 'ubuntu-latest' || format('depot-ubuntu-24.04-{0}', inputs.depot_cores || '8') }}
runs-on: ${{ needs.pick.outputs.use_depot == 'true' && format('depot-ubuntu-24.04-{0}', inputs.depot_cores || '8') || 'ubuntu-latest' }}
permissions:
contents: read
id-token: write
env:
USE_DEPOT: ${{ needs.pick.outputs.is_fork != 'true' && inputs.docker-base-changed != 'true' }}
USE_DEPOT: ${{ needs.pick.outputs.use_depot == 'true' && inputs.docker-base-changed != 'true' }}
DEPOT_TOKEN: ${{ secrets.DEPOT_TOKEN }}
steps:
- name: Harden Runner
+5 -5
View File
@@ -26,12 +26,12 @@ jobs:
deploy:
if: ${{ vars.CI_PROFILE != 'lite' }}
needs: pick
runs-on: ${{ needs.pick.outputs.is_fork == 'true' && 'ubuntu-latest' || 'depot-ubuntu-24.04-4' }}
runs-on: ${{ needs.pick.outputs.use_depot == 'true' && 'depot-ubuntu-24.04-4' || 'ubuntu-latest' }}
permissions:
contents: read
id-token: write
env:
USE_DEPOT: ${{ needs.pick.outputs.is_fork != 'true' }}
USE_DEPOT: ${{ needs.pick.outputs.use_depot == 'true' }}
DEPOT_TOKEN: ${{ secrets.DEPOT_TOKEN }}
steps:
- name: Harden Runner
@@ -154,7 +154,7 @@ jobs:
if: always()
name: detect what files changed
needs: pick
runs-on: ${{ needs.pick.outputs.is_fork == 'true' && 'ubuntu-latest' || 'depot-ubuntu-24.04-4' }}
runs-on: ${{ needs.pick.outputs.use_depot == 'true' && 'depot-ubuntu-24.04-4' || 'ubuntu-latest' }}
timeout-minutes: 3
outputs:
frontend: ${{ steps.changes.outputs.frontend }}
@@ -175,7 +175,7 @@ jobs:
test:
if: needs.files-changed.outputs.frontend == 'true'
needs: [pick, deploy, files-changed]
runs-on: ${{ needs.pick.outputs.is_fork == 'true' && 'ubuntu-latest' || 'depot-ubuntu-24.04-4' }}
runs-on: ${{ needs.pick.outputs.use_depot == 'true' && 'depot-ubuntu-24.04-4' || 'ubuntu-latest' }}
steps:
- name: Harden Runner
uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3
@@ -209,7 +209,7 @@ jobs:
cleanup:
needs: [pick, deploy, test]
runs-on: ${{ needs.pick.outputs.is_fork == 'true' && 'ubuntu-latest' || 'depot-ubuntu-24.04-4' }}
runs-on: ${{ needs.pick.outputs.use_depot == 'true' && 'depot-ubuntu-24.04-4' || 'ubuntu-latest' }}
if: always()
steps:
+2 -4
View File
@@ -26,7 +26,6 @@ tasks:
AIENGINE_ENABLED: '{{.AIENGINE_ENABLED}}'
AIENGINE_TIMEOUTSECONDS: '{{.AIENGINE_TIMEOUTSECONDS}}'
SECURITY_ENABLELOGIN: '{{.SECURITY_ENABLELOGIN}}'
POLICIES_ENABLED: '{{.POLICIES_ENABLED}}'
dev:proprietary:
desc: "Start backend dev server in proprietary mode"
@@ -41,13 +40,12 @@ tasks:
AIENGINE_ENABLED: '{{.AIENGINE_ENABLED | default "false"}}'
AIENGINE_TIMEOUTSECONDS: '{{.AIENGINE_TIMEOUTSECONDS | default "120"}}'
SECURITY_ENABLELOGIN: '{{.SECURITY_ENABLELOGIN | default ""}}'
POLICIES_ENABLED: '{{.POLICIES_ENABLED | 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}}{{if .POLICIES_ENABLED}}POLICIES_ENABLED={{.POLICIES_ENABLED}} {{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}}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}}{{if .POLICIES_ENABLED}}POLICIES_ENABLED={{.POLICIES_ENABLED}} {{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}}./gradlew :stirling-pdf:bootRun'
platforms: [linux, darwin]
dev:bundled:
+14 -3
View File
@@ -203,6 +203,19 @@ tasks:
cmds:
- task: lint:eslint
- task: lint:dpdm
- task: lint:colors
lint:colors:
desc: "Enforce theme tokens — colours in core/theme route through the palette"
deps: [install]
cmds:
- node editor/scripts/lint/theme-lint.mjs
contrast:
desc: "Report low-contrast theme token pairs (warning only, never blocks)"
deps: [install]
cmds:
- node editor/scripts/lint/theme-lint.mjs contrast
lint:eslint:
desc: "Run ESLint linting"
@@ -249,10 +262,8 @@ tasks:
typecheck:_run:
internal: true
env:
CI: '{{ .CI | default "false" }}'
cmds:
- '{{ if eq .CI "true" }}npx tsc{{ else }}npx tsgo{{ end }} --noEmit --project {{.PROJECT}}'
- 'npx tsc --noEmit --project {{.PROJECT}}'
typecheck:core:
desc: "Typecheck core build variant"
+2
View File
@@ -155,6 +155,8 @@ The project structure is defined in `engine/pyproject.toml`. Any new dependencie
For a broader explanation of the frontend layering and override architecture, read @frontend/editor/DeveloperGuide.md
Before touching colours or theming (tokens, dark mode, accent colours), read @frontend/editor/src/core/theme/README.md — it explains the palette/`--c-*` token system and the rule that literal colours live only in `primitives.css`.
```typescript
// ✅ CORRECT - Use @app/* for all imports
import { AppLayout } from "@app/components/AppLayout";
-1
View File
@@ -90,7 +90,6 @@ tasks:
vars:
PORT: '{{.BACKEND_PORT}}'
SECURITY_ENABLELOGIN: "true"
POLICIES_ENABLED: "true"
- task: frontend:dev:proprietary
vars:
PORT: '{{.EDITOR_PORT}}'
@@ -206,11 +206,6 @@ public class ApplicationProperties {
@Data
public static class Policies {
/**
* Master switch for the policy + sources subsystem (the PAYG-metered automation surface).
*/
private boolean enabled = false;
/**
* Absolute directories that policy folder input sources and output sinks may read from or
* write to. Empty (the default) disables folder access entirely, so a policy can never be
@@ -35,6 +35,7 @@ import stirling.software.proprietary.billing.ContentHasher;
import stirling.software.proprietary.billing.DocumentUnitCalculator;
import stirling.software.proprietary.billing.DocumentUnitCalculator.FileSize;
import stirling.software.proprietary.billing.UnitCalcPolicy;
import stirling.software.proprietary.policy.controller.PolicyRunRoutes;
import stirling.software.proprietary.security.model.ApiKeyAuthenticationToken;
/**
@@ -84,7 +85,13 @@ public class InstanceEntitlementInterceptor implements HandlerInterceptor {
instanceof ApiKeyAuthenticationToken;
BillingCategory category = BillableOperationClassifier.categorize(request, apiKey);
request.setAttribute(ATTR_CATEGORY, category);
decision = gate.evaluate(category != BillingCategory.BYPASSED);
// A policy run kicks off billable automation, so block it up front when unentitled
// rather than after its first tool. It carries no automation header itself (category
// BYPASSED), so it's gated here but metered only via its dispatched sub-steps - keeping
// the BYPASSED meter category avoids double-counting.
boolean billable =
category != BillingCategory.BYPASSED || PolicyRunRoutes.matches(request);
decision = gate.evaluate(billable);
} catch (RuntimeException e) {
// Fail open: an inability to resolve entitlement (e.g. a DB or SaaS blip) must never
// turn into a hard block on billable work.
@@ -0,0 +1,35 @@
package stirling.software.proprietary.integration.crypto;
import jakarta.persistence.AttributeConverter;
import jakarta.persistence.Converter;
/**
* For columns that were once whole-blob encrypted but no longer hold secrets.
*
* <p>Writes plaintext, so the value is readable by any instance regardless of the per-installation
* encryption key. Any value that isn't our ciphertext (already-plaintext JSON, or ciphertext from a
* key we don't hold) is returned as-is; the latter is the caller's to reject.
*/
@Converter
public class LegacyDecryptStringConverter implements AttributeConverter<String, String> {
@Override
public String convertToDatabaseColumn(String attribute) {
return attribute;
}
@Override
public String convertToEntityAttribute(String dbData) {
// Plaintext JSON can never be our Base64 ciphertext ('{' is not in the Base64 alphabet), so
// skip the decrypt attempt for it.
if (dbData == null || dbData.stripLeading().startsWith("{")) {
return dbData;
}
try {
return CredentialEncryption.decrypt(dbData);
} catch (IllegalArgumentException | IllegalStateException e) {
// Legacy ciphertext we can't read (key we don't hold) - the caller's to reject.
return dbData;
}
}
}
@@ -1,31 +0,0 @@
package stirling.software.proprietary.integration.crypto;
import jakarta.persistence.AttributeConverter;
import jakarta.persistence.Converter;
/**
* {@link EncryptedStringConverter} for columns that held plaintext before encryption shipped:
* writes are always encrypted, but a stored value that is not valid ciphertext is returned as-is,
* so pre-encryption rows keep loading and become encrypted on their next save. The discrimination
* is exact for JSON payloads, which can never be mistaken for ciphertext ('{' is not in the Base64
* alphabet). The trade-off is that a genuinely corrupted ciphertext surfaces as garbage to the
* caller's parser instead of failing here.
*/
@Converter
public class LenientEncryptedStringConverter implements AttributeConverter<String, String> {
@Override
public String convertToDatabaseColumn(String attribute) {
return CredentialEncryption.encrypt(attribute);
}
@Override
public String convertToEntityAttribute(String dbData) {
try {
return CredentialEncryption.decrypt(dbData);
} catch (IllegalArgumentException | IllegalStateException e) {
// Not ciphertext: legacy plaintext from before encryption shipped.
return dbData;
}
}
}
@@ -6,7 +6,6 @@ import java.util.Arrays;
import java.util.List;
import java.util.Optional;
import org.springframework.boot.autoconfigure.condition.ConditionalOnBooleanProperty;
import org.springframework.core.env.Environment;
import org.springframework.stereotype.Component;
@@ -30,7 +29,6 @@ import stirling.software.proprietary.policy.source.SourceStore;
* defended: an operator who roots an allowlist on a symlink to a sensitive location is trusted.
*/
@Component
@ConditionalOnBooleanProperty(name = "policies.enabled")
public class FolderAccessGuard {
public static final String FOLDER_TYPE = "folder";
@@ -3,7 +3,6 @@ package stirling.software.proprietary.policy.config;
import java.util.List;
import java.util.Objects;
import org.springframework.boot.autoconfigure.condition.ConditionalOnBooleanProperty;
import org.springframework.stereotype.Component;
import lombok.RequiredArgsConstructor;
@@ -23,7 +22,6 @@ import stirling.software.proprietary.policy.store.PolicyStore;
*/
@Component
@RequiredArgsConstructor
@ConditionalOnBooleanProperty(name = "policies.enabled")
public class PolicyAccessGuard {
private final UserServiceInterface userService;
@@ -7,7 +7,6 @@ import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import org.springframework.boot.autoconfigure.condition.ConditionalOnBooleanProperty;
import org.springframework.core.io.FileSystemResource;
import org.springframework.core.io.Resource;
import org.springframework.http.HttpStatus;
@@ -85,7 +84,6 @@ import stirling.software.proprietary.util.SecretMasker;
@Hidden
@RequiredArgsConstructor
@Tag(name = "Policies", description = "Run tool pipelines on the backend")
@ConditionalOnBooleanProperty(name = "policies.enabled")
public class PolicyController {
private final PolicyRunner policyRunner;
@@ -0,0 +1,85 @@
package stirling.software.proprietary.policy.controller;
import org.springframework.web.servlet.HandlerMapping;
import jakarta.servlet.http.HttpServletRequest;
/**
* Policy execute-route namespace under {@code /api/v1/policies} - the paths that actually run an
* automation ({@code /run}, {@code /run/stream}, {@code /{id}/run}, {@code /{id}/trigger}).
*
* <p>Single source of truth for both PAYG entitlement gates, so a caller without billing is blocked
* at the start of a run rather than partway through: the saas {@code EntitlementGuard} gates these
* on {@code FeatureGate.AUTOMATION}, and the self-hosted account-link {@code
* InstanceEntitlementInterceptor} treats them as billable. Read/list policy endpoints are
* deliberately excluded so the UI can still show policies and prompt on use.
*
* <p>This is the sole gate between an unentitled caller and a billable run, so the match is exact
* (not a loose suffix) and segment-anchored. {@code PolicyRunRoutesTest} asserts it against every
* mapping on {@code PolicyController}, so a new execute route that isn't classified here fails the
* build rather than silently running for free.
*/
public final class PolicyRunRoutes {
private static final String BASE = "/api/v1/policies";
private PolicyRunRoutes() {}
/**
* True when the request resolved to a policy execute endpoint. Prefers the matched route
* pattern (context-path independent, set by Spring MVC) and falls back to the raw request URI.
*/
public static boolean matches(HttpServletRequest request) {
Object pattern = request.getAttribute(HandlerMapping.BEST_MATCHING_PATTERN_ATTRIBUTE);
String path = pattern instanceof String s ? s : request.getRequestURI();
String rel = relativeToBase(path);
return rel != null && isExecuteRoute(rel);
}
/**
* The path relative to {@code /api/v1/policies}, or null if the request isn't under that base.
* Segment-anchored (the char after the base must be {@code /} or end-of-string) so a sibling
* like {@code /api/v1/policies-x/...} never matches; tolerates a leading context path.
*/
private static String relativeToBase(String path) {
if (path == null) {
return null;
}
int base = path.indexOf(BASE);
if (base < 0) {
return null;
}
int end = base + BASE.length();
if (end < path.length() && path.charAt(end) != '/') {
return null;
}
return path.substring(end);
}
/**
* The execute routes only: {@code /run}, {@code /run/stream}, and the single-segment {@code
* /{id}/run} / {@code /{id}/trigger} (template or concrete id). Read/list/CRUD routes - {@code
* /run/{runId}}, {@code /runs}, {@code /overview}, {@code /triggers}, {@code /{id}}, {@code
* /order}, {@code /{id}/processed-history}, the base list/create - are all excluded.
*/
private static boolean isExecuteRoute(String rel) {
return rel.equals("/run")
|| rel.equals("/run/stream")
|| isSingleIdRoute(rel, "run")
|| isSingleIdRoute(rel, "trigger");
}
/**
* True for exactly {@code /{oneSegment}/<verb>} (the id being a template or a concrete value).
*/
private static boolean isSingleIdRoute(String rel, String verb) {
String suffix = "/" + verb;
if (!rel.endsWith(suffix)) {
return false;
}
String idSegment = rel.substring(0, rel.length() - suffix.length());
return idSegment.length() > 1
&& idSegment.charAt(0) == '/'
&& idSegment.indexOf('/', 1) < 0;
}
}
@@ -9,7 +9,6 @@ import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ExecutorService;
import org.slf4j.MDC;
import org.springframework.boot.autoconfigure.condition.ConditionalOnBooleanProperty;
import org.springframework.core.io.Resource;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.Authentication;
@@ -57,7 +56,6 @@ import stirling.software.proprietary.service.DownstreamEntitlementError;
@Slf4j
@Service
@RequiredArgsConstructor
@ConditionalOnBooleanProperty(name = "policies.enabled")
public class PolicyEngine {
// Admission weight for one run. Weighted heavy: a run chains many tools and holds intermediate
@@ -9,7 +9,6 @@ import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit;
import org.springframework.boot.autoconfigure.condition.ConditionalOnBooleanProperty;
import org.springframework.stereotype.Service;
import jakarta.annotation.PreDestroy;
@@ -29,7 +28,6 @@ import stirling.software.proprietary.policy.model.PolicyRun;
*/
@Slf4j
@Service
@ConditionalOnBooleanProperty(name = "policies.enabled")
public class PolicyRunRegistry {
private final Map<String, PolicyRun> runs = new ConcurrentHashMap<>();
@@ -5,7 +5,6 @@ import java.util.ArrayList;
import java.util.List;
import java.util.function.Consumer;
import org.springframework.boot.autoconfigure.condition.ConditionalOnBooleanProperty;
import org.springframework.stereotype.Service;
import lombok.RequiredArgsConstructor;
@@ -35,7 +34,6 @@ import stirling.software.proprietary.policy.source.SourceStore;
@Slf4j
@Service
@RequiredArgsConstructor
@ConditionalOnBooleanProperty(name = "policies.enabled")
public class PolicyRunner {
private final PolicyEngine policyEngine;
@@ -2,7 +2,6 @@ package stirling.software.proprietary.policy.engine;
import java.util.List;
import org.springframework.boot.autoconfigure.condition.ConditionalOnBooleanProperty;
import org.springframework.stereotype.Service;
import lombok.RequiredArgsConstructor;
@@ -25,7 +24,6 @@ import stirling.software.proprietary.policy.trigger.PolicyTrigger;
*/
@Service
@RequiredArgsConstructor
@ConditionalOnBooleanProperty(name = "policies.enabled")
public class PolicyValidator {
private final List<PolicyTrigger> triggers;
@@ -14,7 +14,6 @@ import java.util.Map;
import java.util.function.Supplier;
import java.util.stream.Stream;
import org.springframework.boot.autoconfigure.condition.ConditionalOnBooleanProperty;
import org.springframework.core.io.FileSystemResource;
import org.springframework.core.io.Resource;
import org.springframework.stereotype.Service;
@@ -42,7 +41,6 @@ import stirling.software.proprietary.policy.model.PolicyInputs;
@Slf4j
@Service
@RequiredArgsConstructor
@ConditionalOnBooleanProperty(name = "policies.enabled")
public class FolderInputSource implements InputSource {
private static final String TYPE = FolderAccessGuard.FOLDER_TYPE;
@@ -6,7 +6,6 @@ import java.io.InputStream;
import java.util.ArrayList;
import java.util.List;
import org.springframework.boot.autoconfigure.condition.ConditionalOnBooleanProperty;
import org.springframework.core.io.AbstractResource;
import org.springframework.core.io.Resource;
import org.springframework.stereotype.Service;
@@ -49,7 +48,6 @@ import software.amazon.awssdk.services.s3.model.S3Object;
@Slf4j
@Service
@RequiredArgsConstructor
@ConditionalOnBooleanProperty(name = "policies.enabled")
public class S3InputSource implements InputSource {
private static final String TYPE = "s3";
@@ -7,7 +7,6 @@ import java.util.Map;
import java.util.function.Supplier;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.autoconfigure.condition.ConditionalOnBooleanProperty;
import org.springframework.boot.context.event.ApplicationReadyEvent;
import org.springframework.context.event.EventListener;
import org.springframework.dao.DataIntegrityViolationException;
@@ -24,7 +23,6 @@ import lombok.extern.slf4j.Slf4j;
*/
@Slf4j
@Service
@ConditionalOnBooleanProperty(name = "policies.enabled")
public class JpaProcessedLedger implements ProcessedLedger {
private static final int STAMP_CHUNK = 500;
@@ -15,7 +15,6 @@ import java.util.List;
import java.util.UUID;
import java.util.stream.Stream;
import org.springframework.boot.autoconfigure.condition.ConditionalOnBooleanProperty;
import org.springframework.core.io.Resource;
import org.springframework.http.MediaType;
import org.springframework.http.MediaTypeFactory;
@@ -41,7 +40,6 @@ import stirling.software.proprietary.policy.model.OutputSpec;
@Slf4j
@Service
@RequiredArgsConstructor
@ConditionalOnBooleanProperty(name = "policies.enabled")
public class FolderOutputSink implements PolicyOutputSink {
static final String TYPE = FolderAccessGuard.FOLDER_TYPE;
@@ -5,7 +5,6 @@ import java.io.InputStream;
import java.util.ArrayList;
import java.util.List;
import org.springframework.boot.autoconfigure.condition.ConditionalOnBooleanProperty;
import org.springframework.core.io.Resource;
import org.springframework.http.MediaType;
import org.springframework.http.MediaTypeFactory;
@@ -23,7 +22,6 @@ import stirling.software.proprietary.policy.model.OutputSpec;
*/
@Service
@RequiredArgsConstructor
@ConditionalOnBooleanProperty(name = "policies.enabled")
public class InlineOutputSink implements PolicyOutputSink {
private static final String TYPE = "inline";
@@ -13,7 +13,6 @@ import java.util.HexFormat;
import java.util.List;
import java.util.UUID;
import org.springframework.boot.autoconfigure.condition.ConditionalOnBooleanProperty;
import org.springframework.core.io.Resource;
import org.springframework.http.MediaType;
import org.springframework.http.MediaTypeFactory;
@@ -54,7 +53,6 @@ import software.amazon.awssdk.services.s3.model.S3Exception;
@Slf4j
@Service
@RequiredArgsConstructor
@ConditionalOnBooleanProperty(name = "policies.enabled")
public class S3OutputSink implements PolicyOutputSink {
private static final String TYPE = "s3";
@@ -5,7 +5,6 @@ import java.util.HashMap;
import java.util.List;
import java.util.Map;
import org.springframework.boot.autoconfigure.condition.ConditionalOnBooleanProperty;
import org.springframework.stereotype.Service;
import lombok.RequiredArgsConstructor;
@@ -29,7 +28,6 @@ import stirling.software.proprietary.policy.store.PolicyStore;
*/
@Service
@RequiredArgsConstructor
@ConditionalOnBooleanProperty(name = "policies.enabled")
public class PolicyOverviewService {
private final PolicyStore policyStore;
@@ -4,7 +4,6 @@ import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import org.springframework.boot.autoconfigure.condition.ConditionalOnBooleanProperty;
import org.springframework.boot.context.event.ApplicationReadyEvent;
import org.springframework.context.event.EventListener;
import org.springframework.stereotype.Component;
@@ -32,8 +31,8 @@ import tools.jackson.databind.ObjectMapper;
* One-time, idempotent extraction of legacy embedded S3 credentials into stored connections:
* sources and policy outputs written before connections shipped carry bucket/credentials in their
* own options; this rewrites each to reference a (deduplicated) S3 {@link IntegrationConfig} and
* keeps only per-use options (prefix, mode). MUST be programmatic - the option JSON is encrypted at
* the application layer, so no SQL migration can read it.
* keeps only per-use options (prefix, mode). MUST be programmatic - it parses and rewrites the
* option JSON (and decrypts any legacy ciphertext row on read), which no SQL migration can do.
*
* <p>Idempotent by construction: rewritten rows no longer embed credentials, so re-runs find
* nothing to do. Connections are deduplicated against both this run's extractions and existing S3
@@ -44,7 +43,6 @@ import tools.jackson.databind.ObjectMapper;
@Slf4j
@Component
@RequiredArgsConstructor
@ConditionalOnBooleanProperty(name = "policies.enabled")
public class EmbeddedS3CredentialMigration {
private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();
@@ -4,7 +4,6 @@ import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import org.springframework.boot.autoconfigure.condition.ConditionalOnBooleanProperty;
import org.springframework.stereotype.Component;
import lombok.RequiredArgsConstructor;
@@ -23,7 +22,6 @@ import stirling.software.proprietary.policy.store.PolicyStore;
*/
@Component
@RequiredArgsConstructor
@ConditionalOnBooleanProperty(name = "policies.enabled")
public class PolicyS3ConnectionUsageCheck implements IntegrationConfigUsageCheck {
private final SourceStore sourceStore;
@@ -6,7 +6,6 @@ import java.util.concurrent.ConcurrentHashMap;
import java.util.function.Function;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.autoconfigure.condition.ConditionalOnBooleanProperty;
import org.springframework.stereotype.Service;
import jakarta.annotation.PreDestroy;
@@ -32,7 +31,6 @@ import software.amazon.awssdk.services.s3.S3Configuration;
* users rather than the operator.
*/
@Service
@ConditionalOnBooleanProperty(name = "policies.enabled")
public class S3ConnectionPool {
private final ApplicationProperties applicationProperties;
@@ -3,7 +3,6 @@ package stirling.software.proprietary.policy.s3;
import java.util.LinkedHashMap;
import java.util.Map;
import org.springframework.boot.autoconfigure.condition.ConditionalOnBooleanProperty;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.core.userdetails.UserDetails;
@@ -39,7 +38,6 @@ import tools.jackson.databind.ObjectMapper;
@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
@ConditionalOnBooleanProperty(name = "policies.enabled")
public class S3ConnectionResolver {
static final String CONNECTION_ID_OPTION = "connectionId";
@@ -3,7 +3,6 @@ package stirling.software.proprietary.policy.s3;
import java.net.URI;
import java.util.Map;
import org.springframework.boot.autoconfigure.condition.ConditionalOnBooleanProperty;
import org.springframework.stereotype.Component;
import lombok.RequiredArgsConstructor;
@@ -21,7 +20,6 @@ import stirling.software.proprietary.integration.service.IntegrationConfigValida
*/
@Component
@RequiredArgsConstructor
@ConditionalOnBooleanProperty(name = "policies.enabled")
public class S3IntegrationValidator implements IntegrationConfigValidator {
private final ApplicationProperties applicationProperties;
@@ -10,7 +10,6 @@ import java.util.function.IntSupplier;
import java.util.function.Supplier;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.autoconfigure.condition.ConditionalOnBooleanProperty;
import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Service;
@@ -24,7 +23,6 @@ import org.springframework.stereotype.Service;
* table stays bounded (~one row per source per active hour, for at most 30 days).
*/
@Service
@ConditionalOnBooleanProperty(name = "policies.enabled")
public class JpaSourceDocCounter implements SourceDocCounter {
private final SourceDocCountRepository countRepository;
@@ -4,10 +4,10 @@ import java.util.List;
import java.util.Optional;
import java.util.UUID;
import org.springframework.boot.autoconfigure.condition.ConditionalOnBooleanProperty;
import org.springframework.stereotype.Service;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import tools.jackson.databind.ObjectMapper;
@@ -15,9 +15,9 @@ import tools.jackson.databind.ObjectMapper;
* Durable {@link SourceStore} backed by JPA; the runtime store. Sources are persisted as JSON via
* {@link SourceEntity}, with scalar columns kept in sync for querying.
*/
@Slf4j
@Service
@RequiredArgsConstructor
@ConditionalOnBooleanProperty(name = "policies.enabled")
public class JpaSourceStore implements SourceStore {
private final SourceRepository repository;
@@ -53,17 +53,20 @@ public class JpaSourceStore implements SourceStore {
@Override
public Optional<Source> get(String id) {
return repository.findById(id).map(this::toSource);
return repository.findById(id).flatMap(this::toSource);
}
@Override
public List<Source> all() {
return repository.findAll().stream().map(this::toSource).toList();
return repository.findAll().stream().map(this::toSource).flatMap(Optional::stream).toList();
}
@Override
public List<Source> findByTeam(Long teamId) {
return repository.findByTeam(teamId).stream().map(this::toSource).toList();
return repository.findByTeam(teamId).stream()
.map(this::toSource)
.flatMap(Optional::stream)
.toList();
}
@Override
@@ -75,7 +78,20 @@ public class JpaSourceStore implements SourceStore {
return true;
}
private Source toSource(SourceEntity entity) {
return objectMapper.readValue(entity.getSourceJson(), Source.class);
// Skip (don't fail) rows whose JSON can't be read - e.g. written by another app version/key.
// One unreadable row must never abort a bulk read or crash startup.
private Optional<Source> toSource(SourceEntity entity) {
try {
return Optional.of(objectMapper.readValue(entity.getSourceJson(), Source.class));
} catch (Exception e) {
log.error(
"Skipping unreadable policy source id={} name={}: stored JSON could not be"
+ " parsed ({}). Likely written by a different app version or"
+ " encryption key.",
entity.getId(),
entity.getName(),
e.getMessage());
return Optional.empty();
}
}
}
@@ -3,7 +3,6 @@ package stirling.software.proprietary.policy.source;
import java.util.List;
import java.util.Objects;
import org.springframework.boot.autoconfigure.condition.ConditionalOnBooleanProperty;
import org.springframework.stereotype.Component;
import lombok.RequiredArgsConstructor;
@@ -20,7 +19,6 @@ import stirling.software.proprietary.policy.config.PolicyManagementAuthority;
*/
@Component
@RequiredArgsConstructor
@ConditionalOnBooleanProperty(name = "policies.enabled")
public class SourceAccessGuard {
private final UserServiceInterface userService;
@@ -3,7 +3,6 @@ package stirling.software.proprietary.policy.source;
import java.util.List;
import java.util.Map;
import org.springframework.boot.autoconfigure.condition.ConditionalOnBooleanProperty;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
@@ -43,7 +42,6 @@ import stirling.software.proprietary.util.SecretMasker;
@Hidden
@RequiredArgsConstructor
@Tag(name = "Sources", description = "Reusable policy input connections")
@ConditionalOnBooleanProperty(name = "policies.enabled")
public class SourceController {
private final SourceStore sourceStore;
@@ -12,7 +12,7 @@ import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import stirling.software.proprietary.integration.crypto.LenientEncryptedStringConverter;
import stirling.software.proprietary.integration.crypto.LegacyDecryptStringConverter;
/**
* JPA row for a {@link Source}. The whole source lives as JSON in {@code sourceJson} (authoritative
@@ -48,9 +48,9 @@ public class SourceEntity implements Serializable {
@Column(name = "enabled")
private boolean enabled;
// Encrypted at rest: source options carry user-supplied credentials (e.g. an S3 secret
// access key). Lenient so rows written before encryption shipped still load.
@Convert(converter = LenientEncryptedStringConverter.class)
// Plaintext at rest: the S3 credentials that used to live here now sit in a referenced
// IntegrationConfig connection (still encrypted). Decrypts legacy ciphertext on read.
@Convert(converter = LegacyDecryptStringConverter.class)
@Column(name = "source_json", columnDefinition = "text")
private String sourceJson;
}
@@ -6,7 +6,6 @@ import java.util.HashMap;
import java.util.List;
import java.util.Map;
import org.springframework.boot.autoconfigure.condition.ConditionalOnBooleanProperty;
import org.springframework.stereotype.Service;
import lombok.RequiredArgsConstructor;
@@ -24,7 +23,6 @@ import stirling.software.proprietary.util.SecretMasker;
*/
@Service
@RequiredArgsConstructor
@ConditionalOnBooleanProperty(name = "policies.enabled")
public class SourceOverviewService {
private final SourceStore sourceStore;
@@ -5,11 +5,11 @@ import java.util.Objects;
import java.util.Optional;
import java.util.UUID;
import org.springframework.boot.autoconfigure.condition.ConditionalOnBooleanProperty;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import stirling.software.proprietary.policy.model.Policy;
@@ -19,9 +19,9 @@ import tools.jackson.databind.ObjectMapper;
* Durable {@link PolicyStore} backed by JPA; the runtime store. Policies are persisted as JSON via
* {@link PolicyEntity}, with scalar columns kept in sync for querying.
*/
@Slf4j
@Service
@RequiredArgsConstructor
@ConditionalOnBooleanProperty(name = "policies.enabled")
public class JpaPolicyStore implements PolicyStore {
private final PolicyRepository repository;
@@ -98,23 +98,30 @@ public class JpaPolicyStore implements PolicyStore {
@Override
public Optional<Policy> get(String id) {
return repository.findById(id).map(this::toPolicy);
return repository.findById(id).flatMap(this::toPolicy);
}
@Override
public List<Policy> all() {
return repository.findAllOrdered().stream().map(this::toPolicy).toList();
return repository.findAllOrdered().stream()
.map(this::toPolicy)
.flatMap(Optional::stream)
.toList();
}
@Override
public List<Policy> findByTeam(Long teamId) {
return repository.findByTeam(teamId).stream().map(this::toPolicy).toList();
return repository.findByTeam(teamId).stream()
.map(this::toPolicy)
.flatMap(Optional::stream)
.toList();
}
@Override
public List<Policy> findByTriggerType(String triggerType) {
return repository.findByTriggerTypeAndEnabledTrue(triggerType).stream()
.map(this::toPolicy)
.flatMap(Optional::stream)
.toList();
}
@@ -127,7 +134,19 @@ public class JpaPolicyStore implements PolicyStore {
return true;
}
private Policy toPolicy(PolicyEntity entity) {
return objectMapper.readValue(entity.getPolicyJson(), Policy.class);
// Skip (don't fail) rows whose JSON can't be read - e.g. written by another app version/key.
// One unreadable row must never abort a bulk read or crash startup.
private Optional<Policy> toPolicy(PolicyEntity entity) {
try {
return Optional.of(objectMapper.readValue(entity.getPolicyJson(), Policy.class));
} catch (Exception e) {
log.error(
"Skipping unreadable policy id={} name={}: stored JSON could not be parsed"
+ " ({}). Likely written by a different app version or encryption key.",
entity.getId(),
entity.getName(),
e.getMessage());
return Optional.empty();
}
}
}
@@ -12,7 +12,7 @@ import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import stirling.software.proprietary.integration.crypto.LenientEncryptedStringConverter;
import stirling.software.proprietary.integration.crypto.LegacyDecryptStringConverter;
/**
* JPA row for a {@link stirling.software.proprietary.policy.model.Policy}. The whole policy lives
@@ -58,9 +58,9 @@ public class PolicyEntity implements Serializable {
@Column(name = "sort_order")
private Integer sortOrder;
// Encrypted at rest: output options carry user-supplied credentials (e.g. an S3 secret
// access key). Lenient so rows written before encryption shipped still load.
@Convert(converter = LenientEncryptedStringConverter.class)
// Plaintext at rest: the S3 credentials that used to live here now sit in a referenced
// IntegrationConfig connection (still encrypted). Decrypts legacy ciphertext on read.
@Convert(converter = LegacyDecryptStringConverter.class)
@Column(name = "policy_json", columnDefinition = "text")
private String policyJson;
}
@@ -20,7 +20,6 @@ import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit;
import org.springframework.boot.autoconfigure.condition.ConditionalOnBooleanProperty;
import org.springframework.stereotype.Service;
import lombok.RequiredArgsConstructor;
@@ -50,7 +49,6 @@ import stirling.software.proprietary.policy.store.PolicyStore;
@Slf4j
@Service
@RequiredArgsConstructor
@ConditionalOnBooleanProperty(name = "policies.enabled")
public class FolderWatchTrigger implements PolicyTrigger {
private static final String TYPE = "folder-watch";
@@ -2,7 +2,6 @@ package stirling.software.proprietary.policy.trigger;
import java.util.List;
import org.springframework.boot.autoconfigure.condition.ConditionalOnBooleanProperty;
import org.springframework.context.SmartLifecycle;
import org.springframework.stereotype.Service;
@@ -13,7 +12,6 @@ import lombok.extern.slf4j.Slf4j;
@Slf4j
@Service
@RequiredArgsConstructor
@ConditionalOnBooleanProperty(name = "policies.enabled")
public class PolicyTriggerManager implements SmartLifecycle {
private final List<PolicyTrigger> triggers;
@@ -10,7 +10,6 @@ import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit;
import org.springframework.boot.autoconfigure.condition.ConditionalOnBooleanProperty;
import org.springframework.stereotype.Service;
import lombok.RequiredArgsConstructor;
@@ -32,7 +31,6 @@ import tools.jackson.databind.ObjectMapper;
@Slf4j
@Service
@RequiredArgsConstructor
@ConditionalOnBooleanProperty(name = "policies.enabled")
public class ScheduleTrigger implements PolicyTrigger {
private static final String TYPE = "schedule";
@@ -155,6 +155,43 @@ class InstanceEntitlementInterceptorTest {
verifyNoInteractions(entitlementCache);
}
@Test
void gatesPolicyRunUpFrontEvenWithoutAutomationHeader() throws Exception {
// The policy /run call carries no automation header, but must be blocked up front (not
// after its first tool) when the instance is unlinked.
when(gate.evaluate(anyBoolean()))
.thenReturn(GateDecision.block(GateDecision.Reason.NOT_LINKED));
InstanceEntitlementInterceptor interceptor = interceptor();
MockHttpServletRequest req =
new MockHttpServletRequest("POST", "/api/v1/policies/pol-1/run");
MockHttpServletResponse resp = new MockHttpServletResponse();
assertFalse(interceptor.preHandle(req, resp, new Object()));
assertEquals(HttpStatus.PAYMENT_REQUIRED.value(), resp.getStatus());
assertTrue(resp.getContentAsString().contains("ACCOUNT_LINK_REQUIRED"));
verify(gate).evaluate(true); // gated as billable despite no automation header
}
@Test
void doesNotMeterThePolicyRunEndpointItself() throws Exception {
// Gated up front, but metered only via its dispatched tool sub-steps (category BYPASSED
// here), so the /run request itself never accrues usage.
when(gate.evaluate(anyBoolean()))
.thenReturn(GateDecision.allow(GateDecision.Reason.ENTITLED));
UsageMeterService meter = mock(UsageMeterService.class);
when(meterProvider.getIfAvailable()).thenReturn(meter);
InstanceEntitlementInterceptor interceptor = interceptor();
MockHttpServletRequest req =
new MockHttpServletRequest("POST", "/api/v1/policies/pol-1/run");
MockHttpServletResponse resp = new MockHttpServletResponse();
interceptor.preHandle(req, resp, new Object());
interceptor.afterCompletion(req, resp, new Object(), null);
verifyNoInteractions(meter);
}
private static InstanceEntitlement entitled(UnitCalcPolicy policy, LocalDateTime period) {
return new InstanceEntitlement(
true, 0, 0, 100L, EntitlementState.OK, policy, period, period.plusMonths(1));
@@ -0,0 +1,51 @@
package stirling.software.proprietary.integration.crypto;
import static org.assertj.core.api.Assertions.assertThat;
import javax.crypto.KeyGenerator;
import javax.crypto.SecretKey;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
class LegacyDecryptStringConverterTest {
private final LegacyDecryptStringConverter converter = new LegacyDecryptStringConverter();
@BeforeAll
static void initKey() throws Exception {
KeyGenerator generator = KeyGenerator.getInstance("AES");
generator.init(256);
SecretKey key = generator.generateKey();
CredentialEncryption.initialiseForTesting(key);
}
@Test
void writesPlaintext() {
String json = "{\"bucket\":\"inbox\",\"mode\":\"consume\"}";
assertThat(converter.convertToDatabaseColumn(json)).isEqualTo(json);
}
@Test
void decryptsLegacyCiphertextOnRead() {
String json = "{\"bucket\":\"inbox\"}";
String legacyCiphertext = CredentialEncryption.encrypt(json);
assertThat(legacyCiphertext).isNotEqualTo(json);
assertThat(converter.convertToEntityAttribute(legacyCiphertext)).isEqualTo(json);
}
@Test
void passesPlaintextThroughOnRead() {
String json = "{\"bucket\":\"inbox\",\"mode\":\"consume\"}";
assertThat(converter.convertToEntityAttribute(json)).isEqualTo(json);
}
@Test
void nullsPassThrough() {
assertThat(converter.convertToDatabaseColumn(null)).isNull();
assertThat(converter.convertToEntityAttribute(null)).isNull();
}
}
@@ -1,45 +0,0 @@
package stirling.software.proprietary.integration.crypto;
import static org.assertj.core.api.Assertions.assertThat;
import javax.crypto.KeyGenerator;
import javax.crypto.SecretKey;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
class LenientEncryptedStringConverterTest {
private final LenientEncryptedStringConverter converter = new LenientEncryptedStringConverter();
@BeforeAll
static void initKey() throws Exception {
KeyGenerator generator = KeyGenerator.getInstance("AES");
generator.init(256);
SecretKey key = generator.generateKey();
CredentialEncryption.initialiseForTesting(key);
}
@Test
void roundTripsThroughCiphertext() {
String json = "{\"bucket\":\"inbox\",\"secretAccessKey\":\"shh\"}";
String stored = converter.convertToDatabaseColumn(json);
assertThat(stored).isNotEqualTo(json).doesNotContain("shh");
assertThat(converter.convertToEntityAttribute(stored)).isEqualTo(json);
}
@Test
void legacyPlaintextRowsPassThroughOnRead() {
String legacy = "{\"bucket\":\"inbox\",\"mode\":\"consume\"}";
assertThat(converter.convertToEntityAttribute(legacy)).isEqualTo(legacy);
}
@Test
void nullsPassThrough() {
assertThat(converter.convertToDatabaseColumn(null)).isNull();
assertThat(converter.convertToEntityAttribute(null)).isNull();
}
}
@@ -0,0 +1,127 @@
package stirling.software.proprietary.policy.controller;
import static org.assertj.core.api.Assertions.assertThat;
import java.util.Arrays;
import java.util.Map;
import org.junit.jupiter.api.Test;
import org.springframework.core.annotation.AnnotatedElementUtils;
import org.springframework.mock.web.MockHttpServletRequest;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.servlet.HandlerMapping;
class PolicyRunRoutesTest {
private static boolean matchesUri(String uri) {
MockHttpServletRequest req = new MockHttpServletRequest();
req.setRequestURI(uri);
return PolicyRunRoutes.matches(req);
}
private static boolean matchesPattern(String pattern) {
MockHttpServletRequest req = new MockHttpServletRequest();
req.setAttribute(HandlerMapping.BEST_MATCHING_PATTERN_ATTRIBUTE, pattern);
return PolicyRunRoutes.matches(req);
}
@Test
void matchesTheFourExecuteRoutes() {
assertThat(matchesUri("/api/v1/policies/run")).isTrue();
assertThat(matchesUri("/api/v1/policies/run/stream")).isTrue();
assertThat(matchesUri("/api/v1/policies/pol-123/run")).isTrue();
assertThat(matchesUri("/api/v1/policies/pol-123/trigger")).isTrue();
}
@Test
void excludesReadListAndCrudRoutes() {
assertThat(matchesUri("/api/v1/policies")).isFalse(); // list + create
assertThat(matchesUri("/api/v1/policies/runs")).isFalse();
assertThat(matchesUri("/api/v1/policies/run/abc-run-id")).isFalse(); // GET /run/{runId}
assertThat(matchesUri("/api/v1/policies/overview")).isFalse();
assertThat(matchesUri("/api/v1/policies/triggers")).isFalse(); // NB: not "/trigger"
assertThat(matchesUri("/api/v1/policies/order")).isFalse();
assertThat(matchesUri("/api/v1/policies/pol-123")).isFalse();
assertThat(matchesUri("/api/v1/policies/pol-123/processed-history")).isFalse();
}
@Test
void isSegmentAnchoredAndContextPathTolerant() {
assertThat(matchesUri("/stirling/api/v1/policies/pol-123/run")).isTrue();
assertThat(matchesUri("/api/v1/policies-x/pol-123/run"))
.isFalse(); // sibling, not the segment
assertThat(matchesUri("/api/v1/sources/pol/run")).isFalse();
assertThat(matchesUri("/api/v1/misc/compress-pdf")).isFalse();
}
/** Every request mapping on PolicyController, and whether it executes an automation. */
private static final Map<String, Boolean> EXPECTED =
Map.of(
"/api/v1/policies", false, // base: list (GET) + create (POST)
"/api/v1/policies/run", true,
"/api/v1/policies/run/stream", true,
"/api/v1/policies/run/{runId}", false,
"/api/v1/policies/runs", false,
"/api/v1/policies/order", false,
"/api/v1/policies/overview", false,
"/api/v1/policies/triggers", false,
"/api/v1/policies/{policyId}", false, // GET + DELETE
"/api/v1/policies/{policyId}/processed-history", false);
// Split out because Map.of caps at 10 entries; the execute {id} routes live here.
private static final Map<String, Boolean> EXPECTED_ID_EXECUTES =
Map.of(
"/api/v1/policies/{policyId}/run", true,
"/api/v1/policies/{policyId}/trigger", true);
/**
* Fail-safe: this matcher is the sole billing gate, so an unmatched execute route would run
* automations for free. Reconstruct every mapping on PolicyController and assert its
* classification is declared above - a new/renamed route lands as "unclassified" and fails the
* build until someone decides whether it executes an automation.
*/
@Test
void everyControllerMappingIsClassified() {
String base = classMapping();
Arrays.stream(PolicyController.class.getDeclaredMethods())
.filter(m -> AnnotatedElementUtils.hasAnnotation(m, RequestMapping.class))
.forEach(
m -> {
String pattern = base + methodMapping(m);
Boolean expected = expectedFor(pattern);
assertThat(expected)
.as(
"unclassified PolicyController route %s - add it to"
+ " PolicyRunRoutesTest.EXPECTED",
pattern)
.isNotNull();
assertThat(matchesPattern(pattern))
.as("PolicyRunRoutes classification of %s", pattern)
.isEqualTo(expected);
});
}
private static Boolean expectedFor(String pattern) {
if (EXPECTED.containsKey(pattern)) {
return EXPECTED.get(pattern);
}
return EXPECTED_ID_EXECUTES.get(pattern);
}
private static String classMapping() {
RequestMapping rm =
AnnotatedElementUtils.getMergedAnnotation(
PolicyController.class, RequestMapping.class);
return rm == null ? "" : firstOrEmpty(rm);
}
private static String methodMapping(java.lang.reflect.Method m) {
RequestMapping rm = AnnotatedElementUtils.getMergedAnnotation(m, RequestMapping.class);
return rm == null ? "" : firstOrEmpty(rm);
}
private static String firstOrEmpty(RequestMapping rm) {
String[] paths = rm.path().length > 0 ? rm.path() : rm.value();
return paths.length > 0 ? paths[0] : "";
}
}
@@ -31,6 +31,7 @@ import jakarta.servlet.http.HttpServletResponse;
import lombok.extern.slf4j.Slf4j;
import stirling.software.common.annotations.AutoJobPostMapping;
import stirling.software.proprietary.policy.controller.PolicyRunRoutes;
import stirling.software.proprietary.security.database.repository.UserRepository;
import stirling.software.proprietary.security.model.ApiKeyAuthenticationToken;
import stirling.software.proprietary.security.model.User;
@@ -46,9 +47,11 @@ import stirling.software.saas.util.AuthenticationUtils;
*
* <p>Scope: routes whose handler method (or bean type) carries either {@link AutoJobPostMapping}
* (multipart tool POSTs) or {@link RequiresFeature} (AI controllers, future non-multipart gated
* routes). Admin / info / config endpoints are excluded by the path-pattern in {@code
* PaygWebMvcConfig} and are additionally skipped here when they carry neither annotation, so non-
* billable infra never trips the guard.
* routes), plus two proprietary route families recognised by path since they can't carry the
* annotation: AI document tools ({@link AiToolRoutes} gated on AI_SUPPORT) and policy execute
* endpoints ({@link PolicyRunRoutes} gated on AUTOMATION). Admin / info / config endpoints are
* excluded by the path-pattern in {@code PaygWebMvcConfig} and are additionally skipped here when
* they carry no annotation and match no such family, so non-billable infra never trips the guard.
*
* <p>Decision matrix:
*
@@ -137,13 +140,20 @@ public class EntitlementGuard implements HandlerInterceptor {
// @RequiresFeature; recognise them by path so they're gated on AI_SUPPORT — see
// AiToolRoutes and PaygChargeInterceptor, which classify the same routes as AI.
boolean aiToolRoute = AiToolRoutes.matches(request);
if (!hasAutoJobPostMapping && !hasRequiresFeature && !aiToolRoute) {
// Policy execute routes (/api/v1/policies/**/run etc.) are proprietary and can't carry
// @RequiresFeature; recognise them by path and gate on AUTOMATION (mirrors aiToolRoute).
boolean policyRunRoute = PolicyRunRoutes.matches(request);
if (!hasAutoJobPostMapping && !hasRequiresFeature && !aiToolRoute && !policyRunRoute) {
skippedNoAnnotationCounter.increment();
return true;
}
FeatureGate[] required =
aiToolRoute ? new FeatureGate[] {FeatureGate.AI_SUPPORT} : resolveRequiredGates(hm);
aiToolRoute
? new FeatureGate[] {FeatureGate.AI_SUPPORT}
: policyRunRoute
? new FeatureGate[] {FeatureGate.AUTOMATION}
: resolveRequiredGates(hm);
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
boolean anonymous = isAnonymous(auth);
@@ -74,10 +74,6 @@ supabase.url=https://${app.supabase.project-ref}.supabase.co
spring.security.oauth2.resourceserver.jwt.jwk-set-uri=https://${app.supabase.project-ref}.supabase.co/auth/v1/.well-known/jwks.json
spring.security.oauth2.resourceserver.jwt.audiences=${app.supabase.expected-aud}
# ---------- Policies ----------
# Exposes the /api/v1/policies and /api/v1/sources controllers, engine, stores, and triggers.
policies.enabled=true
# ---------- Multi-tenant scoping ----------
# Restrict the signing user picker to the caller's team; SaaS must be 'team'
# or unrelated tenants leak emails to each other.
@@ -1,4 +1,4 @@
-- Policy engine schema (gated by policies.enabled): persisted policies and the reusable input
-- Policy engine schema: persisted policies and the reusable input
-- connections ("sources") they reference by id. The whole policy/source lives as JSON in the
-- *_json column (authoritative on read); the scalar columns are denormalized copies for querying,
-- notably team_id so a caller's team can be loaded without scanning every team's rows. owner and
@@ -9,8 +9,8 @@
-- instead of scanning a source's whole bucket history - and so the
-- hourly buckets can be pruned without losing it.
--
-- Gated by policies.enabled like the rest of the subsystem; Hibernate ddl-auto would also create
-- these, but the migration keeps the schema explicit for the Flyway-managed deployments.
-- Hibernate ddl-auto would also create these, but the migration keeps the schema explicit for the
-- Flyway-managed deployments.
CREATE TABLE IF NOT EXISTS policy_source_doc_counts (
source_id VARCHAR(255) NOT NULL,
@@ -1,4 +1,4 @@
-- Classification labels (gated by policies.enabled): the flat multi-label vocabulary the document
-- Classification labels: the flat multi-label vocabulary the document
-- classifier runs against. One admin-editable row per team. The whole label set lives as JSON in
-- labels_json (authoritative on read). team_id is a natural key and a plain value (not a foreign
-- key) to stay decoupled from the security entities, so classification can be enabled or disabled
@@ -10,8 +10,8 @@
-- the policy's sources, so the table stays near the set of files
-- currently present.
--
-- Gated by policies.enabled like the rest of the subsystem; Hibernate ddl-auto would also create
-- this, but the migration keeps the schema explicit for the Flyway-managed deployments.
-- Hibernate ddl-auto would also create this, but the migration keeps the schema explicit for the
-- Flyway-managed deployments.
CREATE TABLE IF NOT EXISTS policy_processed_files (
policy_id VARCHAR(255) NOT NULL,
@@ -198,6 +198,97 @@ class EntitlementGuardTest {
assertThat(body.get("category").asText()).isEqualTo("AI");
}
// ---------------------------------------------------------------------------------------
// Policy execute routes (proprietary; recognised by path, gated on AUTOMATION)
// ---------------------------------------------------------------------------------------
@Test
void policyRunRoute_noAnnotation_isInScopeAndGatedOnAutomation() throws Exception {
UUID supabaseId = UUID.randomUUID();
SecurityContextHolder.getContext().setAuthentication(jwtAuth(supabaseId));
when(userRepository.findBySupabaseId(supabaseId))
.thenReturn(Optional.of(userWithTeam(7L, 42L)));
when(entitlementService.getSnapshot(42L)).thenReturn(degradedSnapshot());
HandlerMethod hm = handlerFor("plainEndpoint"); // no annotations
MockHttpServletRequest req = new MockHttpServletRequest();
req.setRequestURI("/api/v1/policies/pol-123/run");
MockHttpServletResponse res = new MockHttpServletResponse();
boolean proceed = guard.preHandle(req, res, hm);
assertThat(proceed).isFalse();
assertThat(res.getStatus()).isEqualTo(402);
JsonNode body = json.readTree(res.getContentAsByteArray());
assertThat(body.get("error").asText()).isEqualTo("FEATURE_DEGRADED");
assertThat(body.get("missingGates").get(0).asText()).isEqualTo("AUTOMATION");
verify(entitlementService).getSnapshot(42L);
}
@Test
void policyRunRoute_anonymous_returns401WithAutomationCategory() throws Exception {
SecurityContextHolder.getContext()
.setAuthentication(
new AnonymousAuthenticationToken(
"key",
"anonymousUser",
List.of(new SimpleGrantedAuthority("ROLE_ANONYMOUS"))));
HandlerMethod hm = handlerFor("plainEndpoint");
MockHttpServletRequest req = new MockHttpServletRequest();
req.setRequestURI("/api/v1/policies/pol-123/trigger");
MockHttpServletResponse res = new MockHttpServletResponse();
boolean proceed = guard.preHandle(req, res, hm);
assertThat(proceed).isFalse();
assertThat(res.getStatus()).isEqualTo(401);
JsonNode body = json.readTree(res.getContentAsByteArray());
assertThat(body.get("error").asText()).isEqualTo("SIGNUP_REQUIRED");
assertThat(body.get("category").asText()).isEqualTo("AUTOMATION");
}
@Test
void policyRunRoute_authenticatedFull_passesThrough() throws Exception {
UUID supabaseId = UUID.randomUUID();
SecurityContextHolder.getContext().setAuthentication(jwtAuth(supabaseId));
when(userRepository.findBySupabaseId(supabaseId))
.thenReturn(Optional.of(userWithTeam(7L, 42L)));
when(entitlementService.getSnapshot(42L)).thenReturn(fullSnapshot());
HandlerMethod hm = handlerFor("plainEndpoint");
MockHttpServletRequest req = new MockHttpServletRequest();
req.setRequestURI("/api/v1/policies/run");
MockHttpServletResponse res = new MockHttpServletResponse();
boolean proceed = guard.preHandle(req, res, hm);
assertThat(proceed).isTrue();
assertThat(res.getStatus()).isEqualTo(200);
}
@Test
void policyReadRoute_notGated_passesThroughEvenDegraded() throws Exception {
// Listing policies must stay ungated so the UI can show them and prompt on use.
SecurityContextHolder.getContext()
.setAuthentication(
new AnonymousAuthenticationToken(
"key",
"anonymousUser",
List.of(new SimpleGrantedAuthority("ROLE_ANONYMOUS"))));
HandlerMethod hm = handlerFor("plainEndpoint");
MockHttpServletRequest req = new MockHttpServletRequest();
req.setRequestURI("/api/v1/policies");
MockHttpServletResponse res = new MockHttpServletResponse();
boolean proceed = guard.preHandle(req, res, hm);
assertThat(proceed).isTrue();
assertThat(res.getStatus()).isEqualTo(200);
Mockito.verifyNoInteractions(entitlementService);
}
// ---------------------------------------------------------------------------------------
// Anonymous user
// ---------------------------------------------------------------------------------------
+3 -2
View File
@@ -8,8 +8,9 @@ dependencies = [
"pgvector>=0.3.6",
"psycopg[binary,pool]>=3.2",
"pydantic>=2.0.0",
"pydantic-ai>=1.67.0",
"pydantic-ai-slim[voyageai]>=1.67.0",
# <2 cap: 1.99.0 patches CVE-2026-46678; 2.0 is an untested major migration
"pydantic-ai>=1.99.0,<2.0.0",
"pydantic-ai-slim[voyageai]>=1.99.0,<2.0.0",
"pydantic-settings>=2.0.0",
"python-dotenv>=1.2.1",
"sqlite-vec>=0.1.6",
+469 -332
View File
File diff suppressed because it is too large Load Diff
+4
View File
@@ -38,6 +38,10 @@ const config: StorybookConfig = {
// modules (e.g. the auth supabase client that moved into proprietary).
"@proprietary": resolve(__dirname, "../editor/src/proprietary"),
"@core": resolve(__dirname, "../editor/src/core"),
// Public assets (e.g. the en-US translation TOML loaded ?raw by preview.tsx).
// No src alias covers public/, so this lets the config use an alias rather
// than a relative path.
"@public": resolve(__dirname, "../editor/public"),
};
// Editor stories import via @app/* (proprietary→core fallback), @core/* and
// @proprietary/*. Resolve them exactly the way the editor's own build does —
+17 -2
View File
@@ -24,11 +24,11 @@ import { initReactI18next } from "react-i18next";
import { parse as parseToml } from "smol-toml";
// Load the real English copy so stories render human text, not raw keys. Bundled
// synchronously via ?raw so it's present on the very first render (no async flash).
// eslint-disable-next-line no-restricted-imports -- Storybook-only: read the public i18n asset; no @-alias covers editor/public/.
import enTranslationToml from "../editor/public/locales/en-US/translation.toml?raw";
import enTranslationToml from "@public/locales/en-US/translation.toml?raw";
import "@mantine/core/styles.css";
import "@core/tokens/tokens.css";
import "@core/theme/index.css";
import "@core/tokens/base.css";
// Storybook-only: init react-i18next with the real English resources parsed from
@@ -124,6 +124,20 @@ function ThemeBridge({
return <>{children}</>;
}
/**
* Sets the theme attributes colors.css needs — always `data-app-theme="custom"`
* with the fixed default accent (data-accent="default"), matching the editor.
*/
function SchemeSetup({ scheme }: { scheme: "light" | "dark" }) {
useEffect(() => {
const root = document.documentElement;
root.setAttribute("data-app-theme", "custom");
root.setAttribute("data-accent", "default");
root.setAttribute("data-mantine-color-scheme", scheme);
}, [scheme]);
return null;
}
const withProviders: Decorator = (Story, context) => {
const tier = (context.globals.tier as Tier) ?? "pro";
const linkState =
@@ -138,6 +152,7 @@ const withProviders: Decorator = (Story, context) => {
return (
<MemoryRouter initialEntries={["/"]}>
<ThemeProvider>
<SchemeSetup scheme={colorScheme} />
<ThemeBridge theme={colorScheme}>
<SuiProvider colorScheme={colorScheme}>
{/* LinkProvider must wrap TierProvider: TierContext derives its tier
+2 -1
View File
@@ -12,7 +12,8 @@
],
"@core/*": ["../editor/src/core/*"],
"@proprietary/*": ["../editor/src/proprietary/*"],
"@portal/*": ["../editor/src/portal/*"]
"@portal/*": ["../editor/src/portal/*"],
"@public/*": ["../editor/public/*"]
},
"resolveJsonModule": true,
"esModuleInterop": true,
+28
View File
@@ -14,6 +14,34 @@
<link rel="manifest" href="manifest.json" />
<title>Stirling PDF</title>
<!-- Apply theme before first paint (no FOUC); mirrors ThemeProvider, which then refines it. -->
<script>
(function () {
try {
var p = JSON.parse(
localStorage.getItem("stirlingpdf_preferences") || "{}",
);
var mode = p.theme || "system";
var scheme =
mode === "light"
? "light"
: mode === "dark"
? "dark"
: window.matchMedia &&
window.matchMedia("(prefers-color-scheme: dark)").matches
? "dark"
: "light";
var r = document.documentElement;
r.setAttribute("data-theme", scheme);
r.setAttribute("data-app-theme", "custom");
r.setAttribute("data-accent", "default");
r.setAttribute("data-mantine-color-scheme", scheme);
} catch (e) {
/* first visit / unavailable storage: ThemeProvider handles it */
}
})();
</script>
</head>
<body>
<noscript>You need to enable JavaScript to run this app.</noscript>
@@ -6521,63 +6521,6 @@ title_other = "عالِج {{allowance}} ملفات PDF مجانًا"
titleWithRate_one = "عالِج {{allowance}} ملف PDF مجانًا، ثم {{rate}}/PDF"
titleWithRate_other = "عالِج {{allowance}} ملفات PDF مجانًا، ثم {{rate}}/PDF"
[portal.catalogue.card]
lockedAriaLabel = "مقفل"
openAriaLabel = "فتح مكون {{name}}"
[portal.catalogue.detail]
addToProject = "إضافة إلى المشروع"
ariaLabel = "تفاصيل المكون"
tabsAriaLabel = "أقسام تفاصيل المكون"
upgradeToUnlock = "الترقية لإلغاء القفل"
[portal.catalogue.detail.code]
install = "تثبيت"
usage = "الاستخدام"
[portal.catalogue.detail.locked]
description = "{{name}} مضمن بدءًا من خطة {{tier}}. قم بالترقية لتضمينه."
title = "غير متاح في خطتك"
[portal.catalogue.detail.preview]
badge = "معاينة مباشرة"
note = "يتم عرض صندوق الرمل التفاعلي هنا"
[portal.catalogue.detail.pricing]
note = "مقاس لكل {{unit}}. تتم فوترة الاستخدام بعد الحصة الشهرية المجانية على حسابك ويُفصّل ضمن الاستخدام والفوترة."
[portal.catalogue.detail.stats]
billedOn = "تتم الفوترة على"
embeds30d = "التضمينات (30 يومًا)"
freeQuota = "الحصة المجانية"
freeQuotaValue = "{{amount}} / شهر"
maturity = "النضج"
none = "لا شيء"
perAction = "لكل إجراء"
price = "السعر"
[portal.catalogue.detail.tabs]
code = "Code"
overview = "نظرة عامة"
pricing = "التسعير"
props = "Props / API"
[portal.catalogue.props]
optional = "اختياري"
required = "مطلوب"
[portal.catalogue.props.columns]
description = "الوصف"
name = "Prop"
required = "مطلوب"
type = "النوع"
[portal.catalogue.summary]
componentsGa = "مكونات GA"
componentSpendMtd = "إنفاق المكونات (MTD)"
embedsThisMonth = "التضمينات هذا الشهر"
inBeta = "في Beta"
[portal.components.billingUnit]
approval = "موافقة"
check = "فحص"
@@ -6591,18 +6534,6 @@ signature = "توقيع"
beta = "Beta"
ga = "GA"
[portal.componentsView]
subtitle = "ودجات SDK قابلة للتضمين تضعها في تطبيقك — عارض، وتدفق توقيع إلكتروني، ولوحة مراجعة AI. كل منها مقاس لكل إجراء. انقر على بطاقة للتثبيت والاستخدام وprops."
title = "المكونات"
[portal.componentsView.empty]
description = "تعذر تحميل كتالوج المكونات. حاول مرة أخرى قريبًا."
title = "لا توجد مكونات متاحة"
[portal.componentsView.lockedBanner]
description = "مكونات GA متاحة على Pay-as-you-go؛ وبعض مكونات Beta مخصصة للمؤسسات فقط. تعرض البطاقات المقفلة تلميحًا للترقية."
title = "تحتاج بعض المكونات إلى خطة مدفوعة"
[portal.docs.authentication]
codeCaption = "كل طلب"
eyebrow = "البدء"
@@ -6521,63 +6521,6 @@ title_other = "{{allowance}} PDF-i pulsuz emal edin"
titleWithRate_one = "{{allowance}} PDF-i pulsuz emal edin, sonra {{rate}}/PDF"
titleWithRate_other = "{{allowance}} PDF-i pulsuz emal edin, sonra {{rate}}/PDF"
[portal.catalogue.card]
lockedAriaLabel = "Kilidli"
openAriaLabel = "{{name}} komponentini aç"
[portal.catalogue.detail]
addToProject = "Layihəyə əlavə et"
ariaLabel = "Komponent təfərrüatı"
tabsAriaLabel = "Komponent təfərrüatı bölmələri"
upgradeToUnlock = "Kilidi açmaq üçün yüksəldin"
[portal.catalogue.detail.code]
install = "Quraşdır"
usage = "İstifadə"
[portal.catalogue.detail.locked]
description = "{{name}} {{tier}} planından başlayaraq daxildir. Onu daxil etmək üçün yüksəldin."
title = "Planınızda mövcud deyil"
[portal.catalogue.detail.preview]
badge = "Canlı önizləmə"
note = "İnteraktiv sandbox burada render olunur"
[portal.catalogue.detail.pricing]
note = "{{unit}} başına ölçülür. Aylıq pulsuz kvotadan artıq istifadə hesabınıza yazılır və Usage & Billing altında maddələnir."
[portal.catalogue.detail.stats]
billedOn = "Hesablanma üzrə"
embeds30d = "Daxiletmələr (30g)"
freeQuota = "Pulsuz kvota"
freeQuotaValue = "{{amount}} / ay"
maturity = "Yetkinlik"
none = "Yoxdur"
perAction = "Hər hərəkət üçün"
price = "Qiymət"
[portal.catalogue.detail.tabs]
code = "Kod"
overview = "İcmal"
pricing = "Qiymətləndirmə"
props = "Props / API"
[portal.catalogue.props]
optional = "istəyə bağlı"
required = "tələb olunur"
[portal.catalogue.props.columns]
description = "Təsvir"
name = "Prop"
required = "Tələb olunur"
type = "Tip"
[portal.catalogue.summary]
componentsGa = "Components GA"
componentSpendMtd = "Komponent xərci (MTD)"
embedsThisMonth = "Bu ay daxiletmələr"
inBeta = "Beta-da"
[portal.components.billingUnit]
approval = "təsdiq"
check = "yoxlama"
@@ -6591,18 +6534,6 @@ signature = "imza"
beta = "Beta"
ga = "GA"
[portal.componentsView]
subtitle = "Öz tətbiqinizə yerləşdirə biləcəyiniz daxil edilə bilən SDK vidcetləri — görüntüləyici, e-imza axını, AI baxış paneli. Hər biri hərəkət üzrə ölçülür. Quraşdırma, istifadə və props üçün karta klikləyin."
title = "Komponentlər"
[portal.componentsView.empty]
description = "Komponent kataloqu yüklənə bilmədi. Bir az sonra yenidən cəhd edin."
title = "Mövcud komponent yoxdur"
[portal.componentsView.lockedBanner]
description = "GA komponentləri Pay-as-you-go planında mövcuddur; bəzi Beta komponentləri yalnız enterprise üçündür. Kilidli kartlar yüksəltmə təklifi göstərir."
title = "Bəzi komponentlər üçün ödənişli plan lazımdır"
[portal.docs.authentication]
codeCaption = "hər sorğu"
eyebrow = "BAŞLANĞIC"
@@ -6521,63 +6521,6 @@ title_other = "Обработете {{allowance}} PDF безплатно"
titleWithRate_one = "Обработете {{allowance}} PDF безплатно, после {{rate}}/PDF"
titleWithRate_other = "Обработете {{allowance}} PDF безплатно, после {{rate}}/PDF"
[portal.catalogue.card]
lockedAriaLabel = "Заключено"
openAriaLabel = "Отвори компонент {{name}}"
[portal.catalogue.detail]
addToProject = "Добави към проект"
ariaLabel = "Детайли за компонента"
tabsAriaLabel = "Секции с детайли за компонента"
upgradeToUnlock = "Надстройте, за да отключите"
[portal.catalogue.detail.code]
install = "Инсталиране"
usage = "Употреба"
[portal.catalogue.detail.locked]
description = "{{name}} е включен от план {{tier}}. Надстройте, за да го вградите."
title = "Не е налично във вашия план"
[portal.catalogue.detail.preview]
badge = "Live preview"
note = "Интерактивният sandbox се рендира тук"
[portal.catalogue.detail.pricing]
note = "Измерва се на {{unit}}. Използването над месечната безплатна квота се таксува към вашия акаунт и се детайлизира в Usage & Billing."
[portal.catalogue.detail.stats]
billedOn = "Таксува се по"
embeds30d = "Вграждания (30 дни)"
freeQuota = "Безплатна квота"
freeQuotaValue = "{{amount}} / мес"
maturity = "Зрялост"
none = "Няма"
perAction = "На действие"
price = "Цена"
[portal.catalogue.detail.tabs]
code = "Code"
overview = "Общ преглед"
pricing = "Ценообразуване"
props = "Props / API"
[portal.catalogue.props]
optional = "по избор"
required = "задължително"
[portal.catalogue.props.columns]
description = "Описание"
name = "Prop"
required = "Задължително"
type = "Тип"
[portal.catalogue.summary]
componentsGa = "Компоненти GA"
componentSpendMtd = "Разход за компоненти (MTD)"
embedsThisMonth = "Вграждания този месец"
inBeta = "В beta"
[portal.components.billingUnit]
approval = "одобрение"
check = "проверка"
@@ -6591,18 +6534,6 @@ signature = "подпис"
beta = "Beta"
ga = "GA"
[portal.componentsView]
subtitle = "Вградими SDK widgets, които поставяте в собственото си приложение — viewer, e-sign поток, AI панел за преглед. Всеки се измерва на действие. Щракнете върху карта за инсталиране, употреба и props."
title = "Компоненти"
[portal.componentsView.empty]
description = "Каталогът с компоненти не можа да бъде зареден. Опитайте отново след малко."
title = "Няма налични компоненти"
[portal.componentsView.lockedBanner]
description = "GA компонентите са налични при Pay-as-you-go; няколко Beta компонента са само за enterprise. Заключените карти показват подкана за надстройване."
title = "Някои компоненти изискват платен план"
[portal.docs.authentication]
codeCaption = "всяка заявка"
eyebrow = "GETTING STARTED"
@@ -6521,63 +6521,6 @@ title_other = "Processa {{allowance}} PDF de franc"
titleWithRate_one = "Processa {{allowance}} PDF de franc, després {{rate}}/PDF"
titleWithRate_other = "Processa {{allowance}} PDF de franc, després {{rate}}/PDF"
[portal.catalogue.card]
lockedAriaLabel = "Bloquejat"
openAriaLabel = "Obre el component {{name}}"
[portal.catalogue.detail]
addToProject = "Afegeix al projecte"
ariaLabel = "Detall del component"
tabsAriaLabel = "Seccions de detall del component"
upgradeToUnlock = "Actualitza per desbloquejar"
[portal.catalogue.detail.code]
install = "Instal·la"
usage = "Ús"
[portal.catalogue.detail.locked]
description = "{{name}} s'inclou a partir del pla {{tier}}. Actualitza per incrustar-lo."
title = "No disponible al teu pla"
[portal.catalogue.detail.preview]
badge = "Previsualització en directe"
note = "El sandbox interactiu es renderitza aquí"
[portal.catalogue.detail.pricing]
note = "Mesurat per {{unit}}. L'ús més enllà de la quota gratuïta mensual es factura al teu compte i es detalla a Ús i facturació."
[portal.catalogue.detail.stats]
billedOn = "Facturat per"
embeds30d = "Incrustacions (30 d)"
freeQuota = "Quota gratuïta"
freeQuotaValue = "{{amount}} / mes"
maturity = "Maduresa"
none = "Cap"
perAction = "Per acció"
price = "Preu"
[portal.catalogue.detail.tabs]
code = "Code"
overview = "Resum"
pricing = "Preus"
props = "Props / API"
[portal.catalogue.props]
optional = "opcional"
required = "obligatori"
[portal.catalogue.props.columns]
description = "Descripció"
name = "Prop"
required = "Obligatori"
type = "Tipus"
[portal.catalogue.summary]
componentsGa = "Components GA"
componentSpendMtd = "Despesa en components (MTD)"
embedsThisMonth = "Incrustacions aquest mes"
inBeta = "En beta"
[portal.components.billingUnit]
approval = "aprovació"
check = "comprovació"
@@ -6591,18 +6534,6 @@ signature = "signatura"
beta = "Beta"
ga = "GA"
[portal.componentsView]
subtitle = "Widgets SDK incrustables que deixes anar a la teva pròpia aplicació: un visor, un flux de signatura electrònica, un panell de revisió d'AI. Cadascun es mesura per acció. Fes clic en una targeta per veure instal·lació, ús i props."
title = "Components"
[portal.componentsView.empty]
description = "No s'ha pogut carregar el catàleg de components. Torna-ho a provar d'aquí a poc."
title = "No hi ha components disponibles"
[portal.componentsView.lockedBanner]
description = "Els components GA estan disponibles amb pagament per ús; alguns components Beta són només per a empreses. Les targetes bloquejades mostren una invitació a actualitzar."
title = "Alguns components necessiten un pla de pagament"
[portal.docs.authentication]
codeCaption = "cada sol·licitud"
eyebrow = "PRIMERS PASSOS"
@@ -6521,63 +6521,6 @@ title_other = "Zpracujte {{allowance}} PDFs zdarma"
titleWithRate_one = "Zpracujte {{allowance}} PDFs zdarma, poté {{rate}}/PDF"
titleWithRate_other = "Zpracujte {{allowance}} PDFs zdarma, poté {{rate}}/PDF"
[portal.catalogue.card]
lockedAriaLabel = "Zamčeno"
openAriaLabel = "Otevřít komponentu {{name}}"
[portal.catalogue.detail]
addToProject = "Přidat do projektu"
ariaLabel = "Detail komponenty"
tabsAriaLabel = "Sekce detailu komponenty"
upgradeToUnlock = "Upgradujte pro odemknutí"
[portal.catalogue.detail.code]
install = "Instalace"
usage = "Použití"
[portal.catalogue.detail.locked]
description = "{{name}} je zahrnuto od plánu {{tier}}. Upgradujte, abyste ji mohli vložit."
title = "Není dostupné ve vašem plánu"
[portal.catalogue.detail.preview]
badge = "Živý náhled"
note = "Interaktivní sandbox se vykreslí zde"
[portal.catalogue.detail.pricing]
note = "Měřeno za {{unit}}. Využití nad měsíční bezplatnou kvótu je účtováno vašemu účtu a rozepsáno v části Využití a fakturace."
[portal.catalogue.detail.stats]
billedOn = "Účtováno za"
embeds30d = "Vložení (30 dní)"
freeQuota = "Bezplatná kvóta"
freeQuotaValue = "{{amount}} / měs."
maturity = "Vyspělost"
none = "Žádné"
perAction = "Za akci"
price = "Cena"
[portal.catalogue.detail.tabs]
code = "Kód"
overview = "Přehled"
pricing = "Ceník"
props = "Props / API"
[portal.catalogue.props]
optional = "volitelné"
required = "povinné"
[portal.catalogue.props.columns]
description = "Popis"
name = "Prop"
required = "Povinné"
type = "Typ"
[portal.catalogue.summary]
componentsGa = "Komponenty GA"
componentSpendMtd = "Výdaje za komponenty (MTD)"
embedsThisMonth = "Vložení tento měsíc"
inBeta = "V beta verzi"
[portal.components.billingUnit]
approval = "schválení"
check = "kontrola"
@@ -6591,18 +6534,6 @@ signature = "podpis"
beta = "Beta"
ga = "GA"
[portal.componentsView]
subtitle = "Vložitelné SDK widgety, které vložíte do své aplikace — prohlížeč, tok e-podpisu, panel AI kontroly. Každý je měřen za akci. Kliknutím na kartu zobrazíte instalaci, použití a props."
title = "Komponenty"
[portal.componentsView.empty]
description = "Katalog komponent se nepodařilo načíst. Zkuste to brzy znovu."
title = "Žádné dostupné komponenty"
[portal.componentsView.lockedBanner]
description = "Komponenty GA jsou dostupné v režimu Pay-as-you-go; několik komponent Beta je pouze pro enterprise. Zamčené karty zobrazují výzvu k upgradu."
title = "Některé komponenty vyžadují placený plán"
[portal.docs.authentication]
codeCaption = "každý požadavek"
eyebrow = "ZAČÍNÁME"
@@ -6521,63 +6521,6 @@ title_other = "Behandl {{allowance}} PDFs gratis"
titleWithRate_one = "Behandl {{allowance}} PDFs gratis, derefter {{rate}}/PDF"
titleWithRate_other = "Behandl {{allowance}} PDFs gratis, derefter {{rate}}/PDF"
[portal.catalogue.card]
lockedAriaLabel = "Låst"
openAriaLabel = "Åbn {{name}}-komponent"
[portal.catalogue.detail]
addToProject = "Føj til projekt"
ariaLabel = "Komponentdetalje"
tabsAriaLabel = "Komponentdetaljesektioner"
upgradeToUnlock = "Opgrader for at låse op"
[portal.catalogue.detail.code]
install = "Installér"
usage = "Brug"
[portal.catalogue.detail.locked]
description = "{{name}} er inkluderet fra {{tier}}-planen. Opgrader for at indlejre den."
title = "Ikke tilgængelig på din plan"
[portal.catalogue.detail.preview]
badge = "Live preview"
note = "Interaktiv sandbox renderes her"
[portal.catalogue.detail.pricing]
note = "Måles pr. {{unit}}. Brug ud over den månedlige gratis kvote faktureres til din konto og specificeres under Brug og fakturering."
[portal.catalogue.detail.stats]
billedOn = "Faktureres på"
embeds30d = "Indlejringer (30d)"
freeQuota = "Gratis kvote"
freeQuotaValue = "{{amount}} / md."
maturity = "Modenhed"
none = "Ingen"
perAction = "Pr. handling"
price = "Pris"
[portal.catalogue.detail.tabs]
code = "Code"
overview = "Oversigt"
pricing = "Priser"
props = "Props / API"
[portal.catalogue.props]
optional = "valgfri"
required = "påkrævet"
[portal.catalogue.props.columns]
description = "Beskrivelse"
name = "Prop"
required = "Påkrævet"
type = "Type"
[portal.catalogue.summary]
componentsGa = "Komponenter GA"
componentSpendMtd = "Komponentforbrug (MTD)"
embedsThisMonth = "Indlejringer denne måned"
inBeta = "I beta"
[portal.components.billingUnit]
approval = "godkendelse"
check = "kontrol"
@@ -6591,18 +6534,6 @@ signature = "signatur"
beta = "Beta"
ga = "GA"
[portal.componentsView]
subtitle = "Indlejrbare SDK-widgets, du kan lægge ind i din egen app — en viewer, et e-sign-flow, et AI-gennemgangspanel. Hver måles pr. handling. Klik på et kort for installation, brug og props."
title = "Komponenter"
[portal.componentsView.empty]
description = "Komponentkataloget kunne ikke indlæses. Prøv igen om lidt."
title = "Ingen komponenter tilgængelige"
[portal.componentsView.lockedBanner]
description = "GA-komponenter er tilgængelige på Pay-as-you-go; nogle få Beta-komponenter er kun til enterprise. Låste kort viser en opgraderingsopfordring."
title = "Nogle komponenter kræver en betalt plan"
[portal.docs.authentication]
codeCaption = "hver forespørgsel"
eyebrow = "KOM GODT I GANG"
@@ -6553,63 +6553,6 @@ title_other = "{{allowance}} PDFs kostenlos verarbeiten"
titleWithRate_one = "{{allowance}} PDFs kostenlos verarbeiten, danach {{rate}}/PDF"
titleWithRate_other = "{{allowance}} PDFs kostenlos verarbeiten, danach {{rate}}/PDF"
[portal.catalogue.card]
lockedAriaLabel = "Gesperrt"
openAriaLabel = "Komponente {{name}} öffnen"
[portal.catalogue.detail]
addToProject = "Zum Projekt hinzufügen"
ariaLabel = "Komponentendetail"
tabsAriaLabel = "Komponentendetail-Abschnitte"
upgradeToUnlock = "Upgrade zum Entsperren"
[portal.catalogue.detail.code]
install = "Installieren"
usage = "Usage"
[portal.catalogue.detail.locked]
description = "{{name}} ist ab dem {{tier}}-Tarif enthalten. Führen Sie ein Upgrade durch, um es einzubetten."
title = "In Ihrem Tarif nicht verfügbar"
[portal.catalogue.detail.preview]
badge = "Live-Vorschau"
note = "Interaktive Sandbox wird hier gerendert"
[portal.catalogue.detail.pricing]
note = "Nutzungsbasiert pro {{unit}}. Nutzung über das monatliche Freikontingent hinaus wird Ihrem Konto berechnet und unter Nutzung & Abrechnung einzeln aufgeführt."
[portal.catalogue.detail.stats]
billedOn = "Abgerechnet nach"
embeds30d = "Einbettungen (30T)"
freeQuota = "Freikontingent"
freeQuotaValue = "{{amount}} / Mon."
maturity = "Reifegrad"
none = "Keine"
perAction = "Pro Aktion"
price = "Preis"
[portal.catalogue.detail.tabs]
code = "Code"
overview = "Übersicht"
pricing = "Preise"
props = "Props / API"
[portal.catalogue.props]
optional = "optional"
required = "erforderlich"
[portal.catalogue.props.columns]
description = "Beschreibung"
name = "Prop"
required = "Erforderlich"
type = "Typ"
[portal.catalogue.summary]
componentsGa = "Components GA"
componentSpendMtd = "Komponentenausgaben (MTD)"
embedsThisMonth = "Einbettungen diesen Monat"
inBeta = "In Beta"
[portal.components.billingUnit]
approval = "Genehmigung"
check = "Prüfung"
@@ -6623,18 +6566,6 @@ signature = "Signatur"
beta = "Beta"
ga = "GA"
[portal.componentsView]
subtitle = "Einbettbare SDK-Widgets, die Sie in Ihre eigene App einfügen — ein Viewer, ein E-Sign-Flow, ein KI-Review-Panel. Jedes wird pro Aktion nutzungsbasiert abgerechnet. Klicken Sie auf eine Karte für Installation, Nutzung und Props."
title = "Komponenten"
[portal.componentsView.empty]
description = "Der Komponentenkatalog konnte nicht geladen werden. Versuchen Sie es in Kürze erneut."
title = "Keine Komponenten verfügbar"
[portal.componentsView.lockedBanner]
description = "GA-Komponenten sind mit Pay-as-you-go verfügbar; einige Beta-Komponenten sind nur für Enterprise. Gesperrte Karten zeigen einen Upgrade-Hinweis."
title = "Einige Komponenten benötigen einen kostenpflichtigen Tarif"
[portal.docs.authentication]
codeCaption = "jede Anfrage"
eyebrow = "ERSTE SCHRITTE"
@@ -6521,63 +6521,6 @@ title_other = "Επεξεργαστείτε {{allowance}} PDFs δωρεάν"
titleWithRate_one = "Επεξεργαστείτε {{allowance}} PDFs δωρεάν και μετά {{rate}}/PDF"
titleWithRate_other = "Επεξεργαστείτε {{allowance}} PDFs δωρεάν και μετά {{rate}}/PDF"
[portal.catalogue.card]
lockedAriaLabel = "Κλειδωμένο"
openAriaLabel = "Άνοιγμα στοιχείου {{name}}"
[portal.catalogue.detail]
addToProject = "Προσθήκη στο έργο"
ariaLabel = "Λεπτομέρειες στοιχείου"
tabsAriaLabel = "Ενότητες λεπτομερειών στοιχείου"
upgradeToUnlock = "Αναβαθμίστε για ξεκλείδωμα"
[portal.catalogue.detail.code]
install = "Εγκατάσταση"
usage = "Χρήση"
[portal.catalogue.detail.locked]
description = "Το {{name}} περιλαμβάνεται από το πλάνο {{tier}}. Αναβαθμίστε για να το ενσωματώσετε."
title = "Δεν είναι διαθέσιμο στο πλάνο σας"
[portal.catalogue.detail.preview]
badge = "Ζωντανή προεπισκόπηση"
note = "Το διαδραστικό sandbox αποδίδεται εδώ"
[portal.catalogue.detail.pricing]
note = "Μετρημένο ανά {{unit}}. Η χρήση πέρα από τη μηνιαία δωρεάν ποσόστωση χρεώνεται στον λογαριασμό σας και αναλύεται στην ενότητα Χρήση & Χρέωση."
[portal.catalogue.detail.stats]
billedOn = "Χρεώνεται σε"
embeds30d = "Ενσωματώσεις (30ημ.)"
freeQuota = "Δωρεάν ποσόστωση"
freeQuotaValue = "{{amount}} / μήνα"
maturity = "Ωριμότητα"
none = "Καμία"
perAction = "Ανά ενέργεια"
price = "Τιμή"
[portal.catalogue.detail.tabs]
code = "Code"
overview = "Επισκόπηση"
pricing = "Τιμολόγηση"
props = "Props / API"
[portal.catalogue.props]
optional = "προαιρετικό"
required = "απαιτείται"
[portal.catalogue.props.columns]
description = "Περιγραφή"
name = "Prop"
required = "Απαιτείται"
type = "Τύπος"
[portal.catalogue.summary]
componentsGa = "Components GA"
componentSpendMtd = "Δαπάνη components (MTD)"
embedsThisMonth = "Ενσωματώσεις αυτόν τον μήνα"
inBeta = "Σε beta"
[portal.components.billingUnit]
approval = "έγκριση"
check = "έλεγχος"
@@ -6591,18 +6534,6 @@ signature = "υπογραφή"
beta = "Beta"
ga = "GA"
[portal.componentsView]
subtitle = "Ενσωματώσιμα SDK widgets που τοποθετείτε στη δική σας εφαρμογή — ένας viewer, μια ροή e-sign, ένα πάνελ ελέγχου AI. Κάθε ένα μετριέται ανά ενέργεια. Κάντε κλικ σε μια κάρτα για εγκατάσταση, χρήση και props."
title = "Components"
[portal.componentsView.empty]
description = "Δεν ήταν δυνατή η φόρτωση του καταλόγου components. Δοκιμάστε ξανά σύντομα."
title = "Δεν υπάρχουν διαθέσιμα components"
[portal.componentsView.lockedBanner]
description = "Τα GA components είναι διαθέσιμα στο Pay-as-you-go. Μερικά Beta components είναι μόνο για enterprise. Οι κλειδωμένες κάρτες εμφανίζουν προτροπή αναβάθμισης."
title = "Ορισμένα components χρειάζονται πληρωμένο πλάνο"
[portal.docs.authentication]
codeCaption = "κάθε αίτημα"
eyebrow = "ΞΕΚΙΝΩΝΤΑΣ"
@@ -6521,63 +6521,6 @@ title_other = "Process {{allowance}} PDFs free"
titleWithRate_one = "Process {{allowance}} PDFs free, then {{rate}}/PDF"
titleWithRate_other = "Process {{allowance}} PDFs free, then {{rate}}/PDF"
[portal.catalogue.card]
lockedAriaLabel = "Locked"
openAriaLabel = "Open {{name}} component"
[portal.catalogue.detail]
addToProject = "Add to project"
ariaLabel = "Component detail"
tabsAriaLabel = "Component detail sections"
upgradeToUnlock = "Upgrade to unlock"
[portal.catalogue.detail.code]
install = "Install"
usage = "Usage"
[portal.catalogue.detail.locked]
description = "{{name}} is included from the {{tier}} plan. Upgrade to embed it."
title = "Not available on your plan"
[portal.catalogue.detail.preview]
badge = "Live preview"
note = "Interactive sandbox renders here"
[portal.catalogue.detail.pricing]
note = "Metered per {{unit}}. Usage beyond the monthly free quota is billed to your account and itemised under Usage & Billing."
[portal.catalogue.detail.stats]
billedOn = "Billed on"
embeds30d = "Embeds (30d)"
freeQuota = "Free quota"
freeQuotaValue = "{{amount}} / mo"
maturity = "Maturity"
none = "None"
perAction = "Per action"
price = "Price"
[portal.catalogue.detail.tabs]
code = "Code"
overview = "Overview"
pricing = "Pricing"
props = "Props / API"
[portal.catalogue.props]
optional = "optional"
required = "required"
[portal.catalogue.props.columns]
description = "Description"
name = "Prop"
required = "Required"
type = "Type"
[portal.catalogue.summary]
componentsGa = "Components GA"
componentSpendMtd = "Component spend (MTD)"
embedsThisMonth = "Embeds this month"
inBeta = "In beta"
[portal.components.billingUnit]
approval = "approval"
check = "check"
@@ -6591,18 +6534,6 @@ signature = "signature"
beta = "Beta"
ga = "GA"
[portal.componentsView]
subtitle = "Embeddable SDK widgets you drop into your own app — a viewer, an e-sign flow, an AI review panel. Each is metered per action. Click a card for install, usage and props."
title = "Components"
[portal.componentsView.empty]
description = "The component catalogue could not be loaded. Try again shortly."
title = "No components available"
[portal.componentsView.lockedBanner]
description = "GA components are available on Pay-as-you-go; a few Beta components are enterprise-only. Locked cards show an upgrade nudge."
title = "Some components need a paid plan"
[portal.docs.authentication]
codeCaption = "every request"
eyebrow = "GETTING STARTED"
@@ -6421,63 +6421,6 @@ title_other = "Process {{allowance}} PDFs free"
titleWithRate_one = "Process {{allowance}} PDFs free, then {{rate}}/PDF"
titleWithRate_other = "Process {{allowance}} PDFs free, then {{rate}}/PDF"
[portal.catalogue.card]
lockedAriaLabel = "Locked"
openAriaLabel = "Open {{name}} component"
[portal.catalogue.detail]
addToProject = "Add to project"
ariaLabel = "Component detail"
tabsAriaLabel = "Component detail sections"
upgradeToUnlock = "Upgrade to unlock"
[portal.catalogue.detail.code]
install = "Install"
usage = "Usage"
[portal.catalogue.detail.locked]
description = "{{name}} is included from the {{plan}}. Upgrade to embed it."
title = "Not available on your plan"
[portal.catalogue.detail.preview]
badge = "Live preview"
note = "Interactive sandbox renders here"
[portal.catalogue.detail.pricing]
note = "Metered per {{unit}}. Usage beyond the monthly free quota is billed to your account and itemised under Usage & Billing."
[portal.catalogue.detail.stats]
billedOn = "Billed on"
embeds30d = "Embeds (30d)"
freeQuota = "Free quota"
freeQuotaValue = "{{amount}} / mo"
maturity = "Maturity"
none = "None"
perAction = "Per action"
price = "Price"
[portal.catalogue.detail.tabs]
code = "Code"
overview = "Overview"
pricing = "Pricing"
props = "Props / API"
[portal.catalogue.props]
optional = "optional"
required = "required"
[portal.catalogue.props.columns]
description = "Description"
name = "Prop"
required = "Required"
type = "Type"
[portal.catalogue.summary]
componentsGa = "Components GA"
componentSpendMtd = "Component spend (MTD)"
embedsThisMonth = "Embeds this month"
inBeta = "In beta"
[portal.components.billingUnit]
approval = "approval"
check = "check"
@@ -6491,18 +6434,6 @@ signature = "signature"
beta = "Beta"
ga = "GA"
[portal.componentsView]
subtitle = "Embeddable SDK widgets you drop into your own app — a viewer, an e-sign flow, an AI review panel. Each is metered per action. Click a card for install, usage and props."
title = "Components"
[portal.componentsView.empty]
description = "The component catalogue could not be loaded. Try again shortly."
title = "No components available"
[portal.componentsView.lockedBanner]
description = "GA components are available on Pay-as-you-go; a few Beta components are enterprise-only. Locked cards show an upgrade nudge."
title = "Some components need a paid plan"
[portal.connections]
createTitle = "New S3 connection"
delete = "Delete"
@@ -8997,9 +8928,18 @@ languageDescription = "Choose the display language"
logout = "Log out"
setAsDefault = "Set as Default"
theme = "Theme"
themeAccent = "Accent colour"
themeAccentCustom = "Custom colour"
themeAccentCustomHint = "Custom (defaults recommended)"
themeAccentDark = "Dark mode accent colour"
themeAccentDefault = "Default"
themeAccentDefaultHint = "Default theme (recommended)"
themeAccentDescription = "Buttons, links and highlights follow it, and it subtly tints the app. Light and dark each have their own. System uses whichever is active."
themeAccentLight = "Light mode accent colour"
themeDark = "Dark"
themeDescription = "Choose light, dark, or follow your system"
themeDescription = "Choose light, dark, or follow your system so it switches automatically."
themeLight = "Light"
themeReset = "Restore theme to default"
themeSystem = "System"
title = "General"
user = "User"
@@ -6521,63 +6521,6 @@ title_other = "Procesa {{allowance}} PDFs gratis"
titleWithRate_one = "Procesa {{allowance}} PDFs gratis, luego {{rate}}/PDF"
titleWithRate_other = "Procesa {{allowance}} PDFs gratis, luego {{rate}}/PDF"
[portal.catalogue.card]
lockedAriaLabel = "Bloqueado"
openAriaLabel = "Abrir componente {{name}}"
[portal.catalogue.detail]
addToProject = "Añadir al proyecto"
ariaLabel = "Detalle del componente"
tabsAriaLabel = "Secciones de detalle del componente"
upgradeToUnlock = "Actualiza para desbloquear"
[portal.catalogue.detail.code]
install = "Instalar"
usage = "Uso"
[portal.catalogue.detail.locked]
description = "{{name}} está incluido desde el plan {{tier}}. Actualiza para incrustarlo."
title = "No disponible en tu plan"
[portal.catalogue.detail.preview]
badge = "Vista previa en vivo"
note = "El sandbox interactivo se renderiza aquí"
[portal.catalogue.detail.pricing]
note = "Medido por {{unit}}. El uso que supere la cuota gratuita mensual se factura a tu cuenta y se detalla en Uso y facturación."
[portal.catalogue.detail.stats]
billedOn = "Facturado por"
embeds30d = "Incrustaciones (30 d)"
freeQuota = "Cuota gratuita"
freeQuotaValue = "{{amount}} / mes"
maturity = "Madurez"
none = "Ninguna"
perAction = "Por acción"
price = "Precio"
[portal.catalogue.detail.tabs]
code = "Código"
overview = "Resumen"
pricing = "Precios"
props = "Props / API"
[portal.catalogue.props]
optional = "opcional"
required = "obligatorio"
[portal.catalogue.props.columns]
description = "Descripción"
name = "Prop"
required = "Obligatorio"
type = "Tipo"
[portal.catalogue.summary]
componentsGa = "Componentes GA"
componentSpendMtd = "Gasto en componentes (MTD)"
embedsThisMonth = "Incrustaciones este mes"
inBeta = "En beta"
[portal.components.billingUnit]
approval = "aprobación"
check = "comprobación"
@@ -6591,18 +6534,6 @@ signature = "firma"
beta = "Beta"
ga = "GA"
[portal.componentsView]
subtitle = "Widgets SDK incrustables que colocas en tu propia app: un visor, un flujo de firma electrónica, un panel de revisión de IA. Cada uno se mide por acción. Haz clic en una tarjeta para instalación, uso y props."
title = "Componentes"
[portal.componentsView.empty]
description = "No se ha podido cargar el catálogo de componentes. Inténtalo de nuevo en breve."
title = "No hay componentes disponibles"
[portal.componentsView.lockedBanner]
description = "Los componentes GA están disponibles en pago por uso; algunos componentes Beta son solo para empresas. Las tarjetas bloqueadas muestran un aviso para actualizar."
title = "Algunos componentes necesitan un plan de pago"
[portal.docs.authentication]
codeCaption = "cada solicitud"
eyebrow = "PRIMEROS PASOS"
@@ -6521,63 +6521,6 @@ title_other = "Prozesatu {{allowance}} PDF doan"
titleWithRate_one = "Prozesatu {{allowance}} PDF doan, gero {{rate}}/PDF"
titleWithRate_other = "Prozesatu {{allowance}} PDF doan, gero {{rate}}/PDF"
[portal.catalogue.card]
lockedAriaLabel = "Blokeatuta"
openAriaLabel = "Ireki {{name}} osagaia"
[portal.catalogue.detail]
addToProject = "Gehitu proiektuari"
ariaLabel = "Osagaiaren xehetasuna"
tabsAriaLabel = "Osagaiaren xehetasun-atalak"
upgradeToUnlock = "Eguneratu desblokeatzeko"
[portal.catalogue.detail.code]
install = "Instalatu"
usage = "Erabilera"
[portal.catalogue.detail.locked]
description = "{{name}} {{tier}} planetik aurrera dago barne. Eguneratu plana txertatzeko."
title = "Ez dago erabilgarri zure planean"
[portal.catalogue.detail.preview]
badge = "Zuzeneko aurrebista"
note = "Sandbox interaktiboa hemen errendatuko da"
[portal.catalogue.detail.pricing]
note = "{{unit}} bakoitzeko neurtua. Hileko doako kuotatik gorako erabilera zure kontuan fakturatzen da eta Erabilera eta fakturazioa atalean xehatzen da."
[portal.catalogue.detail.stats]
billedOn = "Honetan fakturatua"
embeds30d = "Txertaketak (30 egun)"
freeQuota = "Doako kuota"
freeQuotaValue = "{{amount}} / hil"
maturity = "Heldutasuna"
none = "Bat ere ez"
perAction = "Ekintza bakoitzeko"
price = "Prezioa"
[portal.catalogue.detail.tabs]
code = "Kodea"
overview = "Ikuspegi orokorra"
pricing = "Prezioak"
props = "Props / API"
[portal.catalogue.props]
optional = "aukerakoa"
required = "beharrezkoa"
[portal.catalogue.props.columns]
description = "Deskribapena"
name = "Prop"
required = "Beharrezkoa"
type = "Mota"
[portal.catalogue.summary]
componentsGa = "GA osagaiak"
componentSpendMtd = "Osagaien gastua (MTD)"
embedsThisMonth = "Txertaketak hilabete honetan"
inBeta = "Beta-n"
[portal.components.billingUnit]
approval = "onarpena"
check = "egiaztapena"
@@ -6591,18 +6534,6 @@ signature = "sinadura"
beta = "Beta"
ga = "GA"
[portal.componentsView]
subtitle = "Zure aplikazioan txertatzeko SDK widgetak — bistaratzailea, sinadura elektronikoaren fluxua, AI berrikuspen-panela. Bakoitza ekintza bakoitzeko neurtzen da. Egin klik txartel batean instalazioa, erabilera eta props ikusteko."
title = "Osagaiak"
[portal.componentsView.empty]
description = "Ezin izan da osagaien katalogoa kargatu. Saiatu berriro laster."
title = "Ez dago osagairik erabilgarri"
[portal.componentsView.lockedBanner]
description = "GA osagaiak Pay-as-you-go planean daude erabilgarri; Beta osagai batzuk Enterprise-rako soilik dira. Blokeatutako txartelek eguneratzeko iradokizuna erakusten dute."
title = "Osagai batzuek ordainpeko plana behar dute"
[portal.docs.authentication]
codeCaption = "eskaera bakoitza"
eyebrow = "HASTEN"
@@ -6521,63 +6521,6 @@ title_other = "{{allowance}} PDF را رایگان پردازش کنید"
titleWithRate_one = "{{allowance}} PDF را رایگان پردازش کنید، سپس {{rate}}/PDF"
titleWithRate_other = "{{allowance}} PDF را رایگان پردازش کنید، سپس {{rate}}/PDF"
[portal.catalogue.card]
lockedAriaLabel = "قفل‌شده"
openAriaLabel = "باز کردن مؤلفه {{name}}"
[portal.catalogue.detail]
addToProject = "افزودن به پروژه"
ariaLabel = "جزئیات مؤلفه"
tabsAriaLabel = "بخش‌های جزئیات مؤلفه"
upgradeToUnlock = "برای باز کردن قفل ارتقا دهید"
[portal.catalogue.detail.code]
install = "نصب"
usage = "استفاده"
[portal.catalogue.detail.locked]
description = "{{name}} از طرح {{tier}} شامل می‌شود. برای جاسازی آن ارتقا دهید."
title = "در طرح شما در دسترس نیست"
[portal.catalogue.detail.preview]
badge = "پیش‌نمایش زنده"
note = "sandbox تعاملی اینجا رندر می‌شود"
[portal.catalogue.detail.pricing]
note = "بر اساس {{unit}} اندازه‌گیری می‌شود. استفاده فراتر از سهمیه رایگان ماهانه به حساب شما صورت‌حساب می‌شود و زیر Usage & Billing به‌صورت موردی نمایش داده می‌شود."
[portal.catalogue.detail.stats]
billedOn = "صورت‌حساب بر اساس"
embeds30d = "جاسازی‌ها (30 روز)"
freeQuota = "سهمیه رایگان"
freeQuotaValue = "{{amount}} / ماه"
maturity = "بلوغ"
none = "هیچ‌کدام"
perAction = "به‌ازای هر اقدام"
price = "قیمت"
[portal.catalogue.detail.tabs]
code = "کد"
overview = "نمای کلی"
pricing = "قیمت‌گذاری"
props = "Props / API"
[portal.catalogue.props]
optional = "اختیاری"
required = "الزامی"
[portal.catalogue.props.columns]
description = "توضیحات"
name = "Prop"
required = "الزامی"
type = "نوع"
[portal.catalogue.summary]
componentsGa = "مؤلفه‌های GA"
componentSpendMtd = "هزینه مؤلفه (MTD)"
embedsThisMonth = "جاسازی‌ها در این ماه"
inBeta = "در Beta"
[portal.components.billingUnit]
approval = "تأیید"
check = "بررسی"
@@ -6591,18 +6534,6 @@ signature = "امضا"
beta = "Beta"
ga = "GA"
[portal.componentsView]
subtitle = "ویجت‌های SDK قابل جاسازی که در برنامه خودتان قرار می‌دهید — یک نمایشگر، جریان e-sign، پنل بازبینی AI. هرکدام به‌ازای هر اقدام اندازه‌گیری می‌شود. برای نصب، استفاده و props روی کارت کلیک کنید."
title = "مؤلفه‌ها"
[portal.componentsView.empty]
description = "کاتالوگ مؤلفه‌ها بارگیری نشد. کمی بعد دوباره تلاش کنید."
title = "هیچ مؤلفه‌ای در دسترس نیست"
[portal.componentsView.lockedBanner]
description = "مؤلفه‌های GA در Pay-as-you-go در دسترس هستند؛ چند مؤلفه Beta فقط برای enterprise هستند. کارت‌های قفل‌شده یک پیشنهاد ارتقا نشان می‌دهند."
title = "برخی مؤلفه‌ها به طرح پولی نیاز دارند"
[portal.docs.authentication]
codeCaption = "هر درخواست"
eyebrow = "شروع به کار"
@@ -6521,63 +6521,6 @@ title_other = "Traitez {{allowance}} PDF gratuitement"
titleWithRate_one = "Traitez {{allowance}} PDF gratuitement, puis {{rate}}/PDF"
titleWithRate_other = "Traitez {{allowance}} PDF gratuitement, puis {{rate}}/PDF"
[portal.catalogue.card]
lockedAriaLabel = "Verrouillé"
openAriaLabel = "Ouvrir le composant {{name}}"
[portal.catalogue.detail]
addToProject = "Ajouter au projet"
ariaLabel = "Détail du composant"
tabsAriaLabel = "Sections de détail du composant"
upgradeToUnlock = "Passer à un niveau supérieur pour déverrouiller"
[portal.catalogue.detail.code]
install = "Installer"
usage = "Utilisation"
[portal.catalogue.detail.locked]
description = "{{name}} est inclus à partir du plan {{tier}}. Passez à un niveau supérieur pour l’intégrer."
title = "Non disponible avec votre plan"
[portal.catalogue.detail.preview]
badge = "Aperçu en direct"
note = "Le bac à sable interactif s’affiche ici"
[portal.catalogue.detail.pricing]
note = "Mesuré par {{unit}}. L’utilisation au-delà du quota mensuel gratuit est facturée à votre compte et détaillée sous Utilisation et facturation."
[portal.catalogue.detail.stats]
billedOn = "Facturé sur"
embeds30d = "Intégrations (30 j)"
freeQuota = "Quota gratuit"
freeQuotaValue = "{{amount}} / mois"
maturity = "Maturité"
none = "Aucun"
perAction = "Par action"
price = "Prix"
[portal.catalogue.detail.tabs]
code = "Code"
overview = "Vue d’ensemble"
pricing = "Tarification"
props = "Props / API"
[portal.catalogue.props]
optional = "facultatif"
required = "obligatoire"
[portal.catalogue.props.columns]
description = "Description"
name = "Prop"
required = "Obligatoire"
type = "Type"
[portal.catalogue.summary]
componentsGa = "Composants GA"
componentSpendMtd = "Dépenses des composants (MTD)"
embedsThisMonth = "Intégrations ce mois-ci"
inBeta = "En beta"
[portal.components.billingUnit]
approval = "approbation"
check = "vérification"
@@ -6591,18 +6534,6 @@ signature = "signature"
beta = "Beta"
ga = "GA"
[portal.componentsView]
subtitle = "Widgets SDK intégrables que vous ajoutez à votre propre application — une visionneuse, un flux de signature électronique, un panneau d’examen AI. Chacun est mesuré par action. Cliquez sur une carte pour l’installation, l’utilisation et les props."
title = "Composants"
[portal.componentsView.empty]
description = "Le catalogue des composants n’a pas pu être chargé. Réessayez bientôt."
title = "Aucun composant disponible"
[portal.componentsView.lockedBanner]
description = "Les composants GA sont disponibles en paiement à l’usage ; quelques composants Beta sont réservés à Enterprise. Les cartes verrouillées affichent une invitation à passer à un niveau supérieur."
title = "Certains composants nécessitent un plan payant"
[portal.docs.authentication]
codeCaption = "chaque requête"
eyebrow = "BIEN DÉMARRER"
@@ -6521,63 +6521,6 @@ title_other = "Próiseáil {{allowance}} PDFs saor in aisce"
titleWithRate_one = "Próiseáil {{allowance}} PDF saor in aisce, ansin {{rate}}/PDF"
titleWithRate_other = "Próiseáil {{allowance}} PDFs saor in aisce, ansin {{rate}}/PDF"
[portal.catalogue.card]
lockedAriaLabel = "Faoi ghlas"
openAriaLabel = "Oscail comhpháirt {{name}}"
[portal.catalogue.detail]
addToProject = "Cuir le tionscadal"
ariaLabel = "Sonraí comhpháirte"
tabsAriaLabel = "Rannáin shonraí comhpháirte"
upgradeToUnlock = "Uasghrádaigh chun díghlasáil"
[portal.catalogue.detail.code]
install = "Suiteáil"
usage = "Úsáid"
[portal.catalogue.detail.locked]
description = "Tá {{name}} san áireamh ón bplean {{tier}} ar aghaidh. Uasghrádaigh chun é a leabú."
title = "Níl sé ar fáil ar do phlean"
[portal.catalogue.detail.preview]
badge = "Réamhamharc beo"
note = "Rindreálfar bosca gainimh idirghníomhach anseo"
[portal.catalogue.detail.pricing]
note = "Méadraithe in aghaidh {{unit}}. Billeáiltear úsáid thar an gcuóta míosúil saor in aisce ar do chuntas agus mírítear í faoi Úsáid & Billeáil."
[portal.catalogue.detail.stats]
billedOn = "Billeáilte ar"
embeds30d = "Leabuithe (30l)"
freeQuota = "Cuóta saor in aisce"
freeQuotaValue = "{{amount}} / mí"
maturity = "Aibíocht"
none = "Dada"
perAction = "In aghaidh gnímh"
price = "Praghas"
[portal.catalogue.detail.tabs]
code = "Code"
overview = "Forbhreathnú"
pricing = "Praghsáil"
props = "Props / API"
[portal.catalogue.props]
optional = "roghnach"
required = "riachtanach"
[portal.catalogue.props.columns]
description = "Cur síos"
name = "Prop"
required = "Riachtanach"
type = "Cineál"
[portal.catalogue.summary]
componentsGa = "Comhpháirteanna GA"
componentSpendMtd = "Caiteachas comhpháirte (MTD)"
embedsThisMonth = "Leabuithe an mhí seo"
inBeta = "I mBeta"
[portal.components.billingUnit]
approval = "faomhadh"
check = "seiceáil"
@@ -6591,18 +6534,6 @@ signature = "síniú"
beta = "Beta"
ga = "GA"
[portal.componentsView]
subtitle = "Giuirléidí SDK inleabaithe a chuireann tú isteach i d’aip féin — breathnóir, sreabhadh e-sign, painéal athbhreithnithe AI. Méadraítear gach ceann in aghaidh gnímh. Cliceáil cárta le haghaidh suiteála, úsáide agus props."
title = "Comhpháirteanna"
[portal.componentsView.empty]
description = "Níorbh fhéidir catalóg na gcomhpháirteanna a luchtú. Bain triail eile as gan mhoill."
title = "Níl aon chomhpháirteanna ar fáil"
[portal.componentsView.lockedBanner]
description = "Tá comhpháirteanna GA ar fáil ar Pay-as-you-go; tá cúpla comhpháirt Beta don fhiontar amháin. Taispeánann cártaí faoi ghlas leid uasghrádaithe."
title = "Teastaíonn plean íoctha ó roinnt comhpháirteanna"
[portal.docs.authentication]
codeCaption = "gach iarratas"
eyebrow = "AG TOSÚ"
@@ -6521,63 +6521,6 @@ title_other = "{{allowance}} PDFs मुफ़्त process करें"
titleWithRate_one = "{{allowance}} PDFs मुफ़्त process करें, फिर {{rate}}/PDF"
titleWithRate_other = "{{allowance}} PDFs मुफ़्त process करें, फिर {{rate}}/PDF"
[portal.catalogue.card]
lockedAriaLabel = "Locked"
openAriaLabel = "{{name}} component खोलें"
[portal.catalogue.detail]
addToProject = "project में जोड़ें"
ariaLabel = "Component detail"
tabsAriaLabel = "Component detail sections"
upgradeToUnlock = "अनलॉक करने के लिए upgrade करें"
[portal.catalogue.detail.code]
install = "Install"
usage = "Usage"
[portal.catalogue.detail.locked]
description = "{{name}} {{tier}} प्लान से शामिल है। इसे embed करने के लिए upgrade करें।"
title = "आपके प्लान पर उपलब्ध नहीं"
[portal.catalogue.detail.preview]
badge = "Live preview"
note = "Interactive sandbox यहाँ render होता है"
[portal.catalogue.detail.pricing]
note = "प्रति {{unit}} metered। मासिक free quota से अधिक usage का बिल आपके account से लिया जाता है और Usage & Billing के अंतर्गत itemise किया जाता है।"
[portal.catalogue.detail.stats]
billedOn = "Billed on"
embeds30d = "Embeds (30d)"
freeQuota = "Free quota"
freeQuotaValue = "{{amount}} / mo"
maturity = "Maturity"
none = "कोई नहीं"
perAction = "Per action"
price = "Price"
[portal.catalogue.detail.tabs]
code = "Code"
overview = "Overview"
pricing = "Pricing"
props = "Props / API"
[portal.catalogue.props]
optional = "optional"
required = "required"
[portal.catalogue.props.columns]
description = "विवरण"
name = "Prop"
required = "Required"
type = "Type"
[portal.catalogue.summary]
componentsGa = "Components GA"
componentSpendMtd = "Component spend (MTD)"
embedsThisMonth = "इस माह embeds"
inBeta = "Beta में"
[portal.components.billingUnit]
approval = "approval"
check = "check"
@@ -6591,18 +6534,6 @@ signature = "signature"
beta = "Beta"
ga = "GA"
[portal.componentsView]
subtitle = "Embeddable SDK widgets जिन्हें आप अपने app में डालते हैं — viewer, e-sign flow, AI review panel। हर action पर metered। install, usage और props के लिए card पर क्लिक करें।"
title = "Components"
[portal.componentsView.empty]
description = "component catalogue लोड नहीं किया जा सका। थोड़ी देर में फिर कोशिश करें।"
title = "कोई components उपलब्ध नहीं"
[portal.componentsView.lockedBanner]
description = "GA components Pay-as-you-go पर उपलब्ध हैं; कुछ Beta components केवल enterprise के लिए हैं। Locked cards upgrade nudge दिखाते हैं।"
title = "कुछ components के लिए paid plan चाहिए"
[portal.docs.authentication]
codeCaption = "हर request"
eyebrow = "शुरुआत करें"
@@ -6521,63 +6521,6 @@ title_other = "Obradite {{allowance}} PDF-ova besplatno"
titleWithRate_one = "Obradite {{allowance}} PDF-ova besplatno, zatim {{rate}}/PDF"
titleWithRate_other = "Obradite {{allowance}} PDF-ova besplatno, zatim {{rate}}/PDF"
[portal.catalogue.card]
lockedAriaLabel = "Zaključano"
openAriaLabel = "Otvori komponentu {{name}}"
[portal.catalogue.detail]
addToProject = "Dodaj u projekt"
ariaLabel = "Detalji komponente"
tabsAriaLabel = "Odjeljci detalja komponente"
upgradeToUnlock = "Nadogradite za otključavanje"
[portal.catalogue.detail.code]
install = "Instalacija"
usage = "Upotreba"
[portal.catalogue.detail.locked]
description = "{{name}} je uključeno od plana {{tier}}. Nadogradite da biste ga ugradili."
title = "Nije dostupno na vašem planu"
[portal.catalogue.detail.preview]
badge = "Pregled uživo"
note = "Interaktivni sandbox prikazuje se ovdje"
[portal.catalogue.detail.pricing]
note = "Mjereno po {{unit}}. Upotreba iznad mjesečne besplatne kvote naplaćuje se vašem računu i prikazuje po stavkama u odjeljku Upotreba i naplata."
[portal.catalogue.detail.stats]
billedOn = "Naplaćuje se po"
embeds30d = "Ugradnje (30 d)"
freeQuota = "Besplatna kvota"
freeQuotaValue = "{{amount}} / mj."
maturity = "Zrelost"
none = "Ništa"
perAction = "Po radnji"
price = "Cijena"
[portal.catalogue.detail.tabs]
code = "Code"
overview = "Pregled"
pricing = "Cijene"
props = "Props / API"
[portal.catalogue.props]
optional = "neobavezno"
required = "obavezno"
[portal.catalogue.props.columns]
description = "Opis"
name = "Prop"
required = "Obavezno"
type = "Vrsta"
[portal.catalogue.summary]
componentsGa = "Komponente GA"
componentSpendMtd = "Potrošnja komponenti (MTD)"
embedsThisMonth = "Ugradnje ovog mjeseca"
inBeta = "U beta verziji"
[portal.components.billingUnit]
approval = "odobrenje"
check = "provjera"
@@ -6591,18 +6534,6 @@ signature = "potpis"
beta = "Beta"
ga = "GA"
[portal.componentsView]
subtitle = "Ugradivi SDK widgeti koje ubacujete u vlastitu aplikaciju — preglednik, tijek e-potpisa, AI panel za pregled. Svaki se mjeri po radnji. Kliknite karticu za instalaciju, upotrebu i props."
title = "Komponente"
[portal.componentsView.empty]
description = "Katalog komponenti nije moguće učitati. Pokušajte ponovno uskoro."
title = "Nema dostupnih komponenti"
[portal.componentsView.lockedBanner]
description = "GA komponente dostupne su na planu Pay-as-you-go; nekoliko Beta komponenti dostupno je samo za Enterprise. Zaključane kartice prikazuju poticaj za nadogradnju."
title = "Neke komponente zahtijevaju plaćeni plan"
[portal.docs.authentication]
codeCaption = "svaki zahtjev"
eyebrow = "POČETAK RADA"
@@ -6521,63 +6521,6 @@ title_other = "Dolgozzon fel {{allowance}} PDF-et ingyen"
titleWithRate_one = "Dolgozzon fel {{allowance}} PDF-et ingyen, utána {{rate}}/PDF"
titleWithRate_other = "Dolgozzon fel {{allowance}} PDF-et ingyen, utána {{rate}}/PDF"
[portal.catalogue.card]
lockedAriaLabel = "Zárolva"
openAriaLabel = "{{name}} komponens megnyitása"
[portal.catalogue.detail]
addToProject = "Hozzáadás a projekthez"
ariaLabel = "Komponens részletei"
tabsAriaLabel = "Komponens részleteinek szakaszai"
upgradeToUnlock = "Váltson magasabb csomagra a feloldáshoz"
[portal.catalogue.detail.code]
install = "Telepítés"
usage = "Használat"
[portal.catalogue.detail.locked]
description = "A(z) {{name}} a(z) {{tier}} csomagtól érhető el. Váltson magasabb csomagra a beágyazásához."
title = "Nem érhető el az Ön csomagjában"
[portal.catalogue.detail.preview]
badge = "Élő előnézet"
note = "Az interaktív sandbox itt jelenik meg"
[portal.catalogue.detail.pricing]
note = "Mérés {{unit}} alapján. A havi ingyenes kvótán túli használatot a fiókjára számlázzuk, és a Használat és számlázás alatt tételesen megjelenik."
[portal.catalogue.detail.stats]
billedOn = "Számlázás alapja"
embeds30d = "Beágyazások (30 nap)"
freeQuota = "Ingyenes kvóta"
freeQuotaValue = "{{amount}} / hó"
maturity = "Érettség"
none = "Nincs"
perAction = "Műveletenként"
price = "Ár"
[portal.catalogue.detail.tabs]
code = "Code"
overview = "Áttekintés"
pricing = "Árazás"
props = "Props / API"
[portal.catalogue.props]
optional = "opcionális"
required = "kötelező"
[portal.catalogue.props.columns]
description = "Leírás"
name = "Prop"
required = "Kötelező"
type = "Típus"
[portal.catalogue.summary]
componentsGa = "GA komponensek"
componentSpendMtd = "Komponensköltés (MTD)"
embedsThisMonth = "Beágyazások ebben a hónapban"
inBeta = "Beta állapotban"
[portal.components.billingUnit]
approval = "jóváhagyás"
check = "ellenőrzés"
@@ -6591,18 +6534,6 @@ signature = "aláírás"
beta = "Beta"
ga = "GA"
[portal.componentsView]
subtitle = "Beágyazható SDK widgetek, amelyeket a saját alkalmazásába illeszthet — megjelenítő, e-aláírási folyamat, AI felülvizsgálati panel. Mindegyik műveletenként mért. Kattintson egy kártyára a telepítéshez, használathoz és props megtekintéséhez."
title = "Komponensek"
[portal.componentsView.empty]
description = "A komponenskatalógust nem sikerült betölteni. Próbálja újra rövidesen."
title = "Nincsenek elérhető komponensek"
[portal.componentsView.lockedBanner]
description = "A GA komponensek Pay-as-you-go csomagban érhetők el; néhány Beta komponens csak vállalati csomagban. A zárolt kártyák frissítési javaslatot mutatnak."
title = "Egyes komponensekhez fizetős csomag szükséges"
[portal.docs.authentication]
codeCaption = "minden kérés"
eyebrow = "ELSŐ LÉPÉSEK"
@@ -6521,63 +6521,6 @@ title_other = "Proses {{allowance}} PDF gratis"
titleWithRate_one = "Proses {{allowance}} PDF gratis, lalu {{rate}}/PDF"
titleWithRate_other = "Proses {{allowance}} PDF gratis, lalu {{rate}}/PDF"
[portal.catalogue.card]
lockedAriaLabel = "Terkunci"
openAriaLabel = "Buka komponen {{name}}"
[portal.catalogue.detail]
addToProject = "Tambahkan ke proyek"
ariaLabel = "Detail komponen"
tabsAriaLabel = "Bagian detail komponen"
upgradeToUnlock = "Upgrade untuk membuka akses"
[portal.catalogue.detail.code]
install = "Instal"
usage = "Penggunaan"
[portal.catalogue.detail.locked]
description = "{{name}} disertakan mulai paket {{tier}}. Upgrade untuk menyematkannya."
title = "Tidak tersedia pada paket Anda"
[portal.catalogue.detail.preview]
badge = "Pratinjau langsung"
note = "Sandbox interaktif dirender di sini"
[portal.catalogue.detail.pricing]
note = "Bermeter per {{unit}}. Penggunaan di luar kuota gratis bulanan ditagihkan ke akun Anda dan dirinci di Penggunaan & Penagihan."
[portal.catalogue.detail.stats]
billedOn = "Ditagih pada"
embeds30d = "Embed (30h)"
freeQuota = "Kuota gratis"
freeQuotaValue = "{{amount}} / bln"
maturity = "Kematangan"
none = "Tidak ada"
perAction = "Per tindakan"
price = "Harga"
[portal.catalogue.detail.tabs]
code = "Code"
overview = "Ikhtisar"
pricing = "Harga"
props = "Props / API"
[portal.catalogue.props]
optional = "opsional"
required = "wajib"
[portal.catalogue.props.columns]
description = "Deskripsi"
name = "Prop"
required = "Wajib"
type = "Tipe"
[portal.catalogue.summary]
componentsGa = "Komponen GA"
componentSpendMtd = "Pengeluaran komponen (MTD)"
embedsThisMonth = "Embed bulan ini"
inBeta = "Dalam beta"
[portal.components.billingUnit]
approval = "persetujuan"
check = "pemeriksaan"
@@ -6591,18 +6534,6 @@ signature = "tanda tangan"
beta = "Beta"
ga = "GA"
[portal.componentsView]
subtitle = "Widget SDK yang dapat disematkan dan Anda letakkan di aplikasi sendiri — viewer, alur e-sign, panel tinjauan AI. Masing-masing bermeter per tindakan. Klik kartu untuk instalasi, penggunaan, dan props."
title = "Komponen"
[portal.componentsView.empty]
description = "Katalog komponen tidak dapat dimuat. Coba lagi sebentar lagi."
title = "Tidak ada komponen tersedia"
[portal.componentsView.lockedBanner]
description = "Komponen GA tersedia pada Bayar sesuai pemakaian; beberapa komponen Beta hanya untuk enterprise. Kartu terkunci menampilkan dorongan upgrade."
title = "Beberapa komponen memerlukan paket berbayar"
[portal.docs.authentication]
codeCaption = "setiap permintaan"
eyebrow = "MEMULAI"
@@ -6521,63 +6521,6 @@ title_other = "Elabora {{allowance}} PDF gratuitamente"
titleWithRate_one = "Elabora {{allowance}} PDF gratuitamente, poi {{rate}}/PDF"
titleWithRate_other = "Elabora {{allowance}} PDF gratuitamente, poi {{rate}}/PDF"
[portal.catalogue.card]
lockedAriaLabel = "Bloccato"
openAriaLabel = "Apri componente {{name}}"
[portal.catalogue.detail]
addToProject = "Aggiungi al progetto"
ariaLabel = "Dettaglio componente"
tabsAriaLabel = "Sezioni dettaglio componente"
upgradeToUnlock = "Esegui l'upgrade per sbloccare"
[portal.catalogue.detail.code]
install = "Installazione"
usage = "Uso"
[portal.catalogue.detail.locked]
description = "{{name}} è incluso dal piano {{tier}}. Esegui l'upgrade per incorporarlo."
title = "Non disponibile nel tuo piano"
[portal.catalogue.detail.preview]
badge = "Anteprima live"
note = "La sandbox interattiva viene renderizzata qui"
[portal.catalogue.detail.pricing]
note = "Misurato per {{unit}}. L'uso oltre la quota gratuita mensile viene addebitato al tuo account e dettagliato in Uso e fatturazione."
[portal.catalogue.detail.stats]
billedOn = "Fatturato su"
embeds30d = "Incorporamenti (30g)"
freeQuota = "Quota gratuita"
freeQuotaValue = "{{amount}} / mese"
maturity = "Maturità"
none = "Nessuno"
perAction = "Per azione"
price = "Prezzo"
[portal.catalogue.detail.tabs]
code = "Code"
overview = "Panoramica"
pricing = "Prezzi"
props = "Props / API"
[portal.catalogue.props]
optional = "facoltativo"
required = "obbligatorio"
[portal.catalogue.props.columns]
description = "Descrizione"
name = "Prop"
required = "Obbligatorio"
type = "Tipo"
[portal.catalogue.summary]
componentsGa = "Componenti GA"
componentSpendMtd = "Spesa componenti (MTD)"
embedsThisMonth = "Incorporamenti questo mese"
inBeta = "In beta"
[portal.components.billingUnit]
approval = "approvazione"
check = "controllo"
@@ -6591,18 +6534,6 @@ signature = "firma"
beta = "Beta"
ga = "GA"
[portal.componentsView]
subtitle = "Widget SDK incorporabili che inserisci nella tua app — un visualizzatore, un flusso di firma elettronica, un pannello di revisione AI. Ognuno è misurato per azione. Fai clic su una scheda per installazione, uso e props."
title = "Componenti"
[portal.componentsView.empty]
description = "Impossibile caricare il catalogo dei componenti. Riprova tra poco."
title = "Nessun componente disponibile"
[portal.componentsView.lockedBanner]
description = "I componenti GA sono disponibili con Pay-as-you-go; alcuni componenti Beta sono solo enterprise. Le schede bloccate mostrano un invito all'upgrade."
title = "Alcuni componenti richiedono un piano a pagamento"
[portal.docs.authentication]
codeCaption = "ogni richiesta"
eyebrow = "PER INIZIARE"
@@ -6521,63 +6521,6 @@ title_other = "{{allowance}} 件のPDFを無料で処理"
titleWithRate_one = "{{allowance}} 件のPDFを無料で処理、その後 {{rate}}/PDF"
titleWithRate_other = "{{allowance}} 件のPDFを無料で処理、その後 {{rate}}/PDF"
[portal.catalogue.card]
lockedAriaLabel = "ロック中"
openAriaLabel = "{{name}} コンポーネントを開く"
[portal.catalogue.detail]
addToProject = "プロジェクトに追加"
ariaLabel = "コンポーネント詳細"
tabsAriaLabel = "コンポーネント詳細セクション"
upgradeToUnlock = "アップグレードしてロック解除"
[portal.catalogue.detail.code]
install = "インストール"
usage = "使用方法"
[portal.catalogue.detail.locked]
description = "{{name}} は {{tier}} プランから含まれます。埋め込むにはアップグレードしてください。"
title = "ご利用のプランでは利用できません"
[portal.catalogue.detail.preview]
badge = "ライブプレビュー"
note = "インタラクティブなサンドボックスがここにレンダリングされます"
[portal.catalogue.detail.pricing]
note = "{{unit}} ごとに従量課金されます。月間無料枠を超えた使用量はアカウントに請求され、Usage & Billing に明細表示されます。"
[portal.catalogue.detail.stats]
billedOn = "請求単位"
embeds30d = "埋め込み(30日)"
freeQuota = "無料枠"
freeQuotaValue = "{{amount}} / 月"
maturity = "成熟度"
none = "なし"
perAction = "アクションごと"
price = "価格"
[portal.catalogue.detail.tabs]
code = "Code"
overview = "概要"
pricing = "料金"
props = "Props / API"
[portal.catalogue.props]
optional = "任意"
required = "必須"
[portal.catalogue.props.columns]
description = "説明"
name = "Prop"
required = "必須"
type = "タイプ"
[portal.catalogue.summary]
componentsGa = "Components GA"
componentSpendMtd = "コンポーネント利用額(MTD)"
embedsThisMonth = "今月の埋め込み数"
inBeta = "Beta 中"
[portal.components.billingUnit]
approval = "承認"
check = "チェック"
@@ -6591,18 +6534,6 @@ signature = "署名"
beta = "Beta"
ga = "GA"
[portal.componentsView]
subtitle = "自分のアプリに組み込める SDK ウィジェット — ビューア、電子署名フロー、AI レビューパネル。各項目はアクションごとの従量課金です。カードをクリックすると、インストール、使用方法、props を確認できます。"
title = "コンポーネント"
[portal.componentsView.empty]
description = "コンポーネントカタログを読み込めませんでした。しばらくしてからもう一度お試しください。"
title = "利用可能なコンポーネントはありません"
[portal.componentsView.lockedBanner]
description = "GA コンポーネントは従量課金で利用できます。一部の Beta コンポーネントは Enterprise 専用です。ロックされたカードにはアップグレード案内が表示されます。"
title = "一部のコンポーネントには有料プランが必要です"
[portal.docs.authentication]
codeCaption = "すべてのリクエスト"
eyebrow = "はじめに"
@@ -6521,63 +6521,6 @@ title_other = "PDF {{allowance}}개 무료 처리"
titleWithRate_one = "PDF {{allowance}}개 무료 처리 후 {{rate}}/PDF"
titleWithRate_other = "PDF {{allowance}}개 무료 처리 후 {{rate}}/PDF"
[portal.catalogue.card]
lockedAriaLabel = "잠김"
openAriaLabel = "{{name}} 구성 요소 열기"
[portal.catalogue.detail]
addToProject = "프로젝트에 추가"
ariaLabel = "구성 요소 상세"
tabsAriaLabel = "구성 요소 상세 섹션"
upgradeToUnlock = "업그레이드하여 잠금 해제"
[portal.catalogue.detail.code]
install = "설치"
usage = "사용법"
[portal.catalogue.detail.locked]
description = "{{name}}은(는) {{tier}} 플랜부터 포함됩니다. 임베드하려면 업그레이드하세요."
title = "현재 플랜에서 사용할 수 없음"
[portal.catalogue.detail.preview]
badge = "라이브 미리보기"
note = "Interactive sandbox가 여기에 렌더링됩니다"
[portal.catalogue.detail.pricing]
note = "{{unit}}당 종량제입니다. 월별 무료 할당량을 초과한 사용량은 계정에 청구되며 Usage & Billing에 항목별로 표시됩니다."
[portal.catalogue.detail.stats]
billedOn = "청구 기준"
embeds30d = "임베드(30일)"
freeQuota = "무료 할당량"
freeQuotaValue = "{{amount}} / 월"
maturity = "성숙도"
none = "없음"
perAction = "작업당"
price = "가격"
[portal.catalogue.detail.tabs]
code = "Code"
overview = "개요"
pricing = "가격"
props = "Props / API"
[portal.catalogue.props]
optional = "선택 사항"
required = "필수"
[portal.catalogue.props.columns]
description = "설명"
name = "Prop"
required = "필수"
type = "유형"
[portal.catalogue.summary]
componentsGa = "구성 요소 GA"
componentSpendMtd = "구성 요소 지출(MTD)"
embedsThisMonth = "이번 달 임베드"
inBeta = "Beta 중"
[portal.components.billingUnit]
approval = "승인"
check = "확인"
@@ -6591,18 +6534,6 @@ signature = "서명"
beta = "Beta"
ga = "GA"
[portal.componentsView]
subtitle = "자체 앱에 넣을 수 있는 임베드 가능한 SDK 위젯 — viewer, e-sign 흐름, AI 검토 패널. 각 항목은 작업당 종량제입니다. 설치, 사용법, props를 보려면 카드를 클릭하세요."
title = "구성 요소"
[portal.componentsView.empty]
description = "구성 요소 카탈로그를 불러올 수 없습니다. 잠시 후 다시 시도하세요."
title = "사용 가능한 구성 요소 없음"
[portal.componentsView.lockedBanner]
description = "GA 구성 요소는 Pay-as-you-go에서 사용할 수 있으며, 일부 Beta 구성 요소는 enterprise 전용입니다. 잠긴 카드에는 업그레이드 안내가 표시됩니다."
title = "일부 구성 요소에는 유료 플랜이 필요합니다"
[portal.docs.authentication]
codeCaption = "모든 요청"
eyebrow = "시작하기"

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