@blazing-customs/ppx-client
v0.1.0-alpha.4
Published
TypeScript client for the Preference Profile Exchange (PPX) reference provider and any conformant implementation.
Maintainers
Readme
@blazing-customs/ppx-client
TypeScript client for the Preference Profile Exchange (PPX) reference provider and any conformant implementation.
Install
npm i @blazing-customs/ppx-clientRequires Node 18.17+. Published version: 0.1.0-alpha.1 (npm).
Alpha, tracking a draft specification. Expect breaking changes.
Requires Node 18+ or a modern browser (uses fetch and EventSource/streams).
Quick start
import { PpxClient, consentRedirectUrl, generatePkce } from "@blazing-customs/ppx-client";
const ppx = new PpxClient({ baseUrl: "https://api.provider.app" });
// 1. Request a scoped grant. PKCE is mandatory and S256-only; keep the
// verifier in sessionStorage (tab-scoped) — it is sent only in step 3.
const pkce = await generatePkce();
const { grant_request_id, request_token } = await ppx.requestGrant({
client_id: "my-app",
subject_id: "did:example:user-123",
purposes: ["recommendation"],
allowed_domains: ["fragrance"],
allowed_namespaces: ["fragrance"],
allowed_operations: ["read"],
cross_domain_transfer: "deny",
writeback_policy: "review_required",
requested_duration_days: 30,
code_challenge: pkce.challenge,
code_challenge_method: "S256",
// Matched EXACTLY against what you registered. No longer a query parameter
// on the consent URL, so it cannot be rewritten in transit.
redirect_uri: window.location.origin + "/callback",
});
// `request_token` is the capability, returned ONCE. Stash it with the verifier
// for the round trip; never put either in a URL.
sessionStorage.setItem(
"ppx.pending",
JSON.stringify({ grant_request_id, request_token, verifier: pkce.verifier }),
);
// 2. Send the user to the provider's consent screen. This URL is safe to treat
// as public: the id in it exchanges for nothing on its own.
window.location.href = consentRedirectUrl("https://app.provider.app", grant_request_id);
// 3. In your callback page, exchange. The id alone is NOT enough.
const pending = JSON.parse(sessionStorage.getItem("ppx.pending")!);
sessionStorage.removeItem("ppx.pending"); // one shot
const tok = await ppx.mintToken(
pending.grant_request_id,
pending.request_token,
pending.verifier,
"my-app",
);
// 4. Use the token to read scoped claims
const profile = await ppx.effectiveProfile(
{ context: { climate: "hot_humid" }, requested_namespaces: ["fragrance"] },
tok.access_token,
);Grant re-use (skip the consent flow on return visits)
// Cache the grant_id in localStorage on first approval
localStorage.setItem("my-app.grant", tok.grant_id);
// On return visits, re-mint without any user interaction
const cached = localStorage.getItem("my-app.grant");
if (cached) {
try {
const fresh = await ppx.refresh("my-app", cached);
// use fresh.access_token
} catch (err) {
if (err instanceof PpxGrantRevokedError) {
localStorage.removeItem("my-app.grant"); // → fall back to full flow
} else throw err;
}
}Live consent events (AG-UI)
The provider streams AG-UI events during consent review. Subscribe to render explanation text, state snapshots, and interrupts live:
const unsub = ppx.onConsentEvents(grant_request_id, {
STATE_SNAPSHOT: (e) => render(e.data),
TEXT_MESSAGE_CONTENT: (e) => appendExplanation(e.delta),
INTERRUPT: (e) => showApproveReject(e.data),
TOOL_CALL_RESULT: (e) => (grantUrn = e.result.grant_urn),
});
// later
unsub();Surface reference
The client wraps every v1 endpoint on the reference provider.
| Method | Endpoint | Auth |
| --- | --- | --- |
| discoveryCard() | GET /.well-known/ppx-card.json | none |
| grantJwks() | GET /.well-known/ppx-grant-jwks.json | none |
| requestGrant(…) | POST /v1/consent/request | none |
| getGrantRequest(id) | GET /v1/consent/request/{id} | none |
| mintToken(id, requestToken, verifier, clientId, clientSecret?) | POST /v1/consent/token | none |
| refresh(client, grant) | POST /v1/consent/refresh | none |
| listGrants() | GET /v1/consent/grants | user |
| revokeGrant(id) | POST /v1/consent/revoke | user |
| auditEvents(…) | GET /v1/audit/events | user |
| myProfile() | GET /v1/profile/export | user |
| profileSummary() | GET /v1/profile/summary | grant |
| queryClaims(…) | POST /v1/profile/query | grant |
| effectiveProfile(…) | POST /v1/profile/effective | grant |
| proposeUpdates(…) | POST /v1/profile/propose-updates | grant |
| listExtensions() | GET /v1/extensions | none |
| onConsentEvents(id, h) | GET /v1/ag-ui/consent/{id} (SSE) | none |
Errors
All non-2xx responses throw PpxError with .status, .body, .url.
410 Gone throws the subclass PpxGrantRevokedError — the idiomatic
signal to clear a cached grant_id and prompt for re-approval.
License
Apache-2.0.
