feat: Tolgee Apps — dashboard-page apps, app auth, and the apps SDK - #3844
Draft
JanCizmar wants to merge 75 commits into
Draft
feat: Tolgee Apps — dashboard-page apps, app auth, and the apps SDK#3844JanCizmar wants to merge 75 commits into
JanCizmar wants to merge 75 commits into
Conversation
Productionizes the Tolgee Apps POC as a vertical slice: an app registers against Tolgee, gets scoped credentials, renders a page inside a project, and calls the REST API under enforced permissions. Scope of the manifest is deliberately narrow: only `project-dashboard-page` is supported. Any other module type, plus top-level `webhooks` and `decoratorsUrl`, is rejected by name at registration rather than silently ignored, so an app author gets an explicit error. Authentication - App tokens are JWTs with their own audience (`tg.app`), signed with a dedicated key derived from — but domain-separated from — the user-session key, and with their own short lifetime (`tolgee.apps.token-expiration`, 1 hour) rather than the 7-day session expiry. - Tokens are thin pointers: they carry identity, never permissions. Every request re-resolves scopes from the database, so revoking a scope or disabling an app takes effect immediately for tokens already issued. - A user-context token (for the iframe) is capped to the intersection of the install's granted scopes and the user's own project permissions, so an app can never exceed the person using it. - An install-context token (for an app backend) is obtained through the OAuth client-credentials grant, so the client secret only ever travels to the token endpoint. The secret is shown once at registration; only its hash and a short display prefix are stored. Registration - Org owners register an app by manifest URL through the UI. - Apps can also self-register with a server-wide secret, which lets an app connect to a running server without a restart or any UI. Re-registering repoints an existing install at a new manifest URL but never re-issues the client secret. - An app that self-registers without an organization becomes a native app owned by no organization; a server admin then grants it to organizations from Administration -> Apps. Revoking availability cascades, disabling the app in that organization's projects. Tooling - `@tolgee/apps-sdk` — postMessage handshake, typed REST client, theme helpers, manifest rendering, self-registration and token exchange. - `create-tolgee-app` — scaffolds an app, wiring the local SDK and an optional Cloudflare dev tunnel for use against a remote Tolgee. - `keys-showcase` — example app rendering ten localization keys.
Auto-connect existed to let an app attach itself to a running server, but it demanded an organization slug — which put the per-organization decision in the app's environment file, where an app author controls it. That decision belongs to a server admin. Self-registration now needs only the registration secret and produces a native install owned by no organization; which organizations may use it is granted afterwards under Administration -> Apps, and a project owner enables it per project from there. The backend already supported both shapes, so this only changes the app-side default: `selfRegisterApp` takes `organizationSlug` as optional and omits it from the request rather than sending an empty value, and reports back whether the install came out native. Passing a slug still installs into that single organization for anyone who wants it. Also drops the organization prompt from the scaffolder's auto-connect flow and its --org requirement, and updates the env templates and READMEs to describe registration and per-organization availability as the separate steps they are.
Two gaps in the server-administration screen for native apps. An admin could grant an app organization by organization, but had no way to say "every organization". Doing that by inserting a row per organization would have silently missed every organization created afterwards, so this is a flag on the install instead: `availableToAllOrganizations` covers current and future organizations alike. The flag and the explicit grants stay independent. Turning the flag on leaves the per-organization grants untouched, so turning it off falls back to exactly those — and that fallback disables the app only in projects that were covered solely by the blanket grant. For the same reason, revoking a single organization while the flag is on no longer disables that organization's projects: they are still covered. The second gap: an app could be registered but never removed. Deregistering a native install now deletes its organization availability, its enablement in every project, and the install itself, after which its client credentials stop working. Both operations act on native installs only — an organization-owned install returns 404, so an admin cannot reach into an organization's own app from here — and both require the admin role, leaving supporters a read-only screen.
Rebasing onto main brought in the Spring Boot 4 upgrade, which moves Jackson's databind and Kotlin module from `com.fasterxml.jackson.*` to `tools.jackson.*` (the annotations keep their original package). Retargets the apps code that reads manifests, and regenerates the API schema and Cypress data-cy types against the rebased backend.
Contributor
|
Important Review skippedDraft detected. Please check the settings in the CodeRabbit UI or the ⚙️ Run configurationConfiguration used: Path: .coderabbit.yaml Review profile: CHILL Plan: Pro Run ID: You can disable this status message by setting the Use the checkbox below for a quick retry:
Comment |
ktlint requires lexicographic import order; renaming com.fasterxml.jackson.* to tools.jackson.* in place left them sorted under the old package name.
The project menu listed apps through the endpoint that returns the whole organization inventory, which requires project.edit because it discloses apps that are not enabled for the project. Every member without that permission therefore got a 403 on every project page, which broke rendering — the E2E permission suites failed on core elements, not on anything apps-related. The sidebar only ever needed the apps already enabled for the project, so this adds an endpoint scoped to exactly that, readable by any project member, and points the menu at it. The inventory endpoint keeps its project.edit requirement for the project settings screen.
Liquibase's generated diff wanted to drop the not-null constraints our changesets declare, because the mappings did not: a plain @manytoone is optional by default, and the lateinit String fields carried no column nullability. Declares what the database already enforces.
The policy guard test asserts every managed entity is classified. The three apps entities are installation and administration state — an install belongs to a server or an organization, and its project enablement is a local decision — so none of it travels with a project export.
The apps controllers are conditional on tolgee.apps.enabled, and the dev profile config that sets it is git-ignored, so CI generated a schema without any apps endpoint and then failed against the committed one.
An admin can now add a native (server-level) app by pasting a manifest URL, instead of the app having to self-register with the server-wide secret. The administration apps controller gets preview and register endpoints mirroring the per-organization ones, backed by the same native-registration path in AppInstallService. Registering discloses the client secret exactly once, as the organization flow does. The two-step manifest URL -> consent dialog is extracted into a shared component so the administration and organization screens are the same UI.
The app rows showed two unlabelled chip rows, so nothing told the user that
one lists the pages the app adds to the project menu and the other the
permissions it was granted. Both rows now carry a caption with an
explanatory tooltip, and say so when they are empty.
The page chips also concatenated the manifest icon into the label, which
rendered a native icon name ("Key01") as literal text. They now go through
AppIcon like the project menu does. AppIcon only forces a size when one is
given, so an app icon in the menu matches the built-in items instead of
rendering smaller, and the emoji fallback follows the same size.
Granting an organization access used to be only half the job — somebody still had to open every project's settings and switch the app on there. The availability dialog now pairs the organization single-select with a project picker, so one action grants access and enables the app for the chosen projects (availability first, since enablement is gated on it). Selecting projects stays optional, so availability alone still works. Enablement runs per project and reports what happened: a partial failure says the organization does have access, which projects could not be enabled, and leaves those selected so the admin can retry.
Sharing one dialog component meant the administration screen borrowed the organization screen's translation keys, so admin-specific wording had nowhere to live. The dialog is now a set of presentational blocks — shell, manifest-URL step, consent step — plus a state hook, none of which hold strings. Each screen composes them and passes its own literal-keyed labels and data-cy values, so the administration screen gets administration_apps_register_* keys while the organization screen keeps its existing, already-translated ones.
…tion The project picker listed only the first 100 projects and told the user the rest were missing, which put larger organizations out of reach from this dialog. It is now an infinite multi-search-select over the organization's projects, following the assigned-projects select used for glossaries: server-side search with load-more paging, so every project is reachable. Selection stays optional, the no-projects state and the partial-failure retry are unchanged.
Tolgee shows an app's client secret exactly once and keeps only its hash, so the register-then-copy-paste flow lost the credentials for good whenever the developer missed the line — and the app could never use the machine-to-machine flow. selfRegisterApp() now writes the whole install record (install id, client id, client secret, and the tolgeeUrl it belongs to) to .tolgee-dev/install.json, and loadTolgeeAppConfig() / fetchAppAccessToken() read it back, so `npm run token` works with no manual setup once the app has registered. The example app and the generator template no longer print the secret at all. TOLGEE_APP_CLIENT_ID / TOLGEE_APP_CLIENT_SECRET still win, so a deployment is never overridden by a stale local file; setting either one ignores the file entirely rather than mixing an env id with a stored secret. Records are keyed by Tolgee instance, a re-registration reporting clientSecret: null keeps the stored secret, and writes go through a temp file plus rename so an interrupted write cannot corrupt the file.
A manifest `icon` takes a native Tolgee icon name or an emoji, and names must match an exported icon component exactly — `Key01` and `Key02` exist, bare `Key` does not. An unrecognised value renders as literal text rather than failing, so a typo is easy to ship and easy to miss. State the exact-match rule, where the names come from, concrete valid ones, and the literal-text fallback everywhere an app author looks: the SDK manifest type, the SDK README, the example app's README, and the generated app's README.
The module-level Map keyed only by project + install survived logout, so the next user signing in in the same tab got the previous user's iframe token, and it was never re-minted, so a tab open past tolgee.apps.token-expiration handed the iframe a dead token. Mint through react-query instead, with the entry dropped as soon as nothing renders it and the next mint scheduled off the token's own exp claim.
A manifest icon of "constructor" resolved to Object through the plain-object registry, and React.createElement(Object) took the whole project sidebar down. Names now come out of Maps, so anything not registered falls back to the text path as documented. The registry also namespace-imported @untitled-ui/icons-react, pinning all ~1200 icon components into the chunk that holds the project menu. Icons are reached through per-icon lazy chunks now, which keeps the named-import tree shaking used everywhere else intact.
The sidebar lists apps from /apps/enabled, which every project member can read, while the page itself still used /apps, which requires project.edit. A member without it saw the app in the menu and got "Unknown app page" after clicking. The missing-page state was also the pre-load state, so it flashed on every open; show the loading placeholder until the query settles.
The backend validates a dashboard entry with URI(baseUrl).resolve(entry) while the iframe src was built by concatenation. For an entry like "foo" against a baseUrl carrying a path, or "/foo" against any baseUrl, the two disagree, so a manifest that passed validation could load a different URL.
The four app error keys had no defaultValue, so a failed registration rendered app_manifest_fetch_failed to the user. Adds defaults, plus the missing app_not_available_for_organization case, which the project Apps UI can hit. The register dialog's cancel button also introduced its own cancel_button key instead of the translated global_cancel_button.
The loop already only keeps keys prefixed with data-cy-, which data-cy is not.
…able URL The manifest URL was built from the Cypress-side API_URL but is fetched by the server, which cannot reach the host port once TOLGEE_E2E_PORT remaps it — every feature worktree. Point it at the port the server listens on internally. The register-from-manifest-URL steps were also copy-pasted between two specs.
…lved Both assertions were vacuous: "no menu entry" ran before the query resolved, and the missing-page state was also the page's pre-load state, so neither could fail. Wait for the query, and assert the menu entry is gone after disabling.
…outcome Replays the backfill against a database that already holds installs — the case a fresh install never exercises — with tables cloned from the live ones and the statements read out of schema.xml, so the test runs the changeset rather than a restatement of it.
Two organizations registering the same manifest at the same moment both pass the "is it registered yet" check and one of them hits the server-wide unique index. That surfaced as an unhandled data integrity violation; it now reports `app_already_registered`, which tells the caller to install instead.
An app now has credentials at two layers: the app-level pair it is registered with, and one pair per organization that installs it. The state file grows an app record and keys installs by id instead of holding one per Tolgee URL. A file written by the previous SDK is read forward — the secret in it exists nowhere else, so discarding it would strand every app already in the wild.
Tolgee POSTs credentials to the app's manifest baseUrl instead of making somebody copy them. mountTolgeeLifecycle() verifies each delivery against the webhook secret, refuses a timestamp more than five minutes from the app's clock and a signature already seen inside that window, stores what the delivery carries, and hands typed events to the app. The registration delivery discloses the webhook secret it is signed with, so it can only be checked against the key it brought along. That one is trusted while the app holds nothing for the instance and refused the moment it does, which is what stops a stranger overwriting a live install. Every later delivery, rotation included, is checked against the stored secret and may replace what is held.
…late One mountTolgeeLifecycle() call each. The template mounts it ahead of express.json(), which would otherwise consume the bytes the signature covers. Self-registration is untouched: an app that never receives a delivery keeps running on the credentials it registered with.
What each event carries, how a delivery is verified, why the webhook secret is what proves a delivery is really Tolgee, and why the first one is the awkward case.
An app now learns what happened to it over a signed POST to the baseUrl in its manifest: registered (app-level credentials), installed (per-install credentials, the install id and the organization), uninstalled, and secret rotated. That delivery is the only channel per-install credentials travel over, and receiving it is what proves control of the app's domain. The signature is the one project webhooks already use, extracted into WebhookSigner so there is a single scheme for an app author to verify against. Outbound calls share appsRestTemplate with the manifest fetch, so they go through the same SSRF guard, and plaintext HTTP is refused outside development because credentials travel in the body. Delivery is fire-and-forget: the record is written in its own transaction, the HTTP happens on another thread, and retries back off exponentially. A dead app host can neither block nor roll back the install that triggered the delivery. The payload is deliberately not persisted — it carries a bearer credential, and those are only ever stored hashed — so a delivery that outlives its process is abandoned rather than resumed, and the app is recovered by rotating, which delivers again.
Rotation at the app layer mirrors the install layer: issue while the old secret still authenticates, revoke it separately. Both the owning organization and the app itself can do it — the app authenticates with the app-level credentials directly rather than exchanging them for a token, so they never become a session that could reach a tenant's data. Issuing both returns the new secret once and pushes it over the lifecycle channel, since an operator cannot read a delivery and an unattended app has no response to paste anywhere. As at the install layer, an operator may revoke the last live secret as a kill switch and the app may not. Removal is the owner taking the app off the shelf: it uninstalls from every organization, drops both layers of credentials with the app row, and emits an uninstalled delivery per organization. The delivery rows survive the app they announce the removal of, which is exactly what an owner needs to see afterwards. All of it hangs off /owned-apps, resolved within the owning organization, so an organization that merely installed somebody else's app reaches none of it.
Every registered app's manifest is re-fetched periodically. A failure has to survive both a minimum number of consecutive checks and a wall-clock window before the app is marked unhealthy and its owner emailed, so neither a single failure nor a burst of them inside one outage counts. Only after a further grace period is the app removed from every organization, through the same path the owner's own removal takes, so the removal is announced. Defaults give an app a day of unreachability before it is called unhealthy and a fortnight after that before anything is destroyed, and the destructive step itself is off unless switched on: a long egress or DNS outage on our side is indistinguishable from an app that is gone, and the health state and the notification are useful without it. Only "nothing answered" can ever lead to removal. A manifest that is served but no longer valid means its author is still there, so it is surfaced and left alone. The URL trusted is the app's own, never an install's, which may have drifted to a development tunnel that has since moved. Follows KeyTrashPurgeScheduler: ApplicationReadyEvent, SchedulingManager and withLockingIfFree, never @scheduled. Each app is checked outside any transaction so one slow host does not delay the rest of the sweep.
An app is now registered once and installed by organizations, so the apps screen splits into the apps this organization installed and the apps it owns — an app it registered itself appears under both. Installing a manifest nobody has registered yet stops and says so, offering to register it, and the registration shows the app-level credentials the one time they exist. An owner can list, issue and revoke those credentials, remove the app from every organization at once, and see why an app is unhealthy or which lifecycle deliveries never arrived — which is how they find out an install is sitting there without the credentials it was sent. Changes made by an app also stop showing an empty author.
The app layer added App, AppSecret, AppDelivery and AppInstallSecret without adding them to ProjectExportImportPolicyRegistry, so the build gate that requires every managed entity to be classified failed. None of them is project data, so they are IGNORED like the installs already were. Registering is also its own step now, so the e2e app specs approve the install, register from the not-registered screen and dismiss the app-level credentials before the app is listed.
An app is registered once for the whole server and outlives the organization that registered it, which the clean only soft-deletes. Left registered, the next test installs the app instead of registering it and never reaches the not-registered screen the register flow goes through.
…ization lookup The refusal already happened, one interceptor later, but only after the organization view check had run against the token's principal. Once that principal is a member of nothing, that check answers "no such organization" and the caller gets a 404 instead of the app-access error explaining the rule. No route under /v2/organizations serves apps, so state the rule where it is first reachable.
An install-context request carried the UserAccountDto of the person who registered the install. Anything resolving that to a live user_account row — authenticationFacade.authenticatedUserEntity above all — failed once that person was disabled or deleted, which took out every endpoint writing a user foreign key: a translation comment, an import, a batch job, a suggestion. Each install now owns a user_account row of its own and runs as that. The row is enabled, so every live-row lookup and every foreign key works with no call-site change; it holds no organization role and no project permission, so it grants nothing and the install's capability stays its granted scopes. is_app_principal keeps it out of what counts and lists people — seats, the administration user list, telemetry, the sign-in and sign-up lookups, and the legacy job that hands an organization to every user without one — and its MANAGED account type refuses the native sign-in path outright. Removing an install retires its principal by soft deletion, the same as for a person who leaves: the comments and imports it wrote keep pointing at something. The author stays as the historical "created by" record. The migration backfills a principal for every install already registered.
Covers what the principal has to be: an account that lets the install write a comment while the person who registered it is disabled and while they are deleted, that is named after the app, that takes no seat, appears in neither the organization's member list nor the server's user list, cannot be signed in as, and is retired when the install goes. Acting as a disabled user still fails, and an app acting as itself is still recorded against the install. The author-role-leak case now also proves the admin role does not bypass a scope check inside the project the app is enabled for, and the backfill test proves every pre-existing install gets a principal of its own.
… name The keys-showcase manifest now names a Tolgee icon rather than an emoji, which is the case an app author is most likely to get wrong — icon names must match an exported component exactly.
The iframe is framed by Tolgee's web app, but the example apps and the scaffold pinned the origin of TOLGEE_URL — the API. Production usually serves both from one host, so the two agree there; a development setup does not, and the app refused the init message it was sent, leaving the page with no context and no data. Both origins are pinned now, and TOLGEE_FRONTEND_URL names the web app when it differs. `tolgeeOrigin` also accepts a comma-separated string, since it generally arrives from an environment variable.
…oses Tolgee returns the app block only on the call that first registers the app, and the SDK read past it — so a self-registering app dropped the one copy of its app-level client secret and signing secret, and could never rotate at that layer. They are stored alongside the install now, and a later call that discloses none leaves what is held alone.
An app can now exchange its own app-level credentials plus an install id for an install-context access token, the way GitHub Apps derive installation tokens. Revoking any of the app's secrets stamps a cutoff on the app, and every token issued before it stops authenticating at once — so a compromised app is recovered by rotating one secret instead of uninstalling it from every organization. The cutoff is stored truncated to the second because a JWT's `iat` is, so the token an app mints to recover from the revocation is not itself rejected. Install credentials keep working unchanged; they are removed separately.
…discovery Install credentials are gone: the app's own credentials plus an install id are the only way to mint an install-scoped token, matching how GitHub Apps derive installation tokens. Registering or installing an app discloses nothing at the install level anymore — the response and the lifecycle channel carry only the app-level credentials, and only on the call that registered the app. POST /v2/public/apps/installations/list is the new entry point of the machine-to-machine flow: it authenticates with the app credentials alone and returns every installation with its enabled projects, which is what the token endpoint needs an id from. Without it the single-credential model could not bootstrap — minting needed an install id and listing installs needed a token. The app_install_secret table and app_install.client_id are dropped.
…n memory The SDK follows the single-credential model: install records in the state file carry no credentials anymore (version 3), the app-level pair is the only thing stored, and fetchAppAccessToken exchanges it plus an install id for a token — cached in memory per install and refreshed shortly before expiry, with invalidateAppAccessToken for the 401-after-revocation path. fetchAppInstallations authenticates with the app credentials alone against the new discovery endpoint, so it is the entry point that needs no install id. rotateAppClientSecret rotates the app secret (the only one left) through the app-secrets endpoint, and ensureAppCredentialsFresh ages it out as before. Lifecycle deliveries no longer parse or store install credentials, and the install-level rotation event is gone.
Under the single-credential model the app secret mints the tokens that reach translation data, so every dialog, schema description and doc comment saying it 'grants access to no data' was describing the deleted design. Found by walking the registration flow in the browser after the model change.
The migration test clones live tables into a scratch schema, and changeset -56 removed app_install_secret from the live schema — on an upgrade the backfill under test runs while the table still exists, so the scratch copy is now created by hand with its historical shape. Also regenerates the API schema for the corrected owned-apps secrets description.
…e retry engine Only two events still carry a secret — an app being registered and an operator rotating its secret — and both happen with a human at a dialog. So the delivery is now synchronous and its outcome comes back in the response the dialog renders: "the app received these automatically" or "couldn't reach the app, copy the secret now". A failure is a value, never thrown, because the credentials were returned in the response too. The installed and uninstalled deliveries are gone (they carried no secret; an app sees its installs change in its own discovery call), and with them the whole fire-and-forget machinery: the dispatcher, the retry scheduler, the pending-delivery holder, the AppDelivery entity, its repository, model, assembler, the owner deliveries endpoint and dialog, the app_delivery table and the retry-tuning properties. Self-registration and app-initiated rotation deliver nothing — the caller is the app and reads the credentials from its own response.
…st deliveries Backend: revoking an app secret is refused while the app has not demonstrably moved to a replacement — no other live secret has been used yet. That catches the ordinary mistake of revoking the old secret before the app picked up the new one. `?force=true` overrides it — the kill switch for a leaked secret, where cutting the app off now is the point. First use of a secret is now stamped synchronously so the guard cannot be raced by an async write. SDK: a first delivery signs with the secret it carries, which proves nothing, so the receiver now confirms the delivered credentials against the CONFIGURED Tolgee (never a URL from the payload) before trusting them, and rate-limits those checks so a flood of forged deliveries cannot turn the app into an outbound amplifier. Verified deliveries are trusted; unverified ones are rejected. Opt out with verifyCredentials:false to fall back to trust-on-first-use. Frontend: the revoke dialog offers "revoke anyway" when the guard fires.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Productionizes the Tolgee Apps POC as a vertical slice: an app registers against Tolgee, gets scoped credentials, renders a page inside a project, and calls the REST API under enforced permissions.
Scope is deliberately narrow — only the
project-dashboard-pagemodule is supported. Any other module type, plus top-levelwebhooksanddecoratorsUrl, is rejected by name at registration rather than silently ignored, so an app author gets an explicit error instead of a capability that quietly does nothing.Authentication
tg.app), signed with a dedicated key domain-separated from the user-session key, and with their own short lifetime (tolgee.apps.token-expiration, default 1 hour) rather than the 7-day session expiry.install scopes ∩ the user's project permissions— an app can never exceed the person using it.Registration and availability
Tooling (
apps/)@tolgee/apps-sdk— postMessage handshake, typed REST client, theme helpers, manifest rendering, self-registration and token exchange.create-tolgee-app— scaffolds an app, wiring the local SDK and an optional Cloudflare dev tunnel for use against a remote Tolgee.keys-showcase— example app rendering ten localization keys.Notes for reviewers
package.jsongains aworkspacesfield forapps/*, which changes what a barenpm installat the repo root does. The largepackage-lock.jsondiff is npm re-resolving the tree because of it.tolgee.apps.enabled).Known follow-ups (not in this PR)
@tolgee/apps-sdkis unpublished — no published version hasselfRegisterApp, so scaffolding outside this repo relies on a localfile:dependency.defaultValue, so the UI reads correctly meanwhile).allow-scripts allow-same-origin, which stops isolating if an app is served from the Tolgee origin.