Skip to content

feat: Tolgee Apps — dashboard-page apps, app auth, and the apps SDK - #3844

Draft
JanCizmar wants to merge 75 commits into
mainfrom
jancizmar/apps-01-register-enable
Draft

feat: Tolgee Apps — dashboard-page apps, app auth, and the apps SDK#3844
JanCizmar wants to merge 75 commits into
mainfrom
jancizmar/apps-01-register-enable

Conversation

@JanCizmar

Copy link
Copy Markdown
Member

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-page module 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 instead of a capability that quietly does nothing.

Authentication

  • App tokens are JWTs with their own audience (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.
  • 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 (the dashboard iframe) is capped to install scopes ∩ the user's project permissions — an app can never exceed the person using it.
  • An install-context token (an app backend) comes from the OAuth 2.0 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 and availability

  • Organization owners register an app by manifest URL through the UI.
  • Apps can self-register with a server-wide secret, connecting 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.
  • Self-registering without an organization creates a native app, owned by none. A server admin then grants it to organizations (or to all organizations, including ones created later) under Administration → Apps, and can deregister it. Revoking availability cascades, disabling the app in that organization's projects.

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

  • The root package.json gains a workspaces field for apps/*, which changes what a bare npm install at the repo root does. The large package-lock.json diff is npm re-resolving the tree because of it.
  • Rebased onto the Spring Boot 4 / Jackson 3 upgrade; the last commit is that adaptation.
  • Feature-flagged off by default (tolgee.apps.enabled).

Known follow-ups (not in this PR)

  • @tolgee/apps-sdk is unpublished — no published version has selfRegisterApp, so scaffolding outside this repo relies on a local file: dependency.
  • New webapp translation keys still need pushing to Tolgee (all have defaultValue, so the UI reads correctly meanwhile).
  • Apps must be hosted on an origin distinct from Tolgee's: the iframe sandbox uses allow-scripts allow-same-origin, which stops isolating if an app is served from the Tolgee origin.

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.
@coderabbitai

coderabbitai Bot commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

Important

Review skipped

Draft detected.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 4ac45030-1a3e-4594-bd17-c457165248c1

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

Comment @coderabbitai help to get the list of available commands.

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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant