From 911d502822ab428e1d8edac15bde1ba05a9f0fed Mon Sep 17 00:00:00 2001 From: Wheatley from Portal <256330434+portal-wheatley@users.noreply.github.com> Date: Thu, 2 Apr 2026 11:08:40 +0000 Subject: [PATCH 1/2] feat: add age verification endpoints (0.4.1) --- README.MD | 18 ++++++- build.gradle.kts | 4 +- .../java/cc/getportal/AsyncOperation.java | 36 ++++++++++++- src/main/java/cc/getportal/PortalClient.java | 52 +++++++++++++++++++ .../model/VerificationSessionResponse.java | 12 +++++ 5 files changed, 118 insertions(+), 4 deletions(-) create mode 100644 src/main/java/cc/getportal/model/VerificationSessionResponse.java diff --git a/README.MD b/README.MD index c21528c..ecb9587 100644 --- a/README.MD +++ b/README.MD @@ -12,7 +12,7 @@ dependencyResolutionManagement { // build.gradle.kts dependencies { - implementation("com.github.PortalTechnologiesInc:java-sdk:0.4.0") + implementation("com.github.PortalTechnologiesInc:java-sdk:0.4.1") } ``` @@ -87,6 +87,22 @@ client.deliverWebhookPayload(rawBody, request.getHeader("X-Portal-Signature")); | `requestCashu(recipientKey, subkeys, mintUrl, unit, amount)` | `AsyncOperation` | | `authenticateKey(mainKey, subkeys)` | `AsyncOperation` | | `newKeyHandshakeUrl(staticToken, noRequest)` | `AsyncOperation` | +| `createVerificationSession(relayUrls)` | `AsyncOperation` | +| `requestVerificationToken(recipientKey, subkeys)` | `AsyncOperation` | + +### Age Verification + +```java +// Create verification session — single call handles everything +AsyncOperation op = client.createVerificationSession(); +System.out.println("Redirect user to: " + op.sessionUrl()); + +// Wait for the user to complete verification in their browser +CashuResponseStatus result = client.pollUntilComplete(op, new PollOptions(1000, 300_000)); +if (result.status.equals("success")) { + System.out.println("Verified! Token: " + result.token); +} +``` ## Sync methods diff --git a/build.gradle.kts b/build.gradle.kts index b98dd95..3d92995 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -4,7 +4,7 @@ plugins { } group = "cc.getportal" -version = "0.4.0" +version = "0.4.1" java { toolchain { @@ -23,7 +23,7 @@ publishing { groupId = "cc.getportal" artifactId = "portal-java-sdk" - version = "0.4.0" + version = "0.4.1" } } } diff --git a/src/main/java/cc/getportal/AsyncOperation.java b/src/main/java/cc/getportal/AsyncOperation.java index 78c7d4c..d36398c 100644 --- a/src/main/java/cc/getportal/AsyncOperation.java +++ b/src/main/java/cc/getportal/AsyncOperation.java @@ -1,9 +1,43 @@ package cc.getportal; +import org.jetbrains.annotations.Nullable; + +import java.util.Map; import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ConcurrentHashMap; /** * Wraps an async operation: the stream ID is available immediately, * while the {@code done} future resolves when a terminal event arrives. */ -public record AsyncOperation(String streamId, CompletableFuture done) {} +public class AsyncOperation { + private final String streamId; + private final CompletableFuture done; + private final Map metadata = new ConcurrentHashMap<>(); + + public AsyncOperation(String streamId, CompletableFuture done) { + this.streamId = streamId; + this.done = done; + } + + public String streamId() { return streamId; } + public CompletableFuture done() { return done; } + + public void setMetadata(String key, String value) { + metadata.put(key, value); + } + + public @Nullable String getMetadata(String key) { + return metadata.get(key); + } + + /** Convenience: get the session_url for verification sessions. */ + public @Nullable String sessionUrl() { + return metadata.get("session_url"); + } + + /** Convenience: get the session_id for verification sessions. */ + public @Nullable String sessionId() { + return metadata.get("session_id"); + } +} diff --git a/src/main/java/cc/getportal/PortalClient.java b/src/main/java/cc/getportal/PortalClient.java index 414ae6c..4e254d4 100644 --- a/src/main/java/cc/getportal/PortalClient.java +++ b/src/main/java/cc/getportal/PortalClient.java @@ -486,6 +486,58 @@ public WalletInfoResponse getWalletInfo() throws IOException, InterruptedExcepti return get("/wallet/info", WalletInfoResponse.class); } + // ------------------------------------------------------------------------- + // Verification + // ------------------------------------------------------------------------- + + /** + * Create an age verification session and automatically start listening for the + * verification token. Returns session info plus an {@link AsyncOperation} that + * resolves when the user completes verification. + * + *

Redirect the user to {@code response.session_url} in their browser. + * Poll or await {@code done()} for the {@link CashuResponseStatus} result. + * + * @param relayUrls optional relay URLs; defaults to server's [nostr] config if null + */ + public AsyncOperation createVerificationSession( + @Nullable List relayUrls + ) throws IOException, InterruptedException, PortalSDKException { + Map body = new java.util.HashMap<>(); + if (relayUrls != null) body.put("relays", relayUrls); + VerificationSessionResponse resp = post("/verification/sessions", body, VerificationSessionResponse.class); + AsyncOperation op = registerStream(resp.stream_id, + json -> gson.fromJson(json.getAsJsonObject("status"), CashuResponseStatus.class)); + op.setMetadata("session_id", resp.session_id); + op.setMetadata("session_url", resp.session_url); + op.setMetadata("ephemeral_npub", resp.ephemeral_npub); + op.setMetadata("expires_at", String.valueOf(resp.expires_at)); + return op; + } + + /** Convenience overload with default relays. */ + public AsyncOperation createVerificationSession() + throws IOException, InterruptedException, PortalSDKException { + return createVerificationSession(null); + } + + /** + * Request a verification token from a user who already holds one + * (e.g. verified through the mobile app). + * + * @param recipientKey hex-encoded public key of the token holder + * @param subkeys optional subkeys + */ + public AsyncOperation requestVerificationToken( + String recipientKey, List subkeys + ) throws IOException, InterruptedException, PortalSDKException { + Map body = Map.of("recipient_key", recipientKey, "subkeys", subkeys); + JsonObject resp = post("/verification/token", body, JsonObject.class); + String streamId = resp.get("stream_id").getAsString(); + return registerStream(streamId, + json -> gson.fromJson(json.getAsJsonObject("status"), CashuResponseStatus.class)); + } + // ------------------------------------------------------------------------- // Events (low-level) // ------------------------------------------------------------------------- diff --git a/src/main/java/cc/getportal/model/VerificationSessionResponse.java b/src/main/java/cc/getportal/model/VerificationSessionResponse.java new file mode 100644 index 0000000..e4dc080 --- /dev/null +++ b/src/main/java/cc/getportal/model/VerificationSessionResponse.java @@ -0,0 +1,12 @@ +package cc.getportal.model; + +/** + * Response from {@code POST /verification/sessions}. + */ +public class VerificationSessionResponse { + public String session_id; + public String session_url; + public String ephemeral_npub; + public long expires_at; + public String stream_id; +} From f72055aab9b076321d0438df660338aab8878f42 Mon Sep 17 00:00:00 2001 From: Wheatley from Portal <256330434+portal-wheatley@users.noreply.github.com> Date: Thu, 2 Apr 2026 14:21:06 +0000 Subject: [PATCH 2/2] refactor: use VerificationSession wrapper instead of AsyncOperation metadata --- README.MD | 10 +++--- .../java/cc/getportal/AsyncOperation.java | 36 +------------------ src/main/java/cc/getportal/PortalClient.java | 19 +++++----- .../getportal/model/VerificationSession.java | 20 +++++++++++ 4 files changed, 33 insertions(+), 52 deletions(-) create mode 100644 src/main/java/cc/getportal/model/VerificationSession.java diff --git a/README.MD b/README.MD index ecb9587..0aa6709 100644 --- a/README.MD +++ b/README.MD @@ -87,18 +87,16 @@ client.deliverWebhookPayload(rawBody, request.getHeader("X-Portal-Signature")); | `requestCashu(recipientKey, subkeys, mintUrl, unit, amount)` | `AsyncOperation` | | `authenticateKey(mainKey, subkeys)` | `AsyncOperation` | | `newKeyHandshakeUrl(staticToken, noRequest)` | `AsyncOperation` | -| `createVerificationSession(relayUrls)` | `AsyncOperation` | +| `createVerificationSession(relayUrls)` | `VerificationSession` | | `requestVerificationToken(recipientKey, subkeys)` | `AsyncOperation` | ### Age Verification ```java -// Create verification session — single call handles everything -AsyncOperation op = client.createVerificationSession(); -System.out.println("Redirect user to: " + op.sessionUrl()); +VerificationSession session = client.createVerificationSession(); +System.out.println("Redirect user to: " + session.session.session_url); -// Wait for the user to complete verification in their browser -CashuResponseStatus result = client.pollUntilComplete(op, new PollOptions(1000, 300_000)); +CashuResponseStatus result = client.pollUntilComplete(session.operation, new PollOptions(1000, 300_000)); if (result.status.equals("success")) { System.out.println("Verified! Token: " + result.token); } diff --git a/src/main/java/cc/getportal/AsyncOperation.java b/src/main/java/cc/getportal/AsyncOperation.java index d36398c..78c7d4c 100644 --- a/src/main/java/cc/getportal/AsyncOperation.java +++ b/src/main/java/cc/getportal/AsyncOperation.java @@ -1,43 +1,9 @@ package cc.getportal; -import org.jetbrains.annotations.Nullable; - -import java.util.Map; import java.util.concurrent.CompletableFuture; -import java.util.concurrent.ConcurrentHashMap; /** * Wraps an async operation: the stream ID is available immediately, * while the {@code done} future resolves when a terminal event arrives. */ -public class AsyncOperation { - private final String streamId; - private final CompletableFuture done; - private final Map metadata = new ConcurrentHashMap<>(); - - public AsyncOperation(String streamId, CompletableFuture done) { - this.streamId = streamId; - this.done = done; - } - - public String streamId() { return streamId; } - public CompletableFuture done() { return done; } - - public void setMetadata(String key, String value) { - metadata.put(key, value); - } - - public @Nullable String getMetadata(String key) { - return metadata.get(key); - } - - /** Convenience: get the session_url for verification sessions. */ - public @Nullable String sessionUrl() { - return metadata.get("session_url"); - } - - /** Convenience: get the session_id for verification sessions. */ - public @Nullable String sessionId() { - return metadata.get("session_id"); - } -} +public record AsyncOperation(String streamId, CompletableFuture done) {} diff --git a/src/main/java/cc/getportal/PortalClient.java b/src/main/java/cc/getportal/PortalClient.java index 4e254d4..e47d138 100644 --- a/src/main/java/cc/getportal/PortalClient.java +++ b/src/main/java/cc/getportal/PortalClient.java @@ -492,15 +492,16 @@ public WalletInfoResponse getWalletInfo() throws IOException, InterruptedExcepti /** * Create an age verification session and automatically start listening for the - * verification token. Returns session info plus an {@link AsyncOperation} that - * resolves when the user completes verification. + * verification token. Returns a {@link VerificationSession} containing session + * info and an {@link AsyncOperation} that resolves when the user completes + * verification. * - *

Redirect the user to {@code response.session_url} in their browser. - * Poll or await {@code done()} for the {@link CashuResponseStatus} result. + *

Redirect the user to {@code session.session_url} in their browser. + * Poll or await {@code operation.done()} for the {@link CashuResponseStatus} result. * * @param relayUrls optional relay URLs; defaults to server's [nostr] config if null */ - public AsyncOperation createVerificationSession( + public VerificationSession createVerificationSession( @Nullable List relayUrls ) throws IOException, InterruptedException, PortalSDKException { Map body = new java.util.HashMap<>(); @@ -508,15 +509,11 @@ public AsyncOperation createVerificationSession( VerificationSessionResponse resp = post("/verification/sessions", body, VerificationSessionResponse.class); AsyncOperation op = registerStream(resp.stream_id, json -> gson.fromJson(json.getAsJsonObject("status"), CashuResponseStatus.class)); - op.setMetadata("session_id", resp.session_id); - op.setMetadata("session_url", resp.session_url); - op.setMetadata("ephemeral_npub", resp.ephemeral_npub); - op.setMetadata("expires_at", String.valueOf(resp.expires_at)); - return op; + return new VerificationSession(resp, op); } /** Convenience overload with default relays. */ - public AsyncOperation createVerificationSession() + public VerificationSession createVerificationSession() throws IOException, InterruptedException, PortalSDKException { return createVerificationSession(null); } diff --git a/src/main/java/cc/getportal/model/VerificationSession.java b/src/main/java/cc/getportal/model/VerificationSession.java new file mode 100644 index 0000000..be1a808 --- /dev/null +++ b/src/main/java/cc/getportal/model/VerificationSession.java @@ -0,0 +1,20 @@ +package cc.getportal.model; + +import cc.getportal.AsyncOperation; + +/** + * Result of {@code createVerificationSession()} — contains the session info + * and an {@link AsyncOperation} for polling the verification token. + */ +public class VerificationSession { + public final VerificationSessionResponse session; + public final AsyncOperation operation; + + public VerificationSession( + VerificationSessionResponse session, + AsyncOperation operation + ) { + this.session = session; + this.operation = operation; + } +}