Skip to content

Chore/merge release 4.7.0 to main - #1995

Merged
ceciliaromao merged 6 commits into
mainfrom
chore/merge-release-4.7.0-to-main
Aug 20, 2026
Merged

ceciliaromao merged 6 commits into
mainfrom
chore/merge-release-4.7.0-to-main

Conversation

@ceciliaromao

Copy link
Copy Markdown
Collaborator

Description

Merges release/4.7.0 into main for the 4.7.0 release.

Context

N/A

Testing

./gradlew test

Documentation

N/A

Known limitations

N/A

amandagonsalves and others added 6 commits August 4, 2026 10:22
Merges main into develop for the 4.6.2 release.
…n-extended threshold/weight parsing on the RPC ledger backend (#1987)

### Description

`StellarRpc.getAccount()` reads an account's `med_threshold` and
master-key weight from the on-chain XDR `Thresholds` field, which the
Java SDK models as a signed `byte[]` even though the wire type is
`uint8`. The code widened each byte with a plain `(int)`/`(long)` cast,
which in Java sign-extends: any byte in `[128,255]` becomes negative
(`200` → `-56`, `255` → `-1`). `Sep10Service` then compares that value
against the summed SEP-10 challenge signer weights via the SDK's
`Sep10Challenge.verifyChallengeTransactionThreshold`, which does a
signed `if_icmpge`. A negative threshold makes that comparison
unconditionally true, so on accounts with `med_threshold >= 128`
(institutional custody, issuers, or any account using per-signer weights
like `50/100/255` instead of `1/2/3`), a single listed signer of any
weight — including a master key the owner deliberately demoted to weight
0 after a compromise — is enough to obtain a valid SEP-10 JWT for that
account. The same cast applied to the master weight produces the
mirror-image bug: any single-signer account whose owner raised their
master weight to `>= 128` is permanently locked out, with the anchor
returning a negative weight in the rejection message.

This only affects the RPC ledger backend (`stellar_network.type: rpc`);
Horizon is unaffected because `AccountResponse.Thresholds` is already
deserialized from JSON into correctly-ranged `int`s. RPC is not the
schema default, but it is what SDF's own shipped reference profile sets
and is required for Soroban/SEP-45 support, so this isn't an edge-case
configuration.

The fix is a single masking helper (`b & 0xFF`), applied at all four
points the raw bytes are widened. No other change is needed: once the
parsed value is guaranteed non-negative by construction, both the bypass
and the lockout resolve as a direct consequence, and no defense-in-depth
range check or signer-list change is required.

**Changes**
- [x] `StellarRpc.getAccount`: added `unsignedByte(byte)` (`b & 0xFF`)
and used it for the three threshold bytes and the master-key signer
weight, replacing the sign-extending `(int)`/`(long)` casts.
- [x] `Sep10RpcThresholdSignExtensionTest.kt` (new): wires the real
`StellarRpc` + `Sep10Service` + pinned Stellar SDK together (only the
Soroban-RPC network call itself is stubbed, same boundary
`StellarRpcTest` already stubs) and runs real, freshly-signed SEP-10
challenges through them, so the SDK's actual threshold-comparison
bytecode decides pass/fail. Covers: the parsing defect in isolation, a
below-threshold co-signer bypass, a revoked weight-0 master-key bypass,
the owner-lockout case, a control case proving the check itself works,
and a 7-value parameterized sweep (`0, 1, 127, 128, 200, 254, 255`)
asserting no `uint8` byte value ever parses as negative.

**Acceptance Criteria**
- [x] On the RPC ledger backend, an account with `med_threshold = 200`
rejects a SEP-10 challenge signed only by a single weight-100 co-signer.
- [x] The same account rejects a challenge signed only by a revoked
weight-0 master key.
- [x] A plain single-signer account with master weight 200 and
`med_threshold = 100` authenticates successfully with its own master key
(no lockout).
- [x] `StellarRpc.getAccount()` returns non-negative
`low`/`medium`/`high` thresholds and a non-negative master-signer weight
for every possible on-chain byte value (`0`–`255`).
- [x] Existing correctly-rejected cases (weight below threshold, both
bytes `<128`) are unaffected.

### Context

[HackerOne #3893785](https://hackerone.com/reports/3893785)

### Testing

- Unit: `./gradlew :core:test --tests
"org.stellar.anchor.ledger.Sep10RpcThresholdSignExtensionTest"`
- Unit: `./gradlew :core:test --tests
"org.stellar.anchor.ledger.StellarRpcTest"` (no regressions in existing
coverage)
- Integration: `./gradlew :core:test :platform:test :core:spotlessCheck`
(full suite, confirms no regressions elsewhere)

### Documentation
N/A

### Known limitations
N/A
…tion in SEP-10/SEP-45 takes the whole anchor SEP API offline from one host (#1990)

### Description

`Sep10Service.createChallenge` and both `Sep45Service` call sites
(`createArgsFromRequest`, `verifyArguments`) fetch the wallet-supplied
`client_domain`'s `stellar.toml` synchronously, on the servlet request
thread, before authorization is checked. The fetch has no bulkhead:
`ClientDomainHelper.fetchSigningKeyFromClientDomain` can block for up to
~30s (a 15s OkHttp call timeout, doubled by the HTTPS→HTTP retry off
mainnet), and nothing caps how many of these can run concurrently.
`/auth` and `/sep45/auth` are both unauthenticated by design (SEP-10
issues the JWT, so it can't require one), and the shipped default
(`sep10.client_attribution_required: false`, and SEP-45 had no
allow-list option at all) means any public hostname is accepted with
zero preconditions. An unauthenticated attacker pointing `client_domain`
at a host that just never answers can park a servlet worker per request;
~20 req/s (3.2 KB/s) exhausts the default 200-thread Tomcat pool and
takes down every SEP endpoint on that connector. The project's own Helm
chart makes this worse: `livenessProbe`/`readinessProbe` hit `/health`
on the same connector/pool, so starvation escalates to actual pod
destruction and a crash-loop that outlives the attack.

Correcting only the allow-list gap (giving SEP-45 the same opt-in
`client_allow_list` SEP-10 already has) does not close this: the DoS
doesn't depend on the domain being disallowed. Any allow-listed domain —
or any domain the attacker can influence the DNS/routing of — can still
park a thread if it's slow to respond. The actual fix has to bound
*concurrency*, not *which domains are trusted*. So this PR does both:
extends the allow-list to SEP-45 (closes the confirmed SSRF-scope gap,
report claim (ii)), and moves the `client_domain` fetch off the servlet
thread onto a small, bounded, reject-not-queue executor (closes the
thread-exhaustion DoS, report claim (iii), the report's headline
finding).

Report claim (i) — that the existing SEP-10 allow-list guard "checks the
wrong getter" — is not addressed here because it isn't a bug: the
current guard matches the deliberate, already-shipped, already-tested
design from the prior SEP-10 SSRF fix (`0979bd19`, #3824178), which was
itself reverted from a stricter draft specifically to avoid breaking
operators who configure `clients:` for unrelated reasons.

**Changes**
- [x] `ClientDomainHelper.java`: added a static, bounded
`ThreadPoolExecutor` (`core=4, max=8, keepAlive=60s, SynchronousQueue`,
`AbortPolicy`) and a new
`fetchSigningKeyFromClientDomainBounded(clientDomain, allowHttpRetry)`
that submits the existing unbounded fetch to it and waits at most 2.5s
via `Future.get`. `RejectedExecutionException`/`TimeoutException` (pool
full or timed out) and `ExecutionException` (unwrapped to the original
`SepException` when that's the cause) both surface as a generic
`SepException("client_domain resolution unavailable")` — no behavior
change for the normal-latency case, since the timeout is well above any
real anchor's expected TOML fetch time.
- [x] `Sep10Service.java`: `createChallenge` and the package-private
`fetchSigningKeyFromClientDomain` wrapper (renamed
`fetchSigningKeyFromClientDomainBounded`) now call the bounded method
instead of the direct one.
- [x] `Sep45Service.java`: both call sites switched to the bounded
method the same way. Added `validateClientDomainAllowed(clientDomain)`,
called before either fetch — mirrors
`Sep10Service.validateChallengeRequestClient`'s opt-in branch exactly:
only enforced when `sep45Config.getClientAllowList()` is explicitly
non-empty, so operators who never set the new field see no behavior
change.
- [x] `Sep45Config.java` (interface): added `getClientAllowList()` /
`getAllowedClientDomains()`.
- [x] `PropertySep45Config.java`: added `clientAllowList` field and a
`ClientService` dependency (new constructor param);
`getAllowedClientDomains()` derives from the `clients:` section using
the identical logic `PropertySep10Config` already uses (falls back to
all non-custodial clients' domains when the list is unset); `validate()`
rejects any allow-list entry that doesn't name a configured client.
- [x] `SepBeans.java`: `sep45Config(...)` bean now takes and passes
`ClientService`.
- [x] `anchor-config-default-values.yaml` /
`anchor-config-schema-v1.yaml`: documented `sep45.client_allow_list`,
mirroring the existing `sep10.client_allow_list` entry.
- [x] `ClientDomainHelperTest.kt`: added tests proving (a) a normal,
unsaturated call still fails with the ordinary fetch error, not the
bulkhead one, and (b) once the bounded pool's `maximumPoolSize` is
saturated by blocking tasks, a further call rejects in well under the
2.5s bound rather than hanging.
- [x] `Sep10ServiceTest.kt`: updated the two references to the renamed
`fetchSigningKeyFromClientDomainBounded`.
- [x] `Sep45ServiceTest.kt`: added three tests mirroring
`Sep10ServiceTest`'s allow-list coverage — rejects an out-of-list
`client_domain` without ever calling the fetch; allows any
`client_domain` when no explicit list is set; allows an unlisted
`client_domain` when `clients:` exists only for unrelated configuration.
- [x] `Sep45ConfigTest.kt`: updated the `PropertySep45Config`
constructor call for the new `ClientService` param; added allow-list
derivation and validation tests mirroring `Sep10ConfigTest`.

**Acceptance Criteria**
- [x] A `client_domain` pointed at a host that never responds fails in
~2.5s, not ~15–30s.
- [x] 20 concurrent requests against such a host never block the
servlet/Tomcat thread pool: `/health` and other SEP endpoints keep
responding normally throughout.
- [x] Of those 20, only as many as the bounded pool's capacity (≤8)
actually wait out the 2.5s bound; the rest are rejected immediately.
- [x] SEP-45 with an explicit `sep45.client_allow_list` configured
rejects a `client_domain` outside that list with
`SepNotAuthorizedException`, before any outbound fetch is attempted.
- [x] SEP-45 with no explicit `client_allow_list` behaves exactly as
before this PR (no regression for existing deployments).
- [x] SEP-10's existing allow-list behavior is unchanged.

### Context

[HackerOne #3903968](https://hackerone.com/reports/3903968)

### Testing

- Unit: `./gradlew :core:test --tests
"org.stellar.anchor.sep10.Sep10ServiceTest" --tests
"org.stellar.anchor.sep45.Sep45ServiceTest" --tests
"org.stellar.anchor.util.ClientDomainHelperTest"`
- Unit: `./gradlew :platform:test --tests
"org.stellar.anchor.platform.config.Sep45ConfigTest" --tests
"org.stellar.anchor.platform.config.Sep10ConfigTest"`
- Full regression: `./gradlew :core:test :platform:test`

### Documentation
N/A

### Known limitations
N/A
… requests to arbitrary attacker-controlled hosts (SSRF) (#1991)

### Description

The ANCHOR-1236 fix (PR #1984) closed the missing-allow-list gap but
never touched `ClientDomainHelper.validateDomainNotPrivateNetwork`, the
private-network guard that runs on mainnet before fetching a
`client_domain`'s `stellar.toml`. Follow-up review on report 3824178
found that guard doesn't actually protect the fetch it's meant to gate:
it resolves the hostname once (`InetAddress.getAllByName`) to decide
pass/fail, but the fetch itself goes through OkHttp's default client,
which resolves the hostname again, independently, at connect time. An
attacker who controls DNS for their own domain can return a public
address for the check and an internal address for the fetch — the two
resolutions never have to agree.

Fixing just the resolution race isn't enough either: the same four
`InetAddress` predicates the guard relies on (`isLoopbackAddress`,
`isSiteLocalAddress`, `isLinkLocalAddress`, `isAnyLocalAddress`) never
recognized carrier-grade NAT (`100.64.0.0/10`, notably used by AWS
EKS/VPC-CNI for pod ranges) or IPv6 unique-local addresses (`fc00::/7` —
distinct from the deprecated `fec0::/10` IPv6 site-local range
`isSiteLocalAddress` actually checks). Those ranges pass the guard with
a single static DNS answer, no rebinding needed.

Both gaps are fixed together in `ClientDomainHelper`, since they're
independent ways of reaching the same outcome (an internal-network fetch
on mainnet) and share the same underlying address classifier.

**Changes**
- [x] `ClientDomainHelper.isNonPublicAddress`: new shared classifier,
extending the existing four checks with `100.64.0.0/10`, `fc00::/7`, and
— since we were touching this anyway — the related `192.0.0.0/24` (IETF
protocol assignments) and `198.18.0.0/15` (benchmarking) reserved
ranges. `validateDomainNotPrivateNetwork` now delegates to it, so its
existing behavior/tests are unchanged beyond the wider coverage.
- [x] `ClientDomainHelper.pinnedValidatingDns`: new `okhttp3.Dns`
implementation that resolves and validates a hostname in one step —
`Dns.SYSTEM.lookup`, then `isNonPublicAddress` against the same result,
then returns those exact addresses to OkHttp.
`fetchSigningKeyFromClientDomain` builds this once (mainnet only) and
threads it through to the actual connection, so there's no second,
independent resolution to disagree with the one that was validated.
Non-mainnet callers are unaffected — `dns` stays `null` and the fetch
uses the plain, unparameterized path exactly as before.
- [x] `NetUtil.fetch` / `OkHttpUtil.buildClient`: new overloads
accepting a `Dns`, additive only — the existing no-arg/`Dns`-less
overloads are untouched and still used by every other caller in the
codebase.
- [x] `Sep1Helper.readToml`: new `Dns`-accepting overload alongside the
existing one, same reasoning.
- [x] `ClientDomainHelperTest.kt`: added coverage for the four new
ranges (including boundary cases just outside the CGNAT range, and a
public-IPv6 sanity check to guard against false positives), plus an
end-to-end test that runs a real loopback HTTP server and confirms
`fetchSigningKeyFromClientDomain(..., allowHttpRetry=false)` can't reach
it — proves the new `Dns` wiring is actually connected through
`NetUtil`/`Sep1Helper`/`OkHttpUtil`, not just that the classifier
rejects the string in isolation.

**Acceptance Criteria**
- [x] On mainnet, a `client_domain` that resolves to `100.64.0.0/10` or
`fc00::/7` is rejected before any fetch, the same as the existing
loopback/RFC-1918/link-local ranges.
- [x] On mainnet, a `client_domain` whose DNS answer differs between the
validation lookup and the fetch's connection lookup cannot reach an
address that would have failed validation — the fetch always connects to
the address that was actually checked.
- [x] Non-mainnet (`allowHttpRetry=true`) behavior is unchanged: no
private-network validation, same permissive fetch path as before.
- [x] SEP-45 (`Sep45Service`, which calls the same
`ClientDomainHelper.fetchSigningKeyFromClientDomain`) gets both fixes
automatically, with no SEP-45-specific change needed.

### Context

[HackerOne #3824178](https://hackerone.com/reports/3824178)

### Testing

- Unit: `./gradlew :core:test --tests
"org.stellar.anchor.util.ClientDomainHelperTest"`
- Unit: `./gradlew :core:test --tests
"org.stellar.anchor.util.NetUtilTest" --tests
"org.stellar.anchor.util.SepHelperTest" --tests
"org.stellar.anchor.util.OkHttpUtilTest"`
- Regression: `./gradlew :core:test --tests
"org.stellar.anchor.sep10.Sep10ServiceTest" --tests
"org.stellar.anchor.sep45.Sep45ServiceTest"`
- Full module: `./gradlew :core:test :platform:test :lib-util:test`

### Documentation
N/A

### Known limitations
N/A
…h a REST management API and JWT-carried client attribution (#1992)

### Description

Raised by an anchor running the Anchor Platform: their partner-config
YAML has grown to ~1,200 lines, and every change requires a rolling
restart on their end. They asked whether config could move to the AP
database instead — either AP checking the DB directly per request, or AP
refreshing an in-memory cache every ~60 minutes — so onboarding a new
partner (their stated goal is to automate onboarding as much as
possible) or updating an existing one no longer needs a restart on their
end.

Client (wallet) definitions — SEP-10 signing keys, SEP-24 domains,
callback URLs, destination accounts — are configured today via
`clients.type: file|inline|json|yaml`, parsed once into an in-memory
list at startup. Every lookup is a linear scan over that list,
onboarding or revoking a single wallet means editing one shared
YAML/JSON file, and any change requires a full restart. That breaks down
past a few hundred clients: at 600+ entries the file becomes unwieldy to
hand-edit safely, and there is no way to add or revoke a client without
redeploying.

Three designs were prototyped before this one: an in-memory cache
refreshed on a timer/signal (the anchor's 60-minute-refresh suggestion;
largest diff, up to an hour of staleness), a purely live DB lookup on
every request (the anchor's other suggestion — AP checks the DB
directly; simplest, but a DB round trip on every SEP-10/24/6/31/12
call), and this one — DB-backed live lookup, but only at SEP-10/SEP-45
auth time. This PR implements the third: SEP-10/SEP-45 resolve the
client once via `ClientFinder` against the database and stamp the result
into a new `client_name` JWT claim; SEP-6/12/24/31 read
`token.getClientName()` directly instead of each doing their own lookup.
That removes the per-request database traffic the live-lookup-only
design would add on every SEP-6/12/24/31 call, at the cost of client
changes not taking effect for a session until its JWT expires (see Known
limitations).

A full REST API on the platform server lets anchors manage clients
without a restart: `PUT`/`GET`/`DELETE /clients/{name}`, `GET /clients`
(all), `GET /clients/custodial` / `GET /clients/non-custodial` (filtered
by type), plus incremental `POST`/`DELETE
/clients/{name}/signing-keys/{key}` and
`/destination-accounts/{account}`. The incremental endpoints exist
specifically for high-volume clients — one anchor's production config
has a single custodial client with 600+ destination accounts — so adding
or revoking one wallet doesn't require resending the entire list.
Setting `clients.type: db` with `clients.value` still pointing at an
existing file/yaml/json source auto-imports those clients into the
database on every startup (upsert, not destructive), so an anchor can
cut over without hand-transcribing every entry; switching `clients.type`
away from `db` afterward does not delete the imported rows.

**Changes**
- [x] `ClientsConfig.java`: adds a `db` `ClientsConfigType`. (No
separate migration flag — see below.)
- [x] `WebAuthJwt.java`: adds a `client_name` claim field, populated
from the decoded JWT.
- [x] `JwtService.encode`: emits the `CLIENT_NAME` claim when
`token.getClientName()` is set (the claim key already existed for the
SEP-24 interactive/more-info JWTs; this reuses it for the SEP-10/45
web-auth JWT).
- [x] `Sep10Service.generateWebAuthJwt`: resolves
`clientFinder.getClientName(clientDomain, account)` once, at
token-issuance time, and stamps it onto the token before encoding.
`validateChallenge`/`generateWebAuthJwt` now declare `throws
SepException` (was `SepValidationException`) so a
`SepNotAuthorizedException` from client resolution propagates and the
login is refused, instead of silently issuing a token with no client
name.
- [x] `Sep45Service.validate`: same resolve-and-stamp for the SEP-45
contract-account flow; adds a `ClientFinder` dependency.
- [x] `Sep6Service`, `Sep24Service`, `Sep12Service`, `Sep31Service`:
drop their own `ClientFinder`/`ClientService` lookups; each now reads
`token.getClientName()` (or
`Context.get().getWebAuthJwt().getClientName()` for SEP-31) directly,
since SEP-10/45 already vetted it.
- [x] `V32__client_config.sql` (new): creates `client_config`,
`client_domain`, `client_signing_key`, `client_destination_account`,
with unique indexes on `domain` and `signing_key` — a hard constraint
the file-based config never enforced.
- [x] `JdbcClientConfig.java` (new): JPA entity for the tables above.
- [x] `JdbcClientConfigRepo.java` (new): Spring Data repo with indexed
`findByDomain`/`findBySigningKey`/`findByType` queries,
`@EntityGraph`-annotated so
`signingKeys`/`domains`/`destinationAccounts` are eagerly fetched
(avoids a `LazyInitializationException` when a config bean reads them
outside a request-scoped Hibernate session).
- [x] `JdbcClientService.java` (new): `ClientService` implementation
backed directly by `JdbcClientConfigRepo` — every lookup is a live,
indexed query, no in-memory copy.
- [x] `PropertyClientsConfig.java`: `type: db` auto-detects and parses
`clients.value` (file path, inline YAML, or inline JSON) into `items`
for import, without clearing any items already bound directly; no
separate migration flag.
- [x] `PropertySep10Config.java`: `clientService` field marked
`transient` — it's a JDK dynamic proxy (inherent to any Spring Data
repo/service, not specific to `@Transactional`), and Gson would
otherwise try to reflectively serialize it during debug logging and fail
on a JPMS `InaccessibleObjectException`.
- [x] `ClientConfigController.java`, `ClientConfigRequest.java`,
`ClientConfigResponse.java` (new): `PUT`/`GET`/`GET all`/`DELETE
/clients/{name}`, `GET /clients/custodial`, `GET
/clients/non-custodial`, and `POST`/`DELETE
/clients/{name}/signing-keys/{key}` + `/destination-accounts/{account}`,
behind the same auth as the rest of the Platform API (see Known
limitations).
- [x] `ClientConfigService.java` (new): validates and
upserts/gets/lists/deletes clients, plus the incremental
add/remove-signing-key and add/remove-destination-account operations
(rejecting removal of a custodial client's last signing key); translates
a `DataIntegrityViolationException` (duplicate domain/signing key) into
a clean 400 instead of a raw SQL error.
- [x] `ClientConfigImportRunner.java` (new): a `CommandLineRunner`,
gated only on `clients.type=db`, that imports every client currently
resolvable from `clients.value` into the database on each startup
(upsert — safe to run repeatedly), logging and skipping any individual
client that fails to import rather than aborting startup.
- [x] `ClientsBeans.clientService`: returns `JdbcClientService` when
`clients.type: db`, otherwise the existing `DefaultClientService`.
- [x] `PlatformServerBeans.java`: registers `ClientConfigService` and
the conditional `ClientConfigImportRunner`.
- [x] `SepBeans.java`: drops `ClientFinder`/`ClientService` constructor
args from the SEP-6/12/24/31 beans; adds `sep45` to `ClientFinder`'s
enabled-SEPs list and wires it into the `Sep45Service` bean.
- [x] `Sep10Controller.validateChallenge`: throws clause widened to
`SepException`.
- [x] `TestProfileRunner.kt`: fixes a test-harness bug where inline
`clients.value` content (JSON/YAML, not a file path) was unconditionally
treated as a classpath resource and crashed with
`RuntimeException("Resource ... not found")`; now falls back to treating
it as inline content with a warning log.
- [x] `anchor-config-default-values.yaml`: documents the new `type: db`
option and its auto-import behavior (corrected from an earlier draft of
this doc that described a since-removed `migrate_from_file_on_startup`
flag).
- [x] `Sep10ServiceTest.kt`, `Sep45ServiceTest.kt`: new cases asserting
`client_name` is stamped onto the JWT, and that a `ClientFinder`
authorization failure propagates instead of issuing a token with no
client name.
- [x] `Sep12ServiceTest.kt`, `Sep24ServiceTest.kt`,
`Sep31ServiceTest.kt`, `Sep6ServiceTest.kt`: updated to set/read
`token.getClientName()` directly instead of mocking `ClientFinder`.
- [x] `JdbcClientServiceTest.kt` (new, 8 tests): domain/signing-key/name
resolution, domain-preferred-over-account-match precedence,
custodial-vs-noncustodial filtering.
- [x] `PropertyClientsConfigTest.kt` (new, 5 tests): `type: db` with no
value, with a YAML/JSON string, with a file path, and with items already
bound directly — none of the four clear or duplicate the item list.
- [x] `ClientConfigImportRunnerTest.kt` (new, 3 tests): upserts every
item, continues past a single item's import failure, no-ops on an empty
list.
- [x] `ClientConfigServiceTest.kt` (29 tests): full coverage of
validation, upsert-conflict handling, list/get/delete, and the
incremental signing-key/destination-account add/remove operations
including the custodial-last-key protection and cross-client uniqueness
conflicts.
- [x] `ClientConfigApiTests.kt` (essential-tests, 19 tests): real-HTTP
coverage against a live Postgres for the full `/clients` REST surface —
CRUD lifecycle, 404s, validation (missing signing keys, no domain on a
noncustodial client, malformed callback URL, unknown type,
domain/signing-key collisions across clients), the four incremental
endpoints (additive without disturbing existing entries, 404 on an
unknown sub-resource, cross-client conflict, last-signing-key
protection), and the two type-filtered list endpoints.
- [x] `ClientsDbAttributionTests.kt` + `ClientsDbTestSuite.kt` (new,
extended-tests, 4 tests): the one thing essential-tests can't cover,
since it only runs against the default profile's `clients.type: file`. A
new `clients-db` service-runner profile (`clients.type: db`, wired into
`sub_extended_tests.yml`) boots a real server with the DB-backed path
active, and this suite runs an actual SEP-10 challenge/validate against
it, decodes the resulting JWT, and asserts `client_name` is attributed
correctly on create, follows a signing key moved between clients, stays
unset for an unregistered key, and disappears after the client is
deleted.

**Acceptance Criteria**
- [x] `PUT /clients/{name}` with a valid custodial or noncustodial
payload creates the client and returns it; a second `PUT` to the same
name updates rather than duplicates.
- [x] `GET /clients/{name}` and `GET /clients` reflect what's stored;
`GET /clients/{unknown}` returns 404. `GET /clients/custodial` and `GET
/clients/non-custodial` return only clients of the matching type.
- [x] `PUT` with a signing key or domain already used by another client,
a noncustodial client with no domain, a custodial client with no signing
keys, a malformed callback URL, or an unrecognized type all return 400
without creating/mutating a row.
- [x] `POST /clients/{name}/signing-keys/{key}` and
`/destination-accounts/{account}` add without disturbing existing
entries; the corresponding `DELETE` removes only the targeted entry;
both 404 on an unknown client or an entry that isn't present; adding a
signing key already used by another client returns 400; removing a
custodial client's last signing key returns 400.
- [x] `DELETE /clients/{name}` removes the client and its
domain/signing-key/destination-account rows; a subsequent `GET` returns
404.
- [x] With `clients.type: db`, a SEP-10 or SEP-45 login for a signing
key/domain that matches a client in the database stamps that client's
name into the `client_name` JWT claim — verified end-to-end (real HTTP,
real Postgres) via `ClientsDbAttributionTests`, not just at the unit
level.
- [x] With `sep10.client_attribution_required: true`, a login for a
signing key/domain not in the database (or not on the allow list) is
rejected outright, not issued a token with a null client name.
- [x] SEP-6/12/24/31 requests authenticated with such a token attribute
the resulting transaction/event to that `client_name`, without making
their own database call to resolve it.
- [x] With `clients.type: db` and `clients.value` pointing at an
existing file/yaml/json source, every client there is upserted into the
database on startup, and startup does not fail if one client fails to
import.
- [x] Existing `clients.type: file|inline|json|yaml` deployments are
unaffected — `clients.type: db` is opt-in, and switching away from it
afterward does not delete previously-imported/managed rows.

### Context

Origin ask (anchor operator, paraphrased): their ~1,200-line
partner-config YAML requires a rolling restart on every change; they
asked for either AP-checks-DB-directly or a ~60-minute AP-side cache
refresh, with automating partner onboarding as the underlying goal. A
separate concrete follow-up from the same anchor: one of their custodial
clients has 600+ destination accounts, and they didn't want to resend
the full list to add or remove a single one — that's what the
incremental signing-key/destination-account endpoints are for.

### Testing

- Unit: `./gradlew :core:test :platform:test` (includes
`ClientConfigServiceTest`, `JdbcClientServiceTest`,
`PropertyClientsConfigTest`, `ClientConfigImportRunnerTest`)
- Integration: `./gradlew runEssentialTests` (includes the expanded
`ClientConfigApiTests`, real HTTP against a live Postgres)
- Extended: `TEST_PROFILE_NAME=clients-db ./gradlew
startServersWithTestProfile` then `./gradlew :extended-tests:test
--tests org.stellar.anchor.platform.suite.ClientsDbTestSuite` (real
SEP-10 auth against a DB-backed server, wired into
`sub_extended_tests.yml` for CI)
- Manual: full `PUT`/`GET`/`GET all`/incremental-endpoint round trips
against a running server, verified against a live Postgres, including a
full clean-restart migration of an existing file-based config into the
database.

### Documentation

`anchor-config-default-values.yaml` updated with the `clients.type: db`
option, its auto-import behavior, and the two new incremental-endpoint /
type-filtered-list capabilities.

### Known limitations

N/A
### Description

This bumps the version to 4.7.0

### Context

Release

### Testing

`./gradlew test`

### Documentation

N/A

### Known limitations

N/A
@ceciliaromao ceciliaromao self-assigned this Aug 20, 2026
@ceciliaromao
ceciliaromao merged commit b1cbb5a into main Aug 20, 2026
18 checks passed
@ceciliaromao
ceciliaromao deleted the chore/merge-release-4.7.0-to-main branch August 20, 2026 16:02
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.

2 participants