From 45a05cb0a5eba8b7327313279fe103f72f682f8e Mon Sep 17 00:00:00 2001 From: xzawed <47019855+xzawed@users.noreply.github.com> Date: Fri, 30 Oct 2026 18:28:47 +0900 Subject: [PATCH 1/2] =?UTF-8?q?docs(java):=20Maven=20Central=20=EC=97=90?= =?UTF-8?q?=20=EA=B2=8C=EC=8B=9C=EB=90=98=EB=8A=94=20javadoc=20=EA=B3=BC?= =?UTF-8?q?=20QuickStart=20=EC=98=88=EC=A0=9C=20=EC=A3=BC=EC=84=9D?= =?UTF-8?q?=EC=9D=84=20=EC=98=81=EB=AC=B8=EC=9C=BC=EB=A1=9C=20=E2=80=94=20?= =?UTF-8?q?=EB=B2=88=EC=97=AD=20=ED=8C=90=EC=A0=95=EC=9D=B4=20java=20?= =?UTF-8?q?=EB=A5=BC=20=EB=B9=A0=EB=9C=A8=EB=A0=B8=EB=8B=A4=20(korean-api-?= =?UTF-8?q?docs-in-published-packages)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 판정 2026-10-29 「번역 — 언어당 PR」은 일곱 언어(#890–#898)만 옮겼고, CHANGELOG 는 「아홉 SDK 의 그려지는 문서가 모두 영문」이라 적었다. 그려지는 문서 가드를 레인마다 걸려고 재다가 java 가 0 이 아님을 찾았다 — 게시용 `-javadoc.jar`(`-Prelease package`, reactor 안) 122 HTML 에 한글 221 줄: facade 20 · admin 138 · auth 5 · core 50 · examples 8. (.NET 은 XML 문서 파일이 이미 0 줄이다.) 범위: 그 221 줄을 낳는 11 파일의 javadoc(공개·protected 요소)과, README 가 링크하는 QuickStart.java 의 주석. 비공개·package-private 의 javadoc 과 그 밖의 `//` 주석(66 줄)은 그려지지 않아 그대로 뒀다. 정정 하나: QuickStart 의 「모듈 POM 의 `maven.deploy.skip=true` 로 배포되지 않는다」는 원문부터 거짓이었다 — 그 속성은 무효였고(examples 모듈 POM 의 주석), 제외 장치는 부모 POM 의 `` 다. 구현은 에이전트(지정 11 파일 · 금지 목록 · 검증 명령)가 했고, PM 이 다른 계측기로 다시 쟀다: - 오라클: 같은 `package` → 122 파일 · 한글 0(양성 대조: core 의 영문 요약이 index 에 있다) - 코드 불변: `javap -v -p`(상수 풀 · 부트스트랩 메서드 포함 — 줄 번호 표 · 체크섬 · 시각만 거름)를 main 빌드와 비교 — 59 클래스 22,687 줄 동일. 대조: 필터 뒤 남은 상수 풀 · 부트스트랩 줄 4,755. - 에이전트의 `javap -c -p -constants` 도 동일(8,431 줄, SHA-256 같음). - 번역 충실도: ResponseLimits · KeycloakConfig · AdminClient · QuickStart 를 원문과 나란히 읽었다 — 수치(65,459 B · 32 MiB/170 MB/17.2 GB · 85 B · 9)와 조건·인과가 그대로다. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01R4jYS4tpj7usahf8MK5dXo --- .../xzawed/keycloak/admin/AdminClient.java | 30 +++++----- .../keycloak/admin/ClientsResource.java | 21 +++---- .../xzawed/keycloak/admin/GroupsResource.java | 22 +++---- .../xzawed/keycloak/admin/RealmsResource.java | 26 ++++---- .../xzawed/keycloak/admin/RolesResource.java | 18 +++--- .../xzawed/keycloak/admin/UsersResource.java | 26 ++++---- .../keycloak/auth/IntrospectionResult.java | 2 +- .../xzawed/keycloak/core/KeycloakConfig.java | 19 ++++-- .../xzawed/keycloak/core/ResponseLimits.java | 59 +++++++++++-------- .../xzawed/keycloak/examples/QuickStart.java | 15 ++--- .../xzawed/keycloak/KeycloakClient.java | 17 +++--- 11 files changed, 138 insertions(+), 117 deletions(-) diff --git a/java/keycloak-sdk-admin/src/main/java/io/github/xzawed/keycloak/admin/AdminClient.java b/java/keycloak-sdk-admin/src/main/java/io/github/xzawed/keycloak/admin/AdminClient.java index 1d3ed171..be936bea 100644 --- a/java/keycloak-sdk-admin/src/main/java/io/github/xzawed/keycloak/admin/AdminClient.java +++ b/java/keycloak-sdk-admin/src/main/java/io/github/xzawed/keycloak/admin/AdminClient.java @@ -29,14 +29,14 @@ import org.keycloak.admin.client.spi.StreamMessageBodyReader; /** - * 관리(admin) API 파사드 진입점. 공식 {@link Keycloak} admin-client를 감싸며 수명주기를 - * 소유한다({@link AutoCloseable}). 리소스 접근자 {@code users()/clients()/realms()/roles()/groups()} - * (WBS 4.3~4.7)를 통해 각 리소스 파사드를 노출한다. + * Entry point of the administration (admin) API facade. Wraps the official {@link Keycloak} admin-client and + * owns its lifecycle ({@link AutoCloseable}). Exposes each resource facade through the resource accessors + * {@code users()/clients()/realms()/roles()/groups()} (WBS 4.3~4.7). * - *

결합 규칙: 기본 생성자({@link #AdminClient(KeycloakConfig)})는 auth 모듈에 의존하지 않는다. - * 이전에 존재하던 {@code TokenProvider} 기반 고급 생성자는 커스텀 RESTEasy - * {@code ClientRequestFilter} 클라이언트를 admin-client 내부 라이브러리와 함께 등록하려 하면서 - * 라이브러리 충돌을 일으켜 MVP 범위에서 제거했다(사용자 결정, Phase 4a). + *

Coupling rule: the default constructor ({@link #AdminClient(KeycloakConfig)}) does not depend on the auth + * module. The advanced constructor based on {@code TokenProvider} that used to exist was removed from the MVP + * scope: trying to register a custom RESTEasy {@code ClientRequestFilter} client together with admin-client's + * internal library caused a library conflict (user decision, Phase 4a). */ public final class AdminClient implements AutoCloseable { @@ -57,8 +57,8 @@ private AdminClient(KeycloakConfig config, Keycloak keycloak) { } /** - * 기본 생성자 — Keycloak admin-client 내장 client-credentials 그랜트를 사용한다. 반환된 - * {@link Keycloak}의 내부 TokenManager가 토큰을 자동으로 획득·갱신한다. + * Default constructor — uses the Keycloak admin-client's built-in client-credentials grant. The internal + * TokenManager of the returned {@link Keycloak} obtains and refreshes tokens automatically. */ public AdminClient(KeycloakConfig config) { this(config, KeycloakBuilder.builder() @@ -150,32 +150,32 @@ static AdminClient withKeycloak(KeycloakConfig config, Keycloak injected) { return new AdminClient(config, injected); } - /** 파사드가 감싸지 않은 엔드포인트에 접근하기 위한 탈출구(WBS 4.8). */ + /** Escape hatch for reaching endpoints the facade does not wrap (WBS 4.8). */ public Keycloak raw() { return keycloak; } - /** 사용자 CRUD 파사드(WBS 4.3). */ + /** User CRUD facade (WBS 4.3). */ public UsersResource users() { return new UsersResource(raw().realm(config.getRealm()).users()); } - /** 클라이언트 CRUD 파사드(WBS 4.4). */ + /** Client CRUD facade (WBS 4.4). */ public ClientsResource clients() { return new ClientsResource(raw().realm(config.getRealm()).clients()); } - /** 렐름 조회/생성/삭제 파사드(WBS 4.5). */ + /** Realm lookup/create/delete facade (WBS 4.5). */ public RealmsResource realms() { return new RealmsResource(raw().realms()); } - /** 역할(role) CRUD 파사드(WBS 4.6). */ + /** Role CRUD facade (WBS 4.6). */ public RolesResource roles() { return new RolesResource(raw().realm(config.getRealm()).roles()); } - /** 그룹 CRUD 파사드(WBS 4.7). */ + /** Group CRUD facade (WBS 4.7). */ public GroupsResource groups() { return new GroupsResource(raw().realm(config.getRealm()).groups()); } diff --git a/java/keycloak-sdk-admin/src/main/java/io/github/xzawed/keycloak/admin/ClientsResource.java b/java/keycloak-sdk-admin/src/main/java/io/github/xzawed/keycloak/admin/ClientsResource.java index 63c892ac..224fd2b8 100644 --- a/java/keycloak-sdk-admin/src/main/java/io/github/xzawed/keycloak/admin/ClientsResource.java +++ b/java/keycloak-sdk-admin/src/main/java/io/github/xzawed/keycloak/admin/ClientsResource.java @@ -6,12 +6,12 @@ import org.keycloak.representations.idm.ClientRepresentation; /** - * {@code clients()} 리소스 파사드(WBS 4.4). 공식 admin-client의 - * {@link org.keycloak.admin.client.resource.ClientsResource}를 감싸며 모든 호출을 - * {@link AdminExceptions#call}/{@link AdminExceptions#run}으로 경계 변환한다. + * {@code clients()} resource facade (WBS 4.4). Wraps the official admin-client's + * {@link org.keycloak.admin.client.resource.ClientsResource} and converts every call at the boundary through + * {@link AdminExceptions#call}/{@link AdminExceptions#run}. * - *

{@link #get(String)}은 {@link UsersResource#get(String)}과 동일한 정책을 따른다: - * 대상이 없으면 {@link io.github.xzawed.keycloak.core.exception.KeycloakNotFoundException}을 전파한다. + *

{@link #get(String)} follows the same policy as {@link UsersResource#get(String)}: if the target does not + * exist, it propagates {@link io.github.xzawed.keycloak.core.exception.KeycloakNotFoundException}. */ public final class ClientsResource { @@ -21,7 +21,7 @@ public final class ClientsResource { this.delegate = delegate; } - /** 클라이언트 생성 후 응답 {@code Location} 헤더에서 신규 클라이언트 id를 추출해 반환한다. */ + /** Creates a client, then extracts the new client's id from the response {@code Location} header and returns it. */ public String create(ClientRepresentation representation) { return AdminExceptions.call(() -> { try (jakarta.ws.rs.core.Response r = delegate.create(representation)) { @@ -43,10 +43,11 @@ public void update(String id, ClientRepresentation representation) { } /** - * admin-client의 {@code delegate.delete(id)}는 {@link jakarta.ws.rs.core.Response}를 반환하는 - * JAX-RS 프록시 메서드라 4xx/5xx 응답에서도 예외를 던지지 않는다({@code void} 반환 메서드만 자동으로 - * throw한다). 따라서 상태 코드를 직접 검사해 실패 시 {@link jakarta.ws.rs.WebApplicationException}을 - * 던져 {@link AdminExceptions} 경계를 통해 SDK 예외로 변환한다. + * admin-client's {@code delegate.delete(id)} is a JAX-RS proxy method that returns a + * {@link jakarta.ws.rs.core.Response}, so it does not throw even on a 4xx/5xx response (only + * {@code void}-returning methods throw automatically). The status code is therefore checked directly, and on + * failure a {@link jakarta.ws.rs.WebApplicationException} is thrown, which the {@link AdminExceptions} boundary + * converts to an SDK exception. */ public void delete(String id) { AdminExceptions.run(() -> { diff --git a/java/keycloak-sdk-admin/src/main/java/io/github/xzawed/keycloak/admin/GroupsResource.java b/java/keycloak-sdk-admin/src/main/java/io/github/xzawed/keycloak/admin/GroupsResource.java index f6311e88..ae30c9b8 100644 --- a/java/keycloak-sdk-admin/src/main/java/io/github/xzawed/keycloak/admin/GroupsResource.java +++ b/java/keycloak-sdk-admin/src/main/java/io/github/xzawed/keycloak/admin/GroupsResource.java @@ -6,15 +6,15 @@ import org.keycloak.representations.idm.GroupRepresentation; /** - * {@code groups()} 리소스 파사드(WBS 4.7). 공식 admin-client의 - * {@link org.keycloak.admin.client.resource.GroupsResource}를 감싸며 모든 호출을 - * {@link AdminExceptions#call}/{@link AdminExceptions#run}으로 경계 변환한다. + * {@code groups()} resource facade (WBS 4.7). Wraps the official admin-client's + * {@link org.keycloak.admin.client.resource.GroupsResource} and converts every call at the boundary through + * {@link AdminExceptions#call}/{@link AdminExceptions#run}. * - *

⚠️ API 편차: 생성 메서드명은 {@code create}가 아니라 {@code add(GroupRepresentation)}이고, - * 단일 그룹 접근자는 {@code get}이 아니라 {@code group(String)}이다({@code delete}도 직접 존재하지 - * 않아 {@code group(id).remove()}로 위임). {@link #get(String)}은 {@link UsersResource#get(String)}과 - * 동일한 정책을 따른다: 대상이 없으면 - * {@link io.github.xzawed.keycloak.core.exception.KeycloakNotFoundException}을 전파한다. + *

⚠️ API deviation: the creation method is named {@code add(GroupRepresentation)}, not {@code create}, and + * the single-group accessor is {@code group(String)}, not {@code get} ({@code delete} does not exist directly + * either, so it delegates to {@code group(id).remove()}). {@link #get(String)} follows the same policy as + * {@link UsersResource#get(String)}: if the target does not exist, it propagates + * {@link io.github.xzawed.keycloak.core.exception.KeycloakNotFoundException}. */ public final class GroupsResource { @@ -24,7 +24,7 @@ public final class GroupsResource { this.delegate = delegate; } - /** 그룹 생성 후 응답 {@code Location} 헤더에서 신규 그룹 id를 추출해 반환한다. */ + /** Creates a group, then extracts the new group's id from the response {@code Location} header and returns it. */ public String create(GroupRepresentation representation) { return AdminExceptions.call(() -> { try (jakarta.ws.rs.core.Response r = delegate.add(representation)) { @@ -42,9 +42,9 @@ public List list(int first, int max) { } /** - * id로 주소를 잡아 그룹을 갱신한다. {@code representation.name}에 새 이름을 주면 rename이다. + * Updates the group, addressed by id. Giving a new name in {@code representation.name} makes it a rename. * - *

⚠️ 경로({@code id})와 body({@code representation})를 합치지 말 것. + *

⚠️ Do not merge the path ({@code id}) and the body ({@code representation}). */ public void update(String id, GroupRepresentation representation) { AdminExceptions.run(() -> delegate.group(id).update(representation)); diff --git a/java/keycloak-sdk-admin/src/main/java/io/github/xzawed/keycloak/admin/RealmsResource.java b/java/keycloak-sdk-admin/src/main/java/io/github/xzawed/keycloak/admin/RealmsResource.java index 6538867c..b75c7ddc 100644 --- a/java/keycloak-sdk-admin/src/main/java/io/github/xzawed/keycloak/admin/RealmsResource.java +++ b/java/keycloak-sdk-admin/src/main/java/io/github/xzawed/keycloak/admin/RealmsResource.java @@ -5,15 +5,15 @@ import org.keycloak.representations.idm.RealmRepresentation; /** - * {@code realms()} 리소스 파사드(WBS 4.5). 공식 admin-client의 - * {@link org.keycloak.admin.client.resource.RealmsResource}를 감싸며 모든 호출을 - * {@link AdminExceptions#call}/{@link AdminExceptions#run}으로 경계 변환한다. + * {@code realms()} resource facade (WBS 4.5). Wraps the official admin-client's + * {@link org.keycloak.admin.client.resource.RealmsResource} and converts every call at the boundary through + * {@link AdminExceptions#call}/{@link AdminExceptions#run}. * - *

⚠️ API 편차: {@code RealmsResource}에는 {@code get(String)}/{@code delete(String)}가 직접 - * 존재하지 않는다({@code create(RealmRepresentation)}만 있음, 반환 타입도 {@code Response}가 아니라 - * {@code void}). 개별 렐름 조회/삭제는 {@code realm(name).toRepresentation()}/{@code remove()}로 - * 위임한다. {@link #get(String)}은 {@link UsersResource#get(String)}과 동일한 정책을 따른다: 대상이 - * 없으면 {@link io.github.xzawed.keycloak.core.exception.KeycloakNotFoundException}을 전파한다. + *

⚠️ API deviation: {@code RealmsResource} has no {@code get(String)}/{@code delete(String)} directly + * (it only has {@code create(RealmRepresentation)}, and its return type is {@code void}, not {@code Response}). + * Looking up/deleting an individual realm delegates to {@code realm(name).toRepresentation()}/{@code remove()}. + * {@link #get(String)} follows the same policy as {@link UsersResource#get(String)}: if the target does not + * exist, it propagates {@link io.github.xzawed.keycloak.core.exception.KeycloakNotFoundException}. */ public final class RealmsResource { @@ -32,18 +32,18 @@ public Optional get(String realmName) { } /** - * 호출자가 볼 수 있는 렐름 전부. 서비스 계정은 보통 자기 렐름만 본다 — 전체를 가정하지 말 것. + * All realms the caller can see. A service account usually sees only its own realm — do not assume all realms. */ public List list() { return AdminExceptions.call(delegate::findAll); } /** - * 현재 이름으로 주소를 잡아 렐름을 갱신한다. {@code representation.realm}에 새 이름을 주면 - * rename이다. + * Updates the realm, addressed by its current name. Giving a new name in {@code representation.realm} makes it + * a rename. * - *

⚠️ 경로({@code realmName})와 body({@code representation})를 합치지 말 것 — 합치면 rename이 - * 조용한 no-op이 된다. + *

⚠️ Do not merge the path ({@code realmName}) and the body ({@code representation}) — merging them turns a + * rename into a silent no-op. */ public void update(String realmName, RealmRepresentation representation) { AdminExceptions.run(() -> delegate.realm(realmName).update(representation)); diff --git a/java/keycloak-sdk-admin/src/main/java/io/github/xzawed/keycloak/admin/RolesResource.java b/java/keycloak-sdk-admin/src/main/java/io/github/xzawed/keycloak/admin/RolesResource.java index 43eb53ae..c2fd9f15 100644 --- a/java/keycloak-sdk-admin/src/main/java/io/github/xzawed/keycloak/admin/RolesResource.java +++ b/java/keycloak-sdk-admin/src/main/java/io/github/xzawed/keycloak/admin/RolesResource.java @@ -5,13 +5,13 @@ import org.keycloak.representations.idm.RoleRepresentation; /** - * {@code roles()} 리소스 파사드(WBS 4.6). 공식 admin-client의 - * {@link org.keycloak.admin.client.resource.RolesResource}를 감싸며 모든 호출을 - * {@link AdminExceptions#call}/{@link AdminExceptions#run}으로 경계 변환한다. + * {@code roles()} resource facade (WBS 4.6). Wraps the official admin-client's + * {@link org.keycloak.admin.client.resource.RolesResource} and converts every call at the boundary through + * {@link AdminExceptions#call}/{@link AdminExceptions#run}. * - *

⚠️ API 편차: 삭제 메서드명은 {@code delete}가 아니라 {@code deleteRole(String)}이다. - * {@link #get(String)}은 {@link UsersResource#get(String)}과 동일한 정책을 따른다: 대상이 없으면 - * {@link io.github.xzawed.keycloak.core.exception.KeycloakNotFoundException}을 전파한다. + *

⚠️ API deviation: the deletion method is named {@code deleteRole(String)}, not {@code delete}. + * {@link #get(String)} follows the same policy as {@link UsersResource#get(String)}: if the target does not + * exist, it propagates {@link io.github.xzawed.keycloak.core.exception.KeycloakNotFoundException}. */ public final class RolesResource { @@ -34,10 +34,10 @@ public List list() { } /** - * 현재 이름으로 주소를 잡아 역할을 갱신한다. {@code representation.name}에 새 이름을 주면 - * rename이다. + * Updates the role, addressed by its current name. Giving a new name in {@code representation.name} makes it + * a rename. * - *

⚠️ 경로({@code name})와 body({@code representation})를 합치지 말 것. + *

⚠️ Do not merge the path ({@code name}) and the body ({@code representation}). */ public void update(String name, RoleRepresentation representation) { AdminExceptions.run(() -> delegate.get(name).update(representation)); diff --git a/java/keycloak-sdk-admin/src/main/java/io/github/xzawed/keycloak/admin/UsersResource.java b/java/keycloak-sdk-admin/src/main/java/io/github/xzawed/keycloak/admin/UsersResource.java index d5c96fe7..4fdd9cb6 100644 --- a/java/keycloak-sdk-admin/src/main/java/io/github/xzawed/keycloak/admin/UsersResource.java +++ b/java/keycloak-sdk-admin/src/main/java/io/github/xzawed/keycloak/admin/UsersResource.java @@ -6,14 +6,15 @@ import org.keycloak.representations.idm.UserRepresentation; /** - * {@code users()} 리소스 파사드(WBS 4.3). 공식 admin-client의 - * {@link org.keycloak.admin.client.resource.UsersResource}를 감싸며 모든 호출을 - * {@link AdminExceptions#call}/{@link AdminExceptions#run}으로 경계 변환한다. - * 모델 타입은 admin-client의 {@link UserRepresentation}을 그대로 사용한다(관용 파사드). + * {@code users()} resource facade (WBS 4.3). Wraps the official admin-client's + * {@link org.keycloak.admin.client.resource.UsersResource} and converts every call at the boundary through + * {@link AdminExceptions#call}/{@link AdminExceptions#run}. + * The model type is admin-client's {@link UserRepresentation}, used as-is (idiomatic facade). * - *

{@link #get(String)}은 대상이 없으면 {@code Optional.empty()}로 삼키지 않고 - * {@link io.github.xzawed.keycloak.core.exception.KeycloakNotFoundException}을 그대로 전파한다 — - * {@code Optional}은 예외 없이 값이 부재할 수 있는 경우를 위한 반환 타입이며, 이 리소스에는 그런 경로가 없다. + *

When the target does not exist, {@link #get(String)} does not swallow that into {@code Optional.empty()}; it + * propagates {@link io.github.xzawed.keycloak.core.exception.KeycloakNotFoundException} as-is — + * {@code Optional} is the return type for cases where a value may be absent without an exception, and this + * resource has no such path. */ public final class UsersResource { @@ -23,7 +24,7 @@ public final class UsersResource { this.delegate = delegate; } - /** 사용자 생성 후 응답 {@code Location} 헤더에서 신규 사용자 id를 추출해 반환한다. */ + /** Creates a user, then extracts the new user's id from the response {@code Location} header and returns it. */ public String create(UserRepresentation representation) { return AdminExceptions.call(() -> { try (jakarta.ws.rs.core.Response r = delegate.create(representation)) { @@ -45,10 +46,11 @@ public void update(String id, UserRepresentation representation) { } /** - * admin-client의 {@code delegate.delete(id)}는 {@link jakarta.ws.rs.core.Response}를 반환하는 - * JAX-RS 프록시 메서드라 4xx/5xx 응답에서도 예외를 던지지 않는다({@code void} 반환 메서드만 자동으로 - * throw한다). 따라서 상태 코드를 직접 검사해 실패 시 {@link jakarta.ws.rs.WebApplicationException}을 - * 던져 {@link AdminExceptions} 경계를 통해 SDK 예외로 변환한다. + * admin-client's {@code delegate.delete(id)} is a JAX-RS proxy method that returns a + * {@link jakarta.ws.rs.core.Response}, so it does not throw even on a 4xx/5xx response (only + * {@code void}-returning methods throw automatically). The status code is therefore checked directly, and on + * failure a {@link jakarta.ws.rs.WebApplicationException} is thrown, which the {@link AdminExceptions} boundary + * converts to an SDK exception. */ public void delete(String id) { AdminExceptions.run(() -> { diff --git a/java/keycloak-sdk-auth/src/main/java/io/github/xzawed/keycloak/auth/IntrospectionResult.java b/java/keycloak-sdk-auth/src/main/java/io/github/xzawed/keycloak/auth/IntrospectionResult.java index e1b2b23c..8ec0fe2a 100644 --- a/java/keycloak-sdk-auth/src/main/java/io/github/xzawed/keycloak/auth/IntrospectionResult.java +++ b/java/keycloak-sdk-auth/src/main/java/io/github/xzawed/keycloak/auth/IntrospectionResult.java @@ -1,7 +1,7 @@ package io.github.xzawed.keycloak.auth; import java.util.Optional; -/** RFC 7662 토큰 introspection 응답의 SDK 표현 (WBS 3.7). */ +/** SDK representation of an RFC 7662 token introspection response (WBS 3.7). */ public final class IntrospectionResult { private final boolean active; private final Optional username; diff --git a/java/keycloak-sdk-core/src/main/java/io/github/xzawed/keycloak/core/KeycloakConfig.java b/java/keycloak-sdk-core/src/main/java/io/github/xzawed/keycloak/core/KeycloakConfig.java index 6a170007..78be4106 100644 --- a/java/keycloak-sdk-core/src/main/java/io/github/xzawed/keycloak/core/KeycloakConfig.java +++ b/java/keycloak-sdk-core/src/main/java/io/github/xzawed/keycloak/core/KeycloakConfig.java @@ -27,18 +27,25 @@ private KeycloakConfig(Builder b) { public char[] getClientSecret() { return clientSecret == null ? null : clientSecret.clone(); } public List getScopes() { return scopes; } /** - * JWT `aud` 포함검사에 기대할 값 — 미설정이면 clientId(기존 동작). 기본 realm은 client-credentials - * 토큰의 aud에 client id를 넣지 않으므로(그러려면 audience 프로토콜 매퍼가 필요) 리소스 서버 이름 등 - * 실제로 발급되는 audience로 재정의할 수 있다. 액세스 토큰(`validate`)의 기대값이다 — `exchangeCode`가 nonce와 함께 - * 검증하는 id_token의 aud는 이 값과 무관하게 clientId로 검사한다(OIDC Core §3.1.3.7). + * The value the JWT `aud` containment check expects — clientId if unset (the existing behavior). The default + * realm does not put the client id in the aud of client-credentials tokens (that needs an audience protocol + * mapper), so this can be overridden with the audience actually issued, such as a resource server name. It is + * the expected value for access tokens (`validate`) — the aud of the id_token that `exchangeCode` verifies along + * with the nonce is checked against clientId regardless of this value (OIDC Core §3.1.3.7). */ public String getExpectedAudience() { return expectedAudience == null ? clientId : expectedAudience; } - /** JWT 서명 검증 시 허용할 알고리즘 핀(기본 ["RS256"]). ES256/PS256 realm을 위해 설정 가능. */ + /** + * Pinned algorithms allowed when verifying JWT signatures (default ["RS256"]). Configurable for ES256/PS256 + * realms. + */ public List getSignatureAlgorithms() { return signatureAlgorithms; } public Duration getConnectTimeout() { return connectTimeout; } public Duration getReadTimeout() { return readTimeout; } public Duration getClockSkew() { return clockSkew; } - /** 미해결 kid(키 회전)로 인한 JWKS 재조회의 최소 간격(기본 30초) — DoS 증폭 상한. */ + /** + * Minimum interval (default 30 seconds) between JWKS refetches caused by an unresolved kid (key rotation) — the + * DoS amplification cap. + */ public Duration getJwksMinRefetch() { return jwksMinRefetch; } public static Builder builder() { return new Builder(); } diff --git a/java/keycloak-sdk-core/src/main/java/io/github/xzawed/keycloak/core/ResponseLimits.java b/java/keycloak-sdk-core/src/main/java/io/github/xzawed/keycloak/core/ResponseLimits.java index 8865d9da..20b8fbbf 100644 --- a/java/keycloak-sdk-core/src/main/java/io/github/xzawed/keycloak/core/ResponseLimits.java +++ b/java/keycloak-sdk-core/src/main/java/io/github/xzawed/keycloak/core/ResponseLimits.java @@ -1,46 +1,55 @@ package io.github.xzawed.keycloak.core; /** - * 이 SDK 가 응답 본문을 읽을 때 거는 크기 상한. + * Size caps this SDK applies when it reads response bodies. * - *

auth 와 admin 은 서로 모르는 모듈이라(§4) 둘이 함께 쓰는 값은 여기 core 에 한 번만 선언한다 — 두 번째 정의 자리를 만들지 - * 않는다. + *

auth and admin are modules that do not know each other (§4), so a value both of them use is declared only + * once, here in core — there is no second place that defines it. */ public final class ResponseLimits { /** - * 토큰 엔드포인트(client_credentials·refresh_token·authorization_code 그랜트 — auth 레인과 admin 레인의 자기 토큰 부여)· - * introspection·logout 응답 본문을 이 바이트 수까지만 받아들인다 — 오류 상태의 본문도 같다. 넘으면 그 호출은 나머지를 요청하지 - * 않고 {@link io.github.xzawed.keycloak.core.exception.KeycloakTransportException} 으로 실패한다. 내용 코딩을 푸는 전송(admin 의 - * RESTEasy gzip 해제)에서 푼 바이트를 세는 것은 결합이 읽는 2xx 본문뿐이다 — 오류 상태의 본문은 받은(내용 코딩된) 바이트로 센다(풀기 - * 전에 재는 응답 필터 — {@code TokenResponseGuard}). ⚠️ 이 값이 묶는 것은 SDK 가 요청하고 쥐는 본문 바이트(이 값+1 까지)다 — - * 응답의 틀(헤더·청크 크기 줄·트레일러)은 {@link #MAX_LINE_LENGTH}·{@link #MAX_HEADER_COUNT} 가 묶는다. + * Response bodies from the token endpoint (client_credentials, refresh_token and authorization_code grants — the + * auth lane's and the admin lane's own token grants), introspection and logout are accepted only up to this many + * bytes — the same holds for error-status bodies. Beyond it, the call does not request the rest and fails with + * {@link io.github.xzawed.keycloak.core.exception.KeycloakTransportException}. On a transport that decodes the + * content coding (admin's RESTEasy gzip decompression), only the 2xx body that the binding reads is counted in + * decoded bytes — an error-status body is counted in received (content-coded) bytes (a response filter that + * measures before decoding — {@code TokenResponseGuard}). ⚠️ What this value bounds is the body bytes the + * SDK requests and holds (up to this value + 1) — the response framing (headers, chunk-size lines, trailers) is + * bounded by {@link #MAX_LINE_LENGTH} and {@link #MAX_HEADER_COUNT}. * - *

Keycloak 26.6 기본 설정(start-dev 로 실측 2026-10-03)이 받아들이는 가장 긴 Bearer(65,459 바이트 — 한 바이트 더 길면 HTTP - * 431)의 16 배라 서버가 받아들이는 토큰을 이 상한이 거부하지 않는다(운영자는 그 헤더 한도를 올릴 수 있다 — 그래서 여유를 크게 - * 둔다). 그래도 적대적이거나 고장 난 엔드포인트의 끝없는 본문을 SDK 가 이 너머로 쥐지는 않는다 — 넘치면 두 레인 모두 남은 - * 본문을 읽지 않고 연결을 끊는다(HTTPS 는 JSSE 가 닫으며 이미 도착한 바이트를 버린다 — auth 는 운송이, admin 은 그 엔진이 단 - * 끊기 손잡이로). ⚠️ JWKS 응답 상한(Nimbus - * 51,200)을 빌려 쓰지 말 것 — 큰 배포의 쓸 수 있는 토큰을 거부했다. admin REST 응답의 성공 본문(사용자 목록 등)에는 걸지 않는다 — - * 정당하게 크다. 그 오류 본문은 이 값까지만 쥔다(진단에만 쓴다 — admin 엔진이 이 값을 빌려 넘는 나머지는 읽지 않고 끊는다). + *

It is 16 times the longest Bearer that Keycloak 26.6 accepts with its default settings (65,459 bytes, measured + * with start-dev on 2026-10-03 — one byte longer gets HTTP 431), so this cap does not refuse a token the server + * accepts (operators can raise that header limit — hence the large margin). Even so, the SDK does not hold an + * endless body from a hostile or broken endpoint beyond this — on overflow, both lanes drop the connection without + * reading the rest of the body (for HTTPS, JSSE discards the bytes that already arrived as it closes — auth through + * its transport, admin through the abort handle its engine attaches). ⚠️ Do not borrow the JWKS response cap + * (Nimbus 51,200) — it refused usable tokens of large deployments. It is not applied to the success bodies of admin + * REST responses (user lists and the like) — they are legitimately large. Their error bodies are held only up to + * this value (they serve diagnostics only — the admin engine borrows this value and drops the connection without + * reading the rest beyond it). * - *

⚠️ 아홉 언어가 함께 움직이는 값이고 교차언어 가드가 이 리터럴을 읽는다 — 식({@code 1 << 20})이 아니라 맨 십진 리터럴로 둔다. + *

⚠️ All nine languages move this value together, and a cross-language guard reads this literal — keep it a bare + * decimal literal, not an expression ({@code 1 << 20}). */ public static final int MAX_TOKEN_RESPONSE_BYTES = 1_048_576; /** - * 응답의 틀에서 한 줄의 최대 바이트 — 상태 줄 · 헤더 줄 · 청크 크기 줄(확장 포함) · 트레일러 줄. auth 레인(토큰·introspection· - * logout·JWKS)과 admin 레인(RESTEasy 의 HttpCore)이 함께 건다. 넘으면 그 호출은 전송 실패다. + * Maximum bytes of one line in the response framing — status line, header lines, chunk-size lines + * (extensions included) and trailer lines. The auth lane (token, introspection, logout, JWKS) and the admin lane + * (RESTEasy's HttpCore) both apply it. Beyond it, the call is a transport failure. * - *

왜: 본문 상한은 본문만 센다. 짧은 청크 본문 뒤의 트레일러를 JDK 는 한도 없이 담았고(4 KiB 줄 32 MiB → 토큰 수락 · 호출 - * 하나 170 MB, 한 줄 1 MiB → 17.2 GB), HttpCore 는 줄·헤더 수의 기본 한도가 없다(-1) — 실측 {@code ResponseFramingBoundsTest}· - * {@code AdminResponseFramingTest}. Keycloak 26.6.4 의 가장 긴 응답 헤더 줄은 85 바이트였다(토큰·introspection·logout·JWKS· - * 디스커버리 실측). + *

Why: the body cap counts only the body. The JDK held the trailers after a short chunked body without a limit + * (32 MiB of 4 KiB lines → token accepted, 170 MB for one call; one 1 MiB line → 17.2 GB), and HttpCore has no + * default limit on lines or header count (-1) — measured in {@code ResponseFramingBoundsTest} and + * {@code AdminResponseFramingTest}. The longest response header line of Keycloak 26.6.4 was 85 bytes (measured on + * token, introspection, logout, JWKS and discovery). */ public static final int MAX_LINE_LENGTH = 8192; /** - * 응답 헤더 하나의 절(머리 또는 트레일러)에 들 수 있는 최대 필드 수 — {@link #MAX_LINE_LENGTH} 와 함께 건다. Keycloak 26.6.4 의 - * 응답은 많아야 9 개였다(같은 실측). + * Maximum number of fields one response header section (the head or the trailer) may hold — applied together with + * {@link #MAX_LINE_LENGTH}. Keycloak 26.6.4's responses had at most 9 (same measurement). */ public static final int MAX_HEADER_COUNT = 100; diff --git a/java/keycloak-sdk-examples/src/main/java/io/github/xzawed/keycloak/examples/QuickStart.java b/java/keycloak-sdk-examples/src/main/java/io/github/xzawed/keycloak/examples/QuickStart.java index 70c8d799..9faeacf0 100644 --- a/java/keycloak-sdk-examples/src/main/java/io/github/xzawed/keycloak/examples/QuickStart.java +++ b/java/keycloak-sdk-examples/src/main/java/io/github/xzawed/keycloak/examples/QuickStart.java @@ -8,12 +8,13 @@ import org.keycloak.representations.idm.UserRepresentation; /** - * QuickStart 예제 (WBS 7.3). {@link KeycloakClient#create(KeycloakConfig)}로 SDK를 조립하고, - * client-credentials 그랜트로 토큰을 발급한 뒤 {@link Secrets#mask(String)}로 마스킹해서만 - * 출력하며(원문 토큰은 절대 로그에 남기지 않음), 렐름의 사용자 목록을 조회한다. + * QuickStart example (WBS 7.3). Assembles the SDK with {@link KeycloakClient#create(KeycloakConfig)}, + * obtains a token with the client-credentials grant and prints it only masked with {@link Secrets#mask(String)} + * (the raw token is never written to a log), then lists the realm's users. * - *

실제 Keycloak 서버가 필요하므로 이 클래스는 배포되지 않으며 컴파일만 검증한다 - * (모듈 POM의 {@code maven.deploy.skip=true}). 실행하려면 아래 환경변수를 실제 값으로 설정한다: + *

Because it needs a real Keycloak server, this class is not deployed — the parent POM's + * {@code } keeps this module out of the Central bundle — and only its compilation is + * verified. To run it, set the environment variables below to real values: * *

{@code
  * KEYCLOAK_URL=https://kc.example.com \
@@ -36,11 +37,11 @@ public static void main(String[] args) {
         .build();
 
     try (KeycloakClient client = KeycloakClient.create(config)) {
-      // 1) client-credentials 토큰 발급. 원문은 절대 출력하지 않고 Secrets.mask로 마스킹한다.
+      // 1) Obtain a client-credentials token. Never print the raw value; mask it with Secrets.mask.
       TokenSet tokens = client.auth().clientCredentialsToken();
       System.out.println("Access token: " + Secrets.mask(tokens.getAccessToken()));
 
-      // 2) 관리 API로 사용자 목록 조회 (최대 20명).
+      // 2) List users through the admin API (up to 20).
       List users = client.admin().users().search(null, 0, 20);
       System.out.println("Users in realm '" + config.getRealm() + "':");
       users.forEach(u -> System.out.println(" - " + u.getUsername()));
diff --git a/java/keycloak-sdk/src/main/java/io/github/xzawed/keycloak/KeycloakClient.java b/java/keycloak-sdk/src/main/java/io/github/xzawed/keycloak/KeycloakClient.java
index 2be4181f..c4f7c245 100644
--- a/java/keycloak-sdk/src/main/java/io/github/xzawed/keycloak/KeycloakClient.java
+++ b/java/keycloak-sdk/src/main/java/io/github/xzawed/keycloak/KeycloakClient.java
@@ -4,10 +4,11 @@
 import io.github.xzawed.keycloak.core.*;
 
 /**
- * SDK 통합 진입점(파사드, WBS 5.1). {@link #create(KeycloakConfig)}가 인증({@link AuthClient})을
- * 즉시 조립한다. 관리({@link AdminClient})는 clientSecret이 없는 공개/PKCE 클라이언트도
- * {@link #auth()}만으로 SDK를 사용할 수 있도록 {@link #admin()} 최초 호출 시점에 지연 생성된다.
- * {@link #close()}는 admin이 실제로 생성된 경우에만 그 네이티브 client-credentials 리소스를 정리한다.
+ * Unified SDK entry point (facade, WBS 5.1). {@link #create(KeycloakConfig)} assembles authentication
+ * ({@link AuthClient}) eagerly. Administration ({@link AdminClient}) is created lazily, on the first call to
+ * {@link #admin()}, so that public/PKCE clients without a clientSecret can also use the SDK through
+ * {@link #auth()} alone. {@link #close()} releases admin's native client-credentials resources only if admin
+ * was actually created.
  */
 public final class KeycloakClient implements AutoCloseable {
   private final AuthClient auth;
@@ -32,10 +33,10 @@ public static KeycloakClient create(KeycloakConfig config) {
   public AuthClient auth() { return auth; }
 
   /**
-   * Admin 클라이언트를 최초 호출 시 생성해 반환한다(동기화 메서드로 스레드 안전성 보장). clientSecret이
-   * 없는 설정으로 {@link #create(KeycloakConfig)}한 경우 여기서
-   * {@link io.github.xzawed.keycloak.core.exception.KeycloakConfigException}이 던져진다 —
-   * {@link #auth()}만 쓰는 공개/PKCE 클라이언트는 이 경로를 타지 않는다.
+   * Creates the admin client on the first call and returns it (a synchronized method, which guarantees thread
+   * safety). If {@link #create(KeycloakConfig)} was given a configuration without a clientSecret,
+   * {@link io.github.xzawed.keycloak.core.exception.KeycloakConfigException} is thrown here —
+   * public/PKCE clients that only use {@link #auth()} never take this path.
    */
   public synchronized AdminClient admin() {
     if (admin == null) {

From 7876a3be0699ebfc8d0b197b11159266dd479565 Mon Sep 17 00:00:00 2001
From: xzawed <47019855+xzawed@users.noreply.github.com>
Date: Fri, 30 Oct 2026 18:37:38 +0900
Subject: [PATCH 2/2] =?UTF-8?q?ci(java)=20=C2=B7=20docs:=20java=20?=
 =?UTF-8?q?=EB=A0=88=EC=9D=B8=EC=9D=B4=20=EB=B2=88=EB=93=A4=EC=9D=98=20-ja?=
 =?UTF-8?q?vadoc.jar=20=EB=A5=BC=20=EC=84=BC=EB=8B=A4=20=C2=B7=20CHANGELOG?=
 =?UTF-8?q?=20=C2=B7=20=EB=93=B1=EB=A1=9D=EB=B6=80=20=ED=95=AD=EB=AA=A9?=
 =?UTF-8?q?=EC=9D=84=20=EB=8B=AB=EB=8A=94=EB=8B=A4=20(korean-api-docs-in-p?=
 =?UTF-8?q?ublished-packages)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

java 레인 가드 — `release-bundle` 잡이 만드는 Central 번들(게시되는 그대로, examples 는 부모 POM 의
 로 빠진다)에서 `*-javadoc.jar` 를 꺼내 풀고 #911 의 계수기로 센다. 번들에 javadoc 이
없으면 읽은 파일 0 으로 실패한다(exit 2). `paths:` 에 계수기를 더했다.
로컬 실측 — CI 와 같은 명령(versions:set 0.0.0-bundle-check → -Prelease deploy -DskipUpload)으로 번들을 만들어:
  번역 뒤: javadoc jar 4 · 110 HTML · 한글 0
  ResponseLimits 의 공개 javadoc 에 한글 한 줄을 심어 다시 만들면: `ResponseLimits.html:97` 한 줄을 잡는다
  (그 실행의 「rc=0」은 내 검증 스크립트가 `| head` 로 계수기의 종료코드를 가린 탓이다 — CI 단계는 파이프 없이
  `set -euo pipefail` 아래에서 돌고, 계수기가 발견 시 1 을 내는 것은 자가테스트가 고정한다).

CHANGELOG — python 항목 끝의 「이로써 아홉 SDK 의 그려지는 문서가 모두 영문입니다」는 그때 거짓이었다(java).
그 문장을 떼고 java 항목에 붙였다 — 이 PR 이 들어가면 참이다.

등록부 — 항목을 닫는다(표제 한 줄): 그려지는 문서는 아홉 레인 가드(#911 · #912 · 이 PR), 런타임 문구는 #908.
진입점을 2026-10-30 으로 고치고 남은 칸 목록에서 이 항목을 뺐다. 함정 한 줄: 측정이 도는 워크트리를 지우지
않는다 — 옛 오탐 측정기가 `|| true` 로 git 실패를 삼켜 거짓 수(존재 408 · 불일치 1)를 냈다.
D 절 머리 열림 51 → 50 · 「규모」의 날짜 붙은 수 → 2026-10-30 기준 열림 130 · 닫힘 154.
예산 래칫(검사 8e)대로 앵커를 내렸다: 적재 174,781 → 173,995 · raw 199,954 → 199,140.

검증(main d5d7789b 위): test-remaining-work-shape 28/0 · test-selftest-hygiene 241/0 ·
check-ci-permissions rc=0 · check-docs rc=0 · test-changelog-landing 16/0 · test-changelog-ledger 68/0 ·
CHANGELOG java 항목 468 B.

Co-Authored-By: Claude Opus 5.5 
Claude-Session: https://claude.ai/code/session_01R4jYS4tpj7usahf8MK5dXo
---
 .github/workflows/ci.yml                 | 17 +++++++++++++++--
 CHANGELOG.md                             |  3 ++-
 docs/superpowers/plans/remaining-work.md | 17 +++++++----------
 3 files changed, 24 insertions(+), 13 deletions(-)

diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 4691eeaf..04204828 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -1,7 +1,7 @@
 name: CI
 on:
-  push: { branches: [main], paths: ['java/**', '.github/workflows/ci.yml', 'scripts/check-hp-attest.mjs'] }
-  pull_request: { branches: [main], paths: ['java/**', '.github/workflows/ci.yml', 'scripts/check-hp-attest.mjs'] }
+  push: { branches: [main], paths: ['java/**', '.github/workflows/ci.yml', 'scripts/check-hp-attest.mjs', 'scripts/check-rendered-docs-hangul.mjs'] }
+  pull_request: { branches: [main], paths: ['java/**', '.github/workflows/ci.yml', 'scripts/check-hp-attest.mjs', 'scripts/check-rendered-docs-hangul.mjs'] }
   # 주간 레인 건강검진. ⚠️ `schedule` 은 `paths` 를 타지 않는다(실측: harness 스케줄이 6일 연속
   # 돌았는데 그 경로가 바뀐 날은 2일뿐) — **그것이 목적이다.** 겨누는 것은 의존성 드리프트가
   # 아니라(락·핀이 이미 막는다) 러너 이미지·액션 같은 **환경** 드리프트다. 상세: .claude/rules/ci.md
@@ -141,3 +141,16 @@ jobs:
         run: |
           set -euo pipefail
           unzip -Z1 java/target/central-publishing/central-bundle.zip | node scripts/check-central-bundle.mjs - --root=.
+      # 그려지는 문서(Central 에 올라가는 이 번들의 -javadoc.jar)에 한글 0 — 등록부 korean-api-docs-in-published-packages.
+      # 번들에 javadoc 이 하나도 없으면 계수기가 읽은 파일 0 으로 실패한다(exit 2).
+      - name: 그려지는 문서(번들의 -javadoc.jar) 한글 0
+        run: |
+          set -euo pipefail
+          D="$RUNNER_TEMP/javadoc"
+          mkdir -p "$D/jars" "$D/html"
+          unzip -q -o java/target/central-publishing/central-bundle.zip '*-javadoc.jar' -d "$D/jars"
+          for j in $(find "$D/jars" -name '*-javadoc.jar' | sort); do
+            n="$(basename "$j" .jar)"
+            mkdir -p "$D/html/$n" && unzip -q -o "$j" -d "$D/html/$n"
+          done
+          node scripts/check-rendered-docs-hangul.mjs "$D/html" --ext=.html --min-files=50
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 7d79665d..b307081d 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -18,8 +18,9 @@
 - **(Python)** ⚠️ sync 응답은 응답마다 바이트 예산(본문 상한 + 65,536 B — 토큰·introspection·logout·admin 오류 1,114,112 · JWKS 116,736 · admin 성공 본문은 없음)을 넘으면 `KeycloakTransportError` 입니다 — 지금까지는 16 MiB 트레일러(트레일러 수를 묶지 않는 CPython 판에서)와 gzip 토큰 본문 뒤의 꼬리를 받았고, 틀이 큰 admin 오류 응답은 `KeycloakNotFoundError` 등으로 나왔습니다. JWKS 오류 메시지에 `HTTP response exceeds 116736 bytes on the wire` 가 실릴 수 있습니다.
 - **(Go)** pkg.go.dev 에 그려지는 문서 주석(마스킹 훅 여섯 · admin `Update` 셋의 ⚠️ 주석)을 영문으로 옮겼습니다 — 나머지는 이미 영문이었습니다. 코드는 그대로이고(`go/scanner` 로 주석을 뺀 토큰열이 같다), `go doc -all` 의 한글은 38 줄 → 0 입니다.
 - **(Kotlin)** Dokka 가 그리는 문서 주석(게시되는 `-javadoc.jar` 포함)과 README 가 링크하는 `examples/quickstart.kt` 의 주석을 영문으로 옮겼습니다 — 지금까지는 한글이었습니다. 코드는 그대로이고(`javap -c` 역어셈블이 main 과 같다), Dokka HTML 의 한글은 41 파일 → 0 입니다.
-- **(Python)** `help()`·IDE 호버에 뜨는 공개 API 의 독스트링(`keycloak_sdk`·`keycloak_sdk.aio` 의 `__all__` 에서 닿는 것)과 README 가 링크하는 예제 둘의 주석을 영문으로 옮겼습니다 — 지금까지는 한글이었습니다. 코드는 그대로이고(독스트링을 자리표시로 바꾼 AST 가 24 파일 모두 같다), 공개 표면 pydoc 의 한글은 358 줄 → 0 입니다. 이로써 아홉 SDK 의 그려지는 문서가 모두 영문입니다.
+- **(Python)** `help()`·IDE 호버에 뜨는 공개 API 의 독스트링(`keycloak_sdk`·`keycloak_sdk.aio` 의 `__all__` 에서 닿는 것)과 README 가 링크하는 예제 둘의 주석을 영문으로 옮겼습니다 — 지금까지는 한글이었습니다. 코드는 그대로이고(독스트링을 자리표시로 바꾼 AST 가 24 파일 모두 같다), 공개 표면 pydoc 의 한글은 358 줄 → 0 입니다.
 - **(Node · Python)** ⚠️ 한글이던 런타임 오류 메시지 다섯을 영문으로 옮겼습니다 — node `KeycloakTokenValidationError` 의 `JWT 검증 실패: …` 는 `JWT validation failed: …`, python `KeycloakConfigError` 넷은 `admin API access requires client_secret (client-credentials grant)` · `admin access requires config (use KeycloakClient.create(config))`(aio 도 같은 꼴)입니다. 예외 타입과 원인 사슬은 그대로이고, 메시지 문자열을 대조하던 코드만 영향을 받습니다. 소비자에게 닿는 한글 문구는 이것이 전부였습니다 — 나머지 일곱은 0 입니다.
+- **(Java)** Maven Central 에 게시되는 `-javadoc.jar` 의 문서 주석(공개 API)과 README 가 링크하는 `QuickStart.java` 의 주석을 영문으로 옮겼습니다 — 다른 언어를 옮길 때 java 만 빠져 지금까지 한글이었습니다. 코드는 그대로이고(`javap -v -p` 가 main 빌드와 같다 — 59 클래스), javadoc jar 의 한글은 221 줄 → 0 입니다. 이로써 아홉 SDK 의 그려지는 문서가 모두 영문입니다.
 
 ### Fixed
 - **(Go)** 공유 비행(첫 `Client.Admin` · client_credentials 토큰 · JWKS 조회)의 결과와 호출자 ctx 의 취소가 함께 준비되면 둘 중 하나를 무작위로 돌려줬습니다(동점 200 회에 `context.Canceled` 93–103 회) — 이제 준비된 결과가 이깁니다(캐시 적중이 이미 그렇다). 결과가 아직 없으면 전처럼 ctx 오류입니다.
diff --git a/docs/superpowers/plans/remaining-work.md b/docs/superpowers/plans/remaining-work.md
index d5018156..454a8717 100644
--- a/docs/superpowers/plans/remaining-work.md
+++ b/docs/superpowers/plans/remaining-work.md
@@ -1,5 +1,5 @@
 
-
+
 
-⚠️ **이 표를 판정에 쓰지 말 것 — 세 줄이 서로 맞지 않는다.** 체크박스 전수는 `284`(2026-10-29 기준 열림 131 · 닫힘 153)인데 심각도·작업량 행의 합은 **153**이다. 어긋난 채로 커밋돼 있었고(2026-09-06 확인), 어느 쪽이 옳은지는 원장을 다시 세야 정해진다. ⚠️ **그리고 그 문장이 「위 두 명령을 돌린다」로 끝나 있었는데 위에는 명령이 없었다** — 세는 법을 지운 채 「세라」만 남은 자리였다(2026-09-16 정정). 세는 명령은 이것이다:
+⚠️ **이 표를 판정에 쓰지 말 것 — 세 줄이 서로 맞지 않는다.** 체크박스 전수는 `284`(2026-10-30 기준 열림 130 · 닫힘 154)인데 심각도·작업량 행의 합은 **153**이다. 어긋난 채로 커밋돼 있었고(2026-09-06 확인), 어느 쪽이 옳은지는 원장을 다시 세야 정해진다. ⚠️ **그리고 그 문장이 「위 두 명령을 돌린다」로 끝나 있었는데 위에는 명령이 없었다** — 세는 법을 지운 채 「세라」만 남은 자리였다(2026-09-16 정정). 세는 명령은 이것이다:
 
 ```sh
 grep -c '^- \[ \]' docs/superpowers/plans/remaining-work.md   # 열림
@@ -60,7 +60,7 @@ git branch --show-current               # ⚠️ 아래 함정 (e)
 
 ⚠️ **`main` 이 아닌 브랜치에서 시작했다면 먼저 `main` 으로 간다** — 함정 (e)가 그것이다. ⚠️ **에이전트의 세션 메모리는 PC를 넘어가지 않는다.** 넘어가야 하는 것은 전부 이 문서와 `.claude/rules/*.md`·`docs/guides/development-setup.md` 에 있어야 하고, 새로 배운 것도 거기 적는다.
 
-### 다음 세션 진입점 (2026-10-29 · #881–#900 반영)
+### 다음 세션 진입점 (2026-10-30 · #901–#913 반영)
 
 **지금 상태** — 물결 6(2026-10-27/28): 아홉 전부 **LIVE**, 게시 바이트 = 태그 · 기준선 = 게시판(#863·#865). 월간 상한 경고(6/7)는 7·8 번째 게시를 막지 않았다(`ci.md`). 태그 위임은 물결마다 다시 묻는다. 열린 PR 은 Dependabot 뿐이어야 한다. 래퍼 9.8 의 되살릴 조건은 #682·#788. ⚠️ `[Unreleased]` 에 아홉 다 변화가 있다 — 물결은 사람 몫.
 
@@ -68,9 +68,9 @@ git branch --show-current               # ⚠️ 아래 함정 (e)
 
 **다음 순서** — ①초록이 거짓 → ②게시본 소비자 → ③잠복 → ④완결성 축 그대로.
 
-1. 2026-10-29 판정의 수행은 끝났다(#882–#898). 남은 칸은 각 항목이 적는다 — `response-read-deadline-divergent` · `korean-api-docs-in-published-packages` · `wave4-hardening-php` · `response-framing-unbounded`(node 는 상류 보고 — 사람 몫).
+1. 2026-10-29 판정의 남은 칸은 각 항목이 적는다 — `wave4-hardening-php`(다음 걸음: 아홉 언어 분류표) · `response-read-deadline-divergent`(JVM admin 틀 · python TLS 안의 TLS) · `response-framing-unbounded`(node 는 상류 보고 — 사람 몫).
 2. `guard-detection-surface-hand-narrowed` [H/M](손 표 축 — 되살릴 조건) · `integration-coverage-never-measured` [H/L](go·python 파일럿 #774 의 CI 관측 셋 → 나머지 일곱).
-3. ⚠️ **손대기 전에 전제를 잰다 — CI 의 하위 판은 로컬과 다르다**(urllib3 2.8 · CPython 의 트레일러 상한이 대조군을 뒤집었다, #893 — 대조군은 동작으로 가른다). 충돌한 PR 에는 실행이 안 생긴다(머리 sha 로 잰다).
+3. ⚠️ **손대기 전에 전제를 잰다 — CI 의 하위 판은 로컬과 다르다**(urllib3 2.8 · CPython 의 트레일러 상한이 대조군을 뒤집었다, #893 — 대조군은 동작으로 가른다). 충돌한 PR 에는 실행이 안 생긴다(머리 sha 로 잰다). ⚠️ 측정이 도는 워크트리를 지우지 않는다 — `|| true` 가 git 실패를 삼켜 거짓 수를 냈다(2026-10-30).
 
 **판정 2026-10-29(사람 위임 「권장안」)** — 대기 판정이 모두 났다 — 항목 표제줄의 「판정 2026-10-29」와 그 주석이 소유한다(PM·Grok 대조는 #881 · 라운드 2 #892 본문). 항목 없는 셋 — php 하한 `guzzle ^7.15.2`·`psr7 ^2.12.3`(composer.json 반영) · rust·node `raw()` 오류 본문 상한은 두지 않는다 · Dependabot PR 스캔 안 함(`rejected.md`). 2026-10-23 판정 둘 — commons-codec 은 1.15 유지(이유·이동 조건은 `.github/dependabot.yml` 의 ignore 주석) · sonarcloud 는 잡 분리(규칙 12 — 닫힘).
 
@@ -532,7 +532,7 @@ low 강등분 + 아무 배치도 담당하지 않았던 harness 사각지대 14
   - ⚠️ **「install/ 64파일이 지도에 없다」는 그 형태로는 반증 불가다** — README 의 Layout 은 **디렉터리 개요**이지 파일 목록이 아니다(`install/` 은 한 노드로 접혀 자체 README·`compose.install.yml`·`install-verify.sh`·`publish/`·`consume/`·`registries/` 만 가리킨다). 「빠진 N 개」는 그 구조에서 셀 수 없다 — **주장을 다시 쓰거나**(예: 「개요가 가리키는 노드와 실제 하위 디렉터리 집합이 어긋난다」) 닫아야 한다.
 - [x] `H8-root-config-never-rederived` **[L/M · 닫힘 2026-09-15]** 리포 루트 설정 둘이 언어·락파일이 늘 때 한 번도 다시 도출되지 않았다 · `.dockerignore:2`
 
-## D. 원장 밖 — 88건 (열림 51)
+## D. 원장 밖 — 88건 (열림 50)
 
 감사가 보지 않은 축 — 유예·미완 마커·로드맵 갭·CI/릴리스·테스트 실행·1.0 이후 운영·완전성 비평.
 
@@ -631,10 +631,7 @@ low 강등분 + 아무 배치도 담당하지 않았던 harness 사각지대 14
   - **열린 medium 31 · low 12**: 언어별 drift 가 다수이고 **아홉 전부에서 최소 1 건**이 나왔다(범위 1–6). 대표: getting-started 의 「coverage gate」 주장이 go·rust·dotnet 세 곳에서 거짓(그 명령은 커버리지를 재지도 게이트하지도 않는다) · `php/composer.json:41` 의 `cs`/`cs:fix` 가 `--allow-risky=yes` 누락으로 **항상 실패** · `.claude/rules/{dotnet,ruby,php,rust}.md` 의 전사된 실측값이 낡아 다음 세션을 틀린 판단으로 이끈다 · PHP·Ruby 의 기본 `readTimeout` 이 10 초로 나머지 일곱(30 초)과 갈린다.
   - ✅ 위 계수가 **하한**이었던 이유는 아래 `critic-2026-09-22-round2` 가 소유한다(완전성 비평 6 각도, 2026-09-22).
 - [x] `critic-2026-09-22-round2` **[H/L · 신규 2026-09-22 · 닫힘 2026-10-27]** 24 생존 중 21 닫힘(#529–#538) · 2 반증 · 남은 1 건은 `korean-api-docs-in-published-packages` 로 분리 — 열린 것이 없다
-- [ ] `korean-api-docs-in-published-packages` **[M/H · 신규 2026-09-22 · 판정 2026-10-29 「번역 — 언어당 PR」 · 번역 끝 #890–#898]** 그려지는 문서 주석은 아홉 다 영문이다 — 남은 것: 다시 들어오지 않게 하는 CI 가드 · `rust/src` 외 6
-  - **판정 기준 = 산출물의 한글 0**(`git grep --no-index -P '[\x{AC00}-\x{D7A3}]'` — ⚠️ plain `grep -P` 는 거짓 0): rust `cargo doc` HTML · node `.d.ts` · ruby `yard doc` · php 공개 docblock 덤프 · go `go doc -all` · kotlin Dokka · python 공개 표면 pydoc(수와 덤프 스크립트는 각 PR 본문).
-  - ⏳ **가드가 없다** — 위 오라클을 레인 CI 에 걸지 않으면 한글 문서 주석이 다시 들어온다(지금은 사람만 본다). ⚠️ pydoc 은 독스트링 없는 공개 def 위의 `#` 주석을 그린다(`inspect.getcomments`).
-  - ✅ **런타임 오류 문구**(2026-10-30) — node 1 · python 4 를 영문으로, 재유입은 두 레인 `runtime-strings-english` 시험이 잡는다. 나머지 일곱 0(rust 는 `#[cfg(test)]` 안뿐). 버린 선택지: 「규칙에 게시 주석 한글 예외를 명시」.
+- [x] `korean-api-docs-in-published-packages` **[M/H · 신규 2026-09-22 · 닫힘 2026-10-30 · #890–#898 · #908 · #911 · #912 · #913]** 그려지는 문서와 런타임 문구가 아홉 다 영문이다 — 레인마다 산출물 한글 0 가드(`check-rendered-docs-hangul.mjs` · python·php 는 단위 시험). java 는 첫 판정에서 빠져 따로 옮겼다
 - [x] `remaining-work-split-to-archive-tag` **[H/M · 신규 2026-09-22 · 닫힘 2026-10-28]** #526 이 실행했다(닫힌 70 건의 서사 → 태그 · 예산 앵커 · 되살릴 조건 이관은 명령 형태가 0 건이라 no-op). 설계의 「닫힘 = 0」만은 「소유 줄 == 닫힘 수」로 바뀌었고(`test-remaining-work-shape.sh` 머리 주석), 뒤이은 이관은 머리말의 포인터가 가리킨다.
 - [x] `changelog-unreleased-never-written` **[M/S · 신규 2026-09-22 · 닫힘 2026-10-27]** 착지 장치 `scripts/test/test-changelog-landing.sh` 가 섰고 `[Unreleased]` 가 착지마다 자란다 — 150 건은 역채우지 않았다
 - [x] `jwks-empty-keyset-node` **[M/M · 신규 2026-09-22 · 닫힘 2026-09-24]** 「구조 변경뿐」이라는 전제가 틀렸다 — 기각된 두 길(getKey 래핑·별도 프리플라이트) 밖에 셋째가 있었다: 이미 배선된 `[customFetch]` 래퍼는 jose 가 파싱할 **바로 그 응답**을 보고, jose reload 는 fetch 가 던지면 옛 캐시를 남긴다(`jose/dist/webapi/jwks/remote.js` `.then` 안에서만 대입). jose 원격 집합을 그대로 상속한다 · `jwt-jwks.test.ts` 수정 전 RED → GREEN · 변이 CAUGHT