From 2eea3c92c69935637ac062c213c976e8d932f521 Mon Sep 17 00:00:00 2001 From: dzdidi Date: Tue, 1 Sep 2026 10:30:24 -0300 Subject: [PATCH 01/13] feat(sdk): add public Paykit data discovery - add Rust probe for public Paykit v0 namespace data - expose static JS/WASM discovery methods with custom relay support - distinguish invalid user input from operational lookup failures - derive Paykit namespace from pinned paykit-lib at build time - keep Paykit crypto dependencies outside the WASM runtime graph - pin compatible pubky-noise rc7 build dependency - raise workspace MSRV and build toolchain to Rust 1.91.1 - document data-presence semantics and explicit non-claims - cover Rust, generated API, WASM, and failure behavior Signed-off-by: dzdidi --- .github/workflows/check.yml | 2 +- .github/workflows/security.yml | 2 +- CONTRIBUTING.md | 2 +- Cargo.lock | 212 +++++++++++++++++- Cargo.toml | 4 +- Dockerfile | 4 +- README.md | 2 +- docker/pubky-testnet.Dockerfile | 2 +- docs/SDK.md | 22 ++ .../js-sdk/scripts/smoke-paykit-compose.mjs | 2 +- locks-sdk/Cargo.toml | 5 + locks-sdk/bindings/js/Cargo.toml | 1 + .../js/scripts/smoke-generated-api.mjs | 32 +++ locks-sdk/bindings/js/src/js_error.rs | 5 + locks-sdk/bindings/js/src/locks.rs | 73 ++++++ locks-sdk/build.rs | 6 + locks-sdk/src/lib.rs | 2 + locks-sdk/src/paykit.rs | 113 ++++++++++ locks-sdk/tests/public_api.rs | 1 + rust-toolchain.toml | 2 +- 20 files changed, 480 insertions(+), 14 deletions(-) create mode 100644 locks-sdk/build.rs create mode 100644 locks-sdk/src/paykit.rs diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml index 6bf189d..6409f77 100644 --- a/.github/workflows/check.yml +++ b/.github/workflows/check.yml @@ -49,7 +49,7 @@ jobs: sudo apt-get install -y --no-install-recommends pkg-config libssl-dev - name: Install Rust toolchain - uses: dtolnay/rust-toolchain@1.89.0 + uses: dtolnay/rust-toolchain@1.91.1 with: components: clippy,rustfmt targets: wasm32-unknown-unknown diff --git a/.github/workflows/security.yml b/.github/workflows/security.yml index 30cfa1e..d59a7c7 100644 --- a/.github/workflows/security.yml +++ b/.github/workflows/security.yml @@ -26,7 +26,7 @@ jobs: uses: actions/checkout@v5 - name: Install Rust toolchain - uses: dtolnay/rust-toolchain@1.89.0 + uses: dtolnay/rust-toolchain@1.91.1 - name: Install cargo-audit run: cargo install cargo-audit --version 0.22.2 --locked diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index cc1ee1d..1426cc1 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -5,7 +5,7 @@ maintainers may change APIs and persistence contracts without backwards compatib ## Development prerequisites -- Rust 1.89.0 with `rustfmt`, `clippy`, and the `wasm32-unknown-unknown` target +- Rust 1.91.1 with `rustfmt`, `clippy`, and the `wasm32-unknown-unknown` target - PostgreSQL 16 for persistence and E2E tests - Node.js 22 and npm - `cargo-nextest` diff --git a/Cargo.lock b/Cargo.lock index 8447547..a73a99f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -12,6 +12,31 @@ dependencies = [ "generic-array", ] +[[package]] +name = "aes" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures 0.2.17", +] + +[[package]] +name = "aes-gcm" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "831010a0f742e1209b3bcea8fab6a8e149051ba6099432c8cb2cc117dec3ead1" +dependencies = [ + "aead", + "aes", + "cipher", + "ctr", + "ghash", + "subtle", +] + [[package]] name = "aho-corasick" version = "1.1.4" @@ -386,6 +411,15 @@ dependencies = [ "zeroize", ] +[[package]] +name = "chrono" +version = "0.4.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" +dependencies = [ + "num-traits", +] + [[package]] name = "cipher" version = "0.4.4" @@ -633,6 +667,29 @@ dependencies = [ "zeroize", ] +[[package]] +name = "ctr" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0369ee1ad671834580515889b80f2ea915f23b8be8d0daa4bbaf2ac5c7590835" +dependencies = [ + "cipher", +] + +[[package]] +name = "curve25519-dalek" +version = "4.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "curve25519-dalek-derive", + "fiat-crypto 0.2.9", + "rustc_version", + "subtle", +] + [[package]] name = "curve25519-dalek" version = "5.0.0" @@ -643,7 +700,7 @@ dependencies = [ "cpufeatures 0.3.0", "curve25519-dalek-derive", "digest 0.11.3", - "fiat-crypto", + "fiat-crypto 0.3.0", "rustc_version", "subtle", "zeroize", @@ -715,6 +772,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" dependencies = [ "block-buffer 0.12.1", + "const-oid 0.10.2", "crypto-common 0.2.2", ] @@ -782,7 +840,7 @@ version = "3.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6ebaa1a2bf1290ab3bfe5a7b771d050ebffab2711c19a81691c683a5144a25de" dependencies = [ - "curve25519-dalek", + "curve25519-dalek 5.0.0", "ed25519", "serde", "sha2 0.11.0", @@ -866,6 +924,12 @@ version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" +[[package]] +name = "fiat-crypto" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" + [[package]] name = "fiat-crypto" version = "0.3.0" @@ -1085,6 +1149,18 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi 5.3.0", + "wasip2", +] + [[package]] name = "getrandom" version = "0.4.3" @@ -1094,11 +1170,21 @@ dependencies = [ "cfg-if", "js-sys", "libc", - "r-efi", + "r-efi 6.0.0", "rand_core 0.10.1", "wasm-bindgen", ] +[[package]] +name = "ghash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0d8a4362ccb29cb0b265253fb0a2728f592895ee6854fd9bc13f2ffda266ff1" +dependencies = [ + "opaque-debug", + "polyval", +] + [[package]] name = "hash32" version = "0.2.1" @@ -1665,8 +1751,11 @@ name = "locks-sdk" version = "0.1.0-rc2" dependencies = [ "locks-core", + "paykit-lib", "percent-encoding", + "pubky", "pubky-common", + "pubky-noise", "serde", "serde_json", "thiserror", @@ -1683,6 +1772,7 @@ dependencies = [ "locks-core", "locks-sdk", "pkarr", + "pubky", "reqwest", "serde", "serde-wasm-bindgen", @@ -2040,6 +2130,25 @@ dependencies = [ "subtle", ] +[[package]] +name = "paykit-lib" +version = "0.1.0-rc48" +source = "git+https://github.com/pubky/paykit-rs.git?tag=v0.1.0-rc48#9b56a0eacd6874137370fa79ec0f40b809140809" +dependencies = [ + "anyhow", + "base64", + "chacha20poly1305", + "chrono", + "pubky", + "pubky-noise", + "serde", + "serde_json", + "thiserror", + "tracing", + "uuid", + "zeroize", +] + [[package]] name = "pem-rfc7468" version = "0.7.0" @@ -2194,6 +2303,18 @@ dependencies = [ "universal-hash", ] +[[package]] +name = "polyval" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d1fe60d06143b2430aa532c94cfe9e29783047f06c0d7fd359a9a51b729fa25" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "opaque-debug", + "universal-hash", +] + [[package]] name = "postcard" version = "1.1.3" @@ -2299,6 +2420,22 @@ dependencies = [ "url", ] +[[package]] +name = "pubky-noise" +version = "0.1.0-rc7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a0fcffa2792342caf099107604fe1bd4c99a5f92fd3584306b678cc0ad744da" +dependencies = [ + "curve25519-dalek 5.0.0", + "ed25519-dalek", + "getrandom 0.3.4", + "hex", + "pubky", + "rand 0.9.5", + "sha2 0.11.0", + "snow", +] + [[package]] name = "pubky-timestamp" version = "0.4.1" @@ -2396,6 +2533,12 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + [[package]] name = "r-efi" version = "6.0.0" @@ -2409,10 +2552,20 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "22f6172bdec972074665ed81ed53b71da00bfc44b65a753cfde883ec4c702a1a" dependencies = [ "libc", - "rand_chacha", + "rand_chacha 0.3.1", "rand_core 0.6.4", ] +[[package]] +name = "rand" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41" +dependencies = [ + "rand_chacha 0.9.0", + "rand_core 0.9.5", +] + [[package]] name = "rand" version = "0.10.2" @@ -2434,6 +2587,16 @@ dependencies = [ "rand_core 0.6.4", ] +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core 0.9.5", +] + [[package]] name = "rand_core" version = "0.6.4" @@ -2443,6 +2606,15 @@ dependencies = [ "getrandom 0.2.17", ] +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + [[package]] name = "rand_core" version = "0.10.1" @@ -3019,6 +3191,23 @@ dependencies = [ "serde", ] +[[package]] +name = "snow" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "599b506ccc4aff8cf7844bc42cf783009a434c1e26c964432560fb6d6ad02d82" +dependencies = [ + "aes-gcm", + "blake2", + "chacha20poly1305", + "curve25519-dalek 4.1.3", + "getrandom 0.3.4", + "ring", + "rustc_version", + "sha2 0.10.9", + "subtle", +] + [[package]] name = "socket2" version = "0.6.5" @@ -3784,6 +3973,15 @@ version = "0.11.1+wasi-snapshot-preview1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" +[[package]] +name = "wasip2" +version = "1.0.4+wasi-0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" +dependencies = [ + "wit-bindgen", +] + [[package]] name = "wasite" version = "0.1.0" @@ -4157,6 +4355,12 @@ dependencies = [ "memchr", ] +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + [[package]] name = "writeable" version = "0.6.3" diff --git a/Cargo.toml b/Cargo.toml index 2062305..68f61e2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -14,7 +14,7 @@ edition = "2024" version = "0.1.0-rc2" license = "MIT" repository = "https://github.com/pubky/locks" -rust-version = "1.89.0" +rust-version = "1.91.1" [workspace.dependencies] anyhow = "1" @@ -28,8 +28,10 @@ clap = { version = "4", features = ["derive"] } locks-core = { path = "locks-core" } locks-service = { path = "locks-service" } mime = "0.3" +paykit-lib = { git = "https://github.com/pubky/paykit-rs.git", tag = "v0.1.0-rc48" } pubky = { version = "0.11.0", features = ["json"] } pubky-common = "0.11.0" +pubky-noise = "=0.1.0-rc7" qrcode = { version = "0.14", default-features = false, features = ["svg"] } pkarr = "8.0.0" percent-encoding = "2" diff --git a/Dockerfile b/Dockerfile index ec88e46..592a07b 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,8 +1,8 @@ # ======================== # Build Stage # ======================== -FROM rust:1.89.0-alpine3.20 AS builder -ENV RUSTUP_TOOLCHAIN=1.89.0 +FROM rust:1.91.1-alpine3.20 AS builder +ENV RUSTUP_TOOLCHAIN=1.91.1 RUN echo "TARGETARCH: $TARGETARCH" diff --git a/README.md b/README.md index 0246466..19b5600 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,7 @@ criteria and receive scoped access through a Lock Server. ## Build and verify -The repository uses Rust 1.89.0. CI also requires PostgreSQL 16, Node.js 22, +The repository uses Rust 1.91.1. CI also requires PostgreSQL 16, Node.js 22, `cargo-nextest`, the `wasm32-unknown-unknown` target, and `wasm-pack` 0.13.1. ```bash diff --git a/docker/pubky-testnet.Dockerfile b/docker/pubky-testnet.Dockerfile index 9819674..6389532 100644 --- a/docker/pubky-testnet.Dockerfile +++ b/docker/pubky-testnet.Dockerfile @@ -1,4 +1,4 @@ -FROM rust:1.89.0-bookworm AS builder +FROM rust:1.91.1-bookworm AS builder ARG PUBKY_CORE_REF=v0.11.0 WORKDIR /usr/src/pubky-core diff --git a/docs/SDK.md b/docs/SDK.md index a1a16ef..c7f44b4 100644 --- a/docs/SDK.md +++ b/docs/SDK.md @@ -332,6 +332,28 @@ Request body: } ``` +### Check public Paykit data presence + +```ts +const hasPaykitData = await Locks.hasPaykitData("pubky..."); + +const options = new LocksOptions(); +options.addPkarrRelay("http://127.0.0.1:15411"); +const hasLocalPaykitData = await Locks.hasPaykitDataWithOptions("pubky...", options); +``` + +These static methods perform an unauthenticated, uncached homeserver listing for the +specified user's current `/pub/paykit/v0/` namespace. They return `true` when at least +one syntactically valid child is present and `false` when the namespace is absent or +empty. Invalid user keys, malformed listings, resolution failures, and transport errors +reject the promise instead of returning `false`. + +Malformed user keys reject with `InvalidInput`. Operational lookup failures reject with +the coarse `PaykitDataLookupFailed` error name without exposing upstream details. + +This is a data-presence probe only. A `true` result does not prove a valid receiver +marker, supported capabilities, freshness, or Paykit runtime readiness. + ### Check Paykit setup readiness ```ts diff --git a/examples/js-sdk/scripts/smoke-paykit-compose.mjs b/examples/js-sdk/scripts/smoke-paykit-compose.mjs index cc2c2f4..8f02ab3 100644 --- a/examples/js-sdk/scripts/smoke-paykit-compose.mjs +++ b/examples/js-sdk/scripts/smoke-paykit-compose.mjs @@ -421,7 +421,7 @@ for (const required of ['FROM rust:1.91.1-slim-bookworm@sha256:8514999d4786ef12e assert.ok(jsDemoDockerfile.includes(required), `JS demo image missing ${required}`); } assert.ok( - locksServerDockerfile.includes('ENV RUSTUP_TOOLCHAIN=1.89.0'), + locksServerDockerfile.includes('ENV RUSTUP_TOOLCHAIN=1.91.1'), 'Lock Server image must use the toolchain already installed in its builder image', ); for (const required of [ diff --git a/locks-sdk/Cargo.toml b/locks-sdk/Cargo.toml index d7af26e..a7b8ad4 100644 --- a/locks-sdk/Cargo.toml +++ b/locks-sdk/Cargo.toml @@ -10,9 +10,14 @@ description = "Client SDK for Pubky Locks creator and viewer flows" [dependencies] locks-core.workspace = true percent-encoding.workspace = true +pubky.workspace = true pubky-common.workspace = true serde.workspace = true serde_json.workspace = true thiserror.workspace = true time.workspace = true url.workspace = true + +[build-dependencies] +paykit-lib.workspace = true +pubky-noise.workspace = true diff --git a/locks-sdk/bindings/js/Cargo.toml b/locks-sdk/bindings/js/Cargo.toml index 4f0b6a1..6d40f7c 100644 --- a/locks-sdk/bindings/js/Cargo.toml +++ b/locks-sdk/bindings/js/Cargo.toml @@ -14,6 +14,7 @@ crate-type = ["cdylib", "rlib"] [dependencies] locks-core.workspace = true locks-sdk = { path = "../.." } +pubky.workspace = true serde.workspace = true serde_json.workspace = true serde-wasm-bindgen = "0.6" diff --git a/locks-sdk/bindings/js/scripts/smoke-generated-api.mjs b/locks-sdk/bindings/js/scripts/smoke-generated-api.mjs index f465e4b..87562b5 100644 --- a/locks-sdk/bindings/js/scripts/smoke-generated-api.mjs +++ b/locks-sdk/bindings/js/scripts/smoke-generated-api.mjs @@ -1,3 +1,4 @@ +import assert from 'node:assert/strict'; import { existsSync, readFileSync } from 'node:fs'; import { readFile } from 'node:fs/promises'; import { join } from 'node:path'; @@ -41,6 +42,8 @@ const requiredSnippets = [ 'static forContentLockWithOptions(resource: string, options: LocksOptions): Promise;', 'static readContentLock(resource: string): Promise;', 'static readContentLockWithOptions(resource: string, options: LocksOptions): Promise;', + 'static hasPaykitData(user: string): Promise;', + 'static hasPaykitDataWithOptions(user: string, options: LocksOptions): Promise;', 'export class LocksOptions', 'constructor();', 'addPkarrRelay(relay_url: string): LocksOptions;', @@ -102,6 +105,35 @@ if (typeof sdk.Creator.prototype.paykitSetupStatus !== 'function') { if (sdk.Creator.prototype.paykitSetupStatus.length !== 0) { throw new Error('paykitSetupStatus must not accept a caller-supplied Creator'); } +if (typeof sdk.Locks.hasPaykitData !== 'function') { + throw new Error('generated Locks missing hasPaykitData'); +} +if (typeof sdk.Locks.hasPaykitDataWithOptions !== 'function') { + throw new Error('generated Locks missing hasPaykitDataWithOptions'); +} +await assert.rejects( + () => sdk.Locks.hasPaykitData('not-a-pubky'), + /invalid user pubky/, +); +await assert.rejects( + () => sdk.Locks.hasPaykitDataWithOptions('not-a-pubky', new sdk.LocksOptions()), + /invalid user pubky/, +); +const unavailableOptions = new sdk.LocksOptions(); +unavailableOptions.addPkarrRelay('http://127.0.0.1:1'); +await Promise.race([ + assert.rejects( + () => sdk.Locks.hasPaykitDataWithOptions( + 'pubky7ir1ttte48bcp4zjychjyscicrwi1j34mtt91ptsafdbjmr8g9eo', + unavailableOptions, + ), + (error) => error?.name === 'PaykitDataLookupFailed' + && error?.message === 'Paykit data lookup failed', + ), + new Promise((_, reject) => { + setTimeout(() => reject(new Error('Paykit data failure smoke timed out')), 10_000); + }), +]); const primaryResource = { path: '/priv/locks.app/content/primary.txt', diff --git a/locks-sdk/bindings/js/src/js_error.rs b/locks-sdk/bindings/js/src/js_error.rs index d0a5286..f6fad56 100644 --- a/locks-sdk/bindings/js/src/js_error.rs +++ b/locks-sdk/bindings/js/src/js_error.rs @@ -6,6 +6,11 @@ pub fn invalid_input(message: impl AsRef) -> JsValue { js_sys_error("InvalidInput", message.as_ref()) } +#[cfg(target_arch = "wasm32")] +pub fn paykit_data_lookup_failed() -> JsValue { + js_sys_error("PaykitDataLookupFailed", "Paykit data lookup failed") +} + fn js_sys_error(name: &str, message: &str) -> JsValue { #[cfg(target_arch = "wasm32")] { diff --git a/locks-sdk/bindings/js/src/locks.rs b/locks-sdk/bindings/js/src/locks.rs index 4c3f7b2..80ee5cb 100644 --- a/locks-sdk/bindings/js/src/locks.rs +++ b/locks-sdk/bindings/js/src/locks.rs @@ -3,11 +3,15 @@ use std::str::FromStr; use locks_core::ids::LockServerPubky; #[cfg(any(test, target_arch = "wasm32"))] use locks_core::ids::{CreatorPubky, PubkyLockResource}; +#[cfg(any(test, target_arch = "wasm32"))] +use pubky::{Pubky, PubkyHttpClient, PublicKey, PublicStorage}; use serde_json::Value; #[cfg(target_arch = "wasm32")] use wasm_bindgen::JsCast; use wasm_bindgen::prelude::*; +#[cfg(target_arch = "wasm32")] +use crate::js_error::paykit_data_lookup_failed; use crate::js_error::{JsResult, invalid_input}; #[cfg(target_arch = "wasm32")] use crate::json::serializable_to_plain_js_value; @@ -257,6 +261,25 @@ impl Locks { .map_err(|err| invalid_input(format!("failed to serialize content lock: {err:?}"))) } + #[cfg(target_arch = "wasm32")] + #[wasm_bindgen(js_name = hasPaykitData)] + pub async fn has_paykit_data(user: &str) -> JsResult { + Self::has_paykit_data_with_options(user, &LocksOptions::new()).await + } + + #[cfg(target_arch = "wasm32")] + #[wasm_bindgen(js_name = hasPaykitDataWithOptions)] + pub async fn has_paykit_data_with_options( + user: &str, + options: &LocksOptions, + ) -> JsResult { + let user = parse_paykit_user(user).map_err(invalid_input)?; + let storage = paykit_public_storage(options).map_err(|_| paykit_data_lookup_failed())?; + locks_sdk::has_paykit_data(&storage, &user) + .await + .map_err(|_| paykit_data_lookup_failed()) + } + #[cfg(target_arch = "wasm32")] #[wasm_bindgen(js_name = forContentLock)] pub async fn for_content_lock(resource: &str) -> JsResult { @@ -353,6 +376,36 @@ impl Locks { } } +#[cfg(any(test, target_arch = "wasm32"))] +fn parse_paykit_user(user: &str) -> Result { + if !user.starts_with("pubky") { + return Err("invalid user pubky".to_owned()); + } + let canonical = CreatorPubky::from_str(user) + .map_err(|err| format!("invalid user pubky: {err}"))? + .to_string(); + let raw = canonical + .strip_prefix("pubky") + .ok_or_else(|| "invalid user pubky".to_owned())?; + PublicKey::try_from_z32(raw).map_err(|err| format!("invalid user pubky: {err}")) +} + +#[cfg(any(test, target_arch = "wasm32"))] +fn paykit_public_storage(options: &LocksOptions) -> Result { + let mut builder = PubkyHttpClient::builder(); + if !options.pkarr_relay_urls().is_empty() { + builder.pkarr(|pkarr| { + pkarr + .relays(options.pkarr_relay_urls()) + .expect("LocksOptions stores validated PKARR relay URLs") + }); + } + let client = builder + .build() + .map_err(|err| format!("failed to build Pubky client: {err}"))?; + Ok(Pubky::with_client(client).public_storage()) +} + impl Locks { fn from_creator_lock_service_pointer_value( value: Value, @@ -816,6 +869,26 @@ mod tests { assert!(options.try_add_pkarr_relay("not a url".to_owned()).is_err()); } + #[test] + fn paykit_data_user_requires_canonical_pubky_key() { + let canonical = "pubky7ir1ttte48bcp4zjychjyscicrwi1j34mtt91ptsafdbjmr8g9eo"; + + assert_eq!(parse_paykit_user(canonical).unwrap().z32(), &canonical[5..]); + assert!(parse_paykit_user(&canonical[5..]).is_err()); + assert!(parse_paykit_user("not-a-pubky").is_err()); + } + + #[test] + fn paykit_data_storage_accepts_default_and_custom_relay_options() { + assert!(paykit_public_storage(&LocksOptions::new()).is_ok()); + + let mut options = LocksOptions::new(); + options + .add_pkarr_relay("http://localhost:15411".to_owned()) + .unwrap(); + assert!(paykit_public_storage(&options).is_ok()); + } + #[test] fn locks_for_server_retains_pkarr_relay_options_for_restored_sessions() { let mut options = LocksOptions::new(); diff --git a/locks-sdk/build.rs b/locks-sdk/build.rs new file mode 100644 index 0000000..b625dd3 --- /dev/null +++ b/locks-sdk/build.rs @@ -0,0 +1,6 @@ +fn main() { + println!( + "cargo:rustc-env=LOCKS_PAYKIT_PATH_PREFIX={}", + paykit_lib::PAYKIT_PATH_PREFIX + ); +} diff --git a/locks-sdk/src/lib.rs b/locks-sdk/src/lib.rs index a438270..075fed2 100644 --- a/locks-sdk/src/lib.rs +++ b/locks-sdk/src/lib.rs @@ -2,6 +2,7 @@ pub mod client; pub mod creator; pub mod discovery; pub mod error; +pub mod paykit; pub mod session; pub mod transport; pub mod viewer; @@ -17,6 +18,7 @@ pub use discovery::{ creator_lock_service_pointer_url, lock_server_for_content_lock, validate_content_lock_value, }; pub use error::{LocksSdkError, Result}; +pub use paykit::has_paykit_data; pub use session::LocksSession; pub use viewer::{ AccessCredentialResponse, ReadLockedResourceRequest, SdkViewerRequest, diff --git a/locks-sdk/src/paykit.rs b/locks-sdk/src/paykit.rs new file mode 100644 index 0000000..1c47ff8 --- /dev/null +++ b/locks-sdk/src/paykit.rs @@ -0,0 +1,113 @@ +use pubky::{PubkyResource, PublicKey, PublicStorage, StatusCode, errors::RequestError}; + +const PAYKIT_PATH_PREFIX: &str = env!("LOCKS_PAYKIT_PATH_PREFIX"); + +/// Return whether a user has any public child under the current Paykit v0 namespace. +/// +/// This reports data presence only. It does not prove that a Paykit receiver is valid, +/// current, capable, or ready. +pub async fn has_paykit_data(storage: &PublicStorage, user: &PublicKey) -> pubky::Result { + let listing = storage + .list(paykit_directory(user))? + .shallow(true) + .limit(1) + .send() + .await; + classify_listing(listing) +} + +fn paykit_directory(user: &PublicKey) -> String { + format!("pubky://{}{PAYKIT_PATH_PREFIX}/", user.z32()) +} + +fn classify_listing(listing: pubky::Result>) -> pubky::Result { + match listing { + Ok(entries) => Ok(!entries.is_empty()), + Err(pubky::Error::Request(RequestError::Server { status, .. })) + if status == StatusCode::NOT_FOUND => + { + Ok(false) + } + Err(error) => Err(error), + } +} + +#[cfg(test)] +mod tests { + use pubky::{Keypair, PubkyResource, StatusCode, errors::RequestError}; + + use super::{PAYKIT_PATH_PREFIX, classify_listing, paykit_directory}; + + fn user() -> pubky::PublicKey { + Keypair::from_secret(&[7; 32]).public_key() + } + + fn resource(path: &str) -> PubkyResource { + format!("pubky://{}{path}", user().z32()).parse().unwrap() + } + + #[test] + fn paykit_directory_uses_canonical_v0_prefix() { + assert_eq!(PAYKIT_PATH_PREFIX, "/pub/paykit/v0"); + assert_eq!( + paykit_directory(&user()), + format!("pubky://{}{PAYKIT_PATH_PREFIX}/", user().z32()) + ); + } + + #[test] + fn empty_listing_means_no_paykit_data() { + assert!(!classify_listing(Ok(Vec::new())).unwrap()); + } + + #[test] + fn any_valid_child_means_paykit_data() { + assert!( + classify_listing(Ok(vec![resource( + "/pub/paykit/v0/unknown/future-record.bin" + )])) + .unwrap() + ); + } + + #[test] + fn absent_namespace_means_no_paykit_data() { + assert!( + !classify_listing(Err(pubky::Error::Request(RequestError::Server { + status: StatusCode::NOT_FOUND, + message: "not found".to_owned(), + }))) + .unwrap() + ); + } + + #[test] + fn non_absence_failures_remain_errors() { + let error = classify_listing(Err(pubky::Error::Request(RequestError::Server { + status: StatusCode::SERVICE_UNAVAILABLE, + message: "unavailable".to_owned(), + }))) + .unwrap_err(); + + assert!(matches!( + error, + pubky::Error::Request(RequestError::Server { + status: StatusCode::SERVICE_UNAVAILABLE, + .. + }) + )); + } + + #[test] + fn malformed_listing_remains_error() { + let error = classify_listing(Err(pubky::Error::Request(RequestError::Validation { + message: "malformed listing entry".to_owned(), + }))) + .unwrap_err(); + + assert!(matches!( + error, + pubky::Error::Request(RequestError::Validation { .. }) + )); + } +} diff --git a/locks-sdk/tests/public_api.rs b/locks-sdk/tests/public_api.rs index 5b9c580..8e3d83d 100644 --- a/locks-sdk/tests/public_api.rs +++ b/locks-sdk/tests/public_api.rs @@ -110,6 +110,7 @@ fn crate_root_exports_foundation_sdk_types() { &locks_core::lock_policy::ContentLock, Option<&CreatorLockServicePointer>, ) -> locks_sdk::Result = lock_server_for_content_lock; + let _paykit_data_probe = locks_sdk::has_paykit_data; assert_eq!(LocksSession::new("another").export_secret(), "another"); } diff --git a/rust-toolchain.toml b/rust-toolchain.toml index 68d5dc2..0c664f6 100644 --- a/rust-toolchain.toml +++ b/rust-toolchain.toml @@ -1,5 +1,5 @@ [toolchain] -channel = "1.89.0" +channel = "1.91.1" components = ["clippy", "rustfmt"] targets = ["wasm32-unknown-unknown"] profile = "minimal" From 8c5e39b048a2d323e58504642062609c9cc452e5 Mon Sep 17 00:00:00 2001 From: dzdidi Date: Wed, 2 Sep 2026 12:42:02 -0300 Subject: [PATCH 02/13] staging demo Signed-off-by: dzdidi --- README.md | 11 +- compose.paykit-staging-demo.yaml | 87 ++++++++ docker/js-staging-demo.Dockerfile | 25 +++ docs/PAYKIT_STAGING_DEMO.md | 116 +++++++++++ examples/js-sdk/README.md | 10 + examples/js-sdk/app-iframe.js | 61 ++++-- examples/js-sdk/creator-identity.js | 14 ++ examples/js-sdk/demo-network.js | 22 ++ examples/js-sdk/package.json | 12 +- examples/js-sdk/reader-app.js | 135 ++++++++++-- examples/js-sdk/reader-flow.js | 5 + examples/js-sdk/reader-persistence.js | 21 ++ examples/js-sdk/reader-staging-paykit.js | 90 ++++++++ examples/js-sdk/reader.html | 6 +- .../js-sdk/scripts/init-staging-config.mjs | 11 + examples/js-sdk/scripts/lib/config.mjs | 29 ++- .../scripts/lib/creator-session-state.mjs | 72 ++++++- examples/js-sdk/scripts/lib/demo-runtime.mjs | 20 ++ examples/js-sdk/scripts/lib/paths.mjs | 5 + examples/js-sdk/scripts/lib/pubky.mjs | 5 +- .../js-sdk/scripts/lib/staging-config.mjs | 132 ++++++++++++ .../reset-paykit-staging-demo-local.mjs | 53 +++++ examples/js-sdk/scripts/start-demo-server.mjs | 100 +++++---- .../scripts/start-reader-demo-server.mjs | 31 ++- .../scripts/test-creator-session-state.mjs | 89 ++++++++ .../scripts/test-reader-persistence.mjs | 48 +++++ .../test-reset-paykit-staging-demo-local.mjs | 58 ++++++ .../js-sdk/scripts/test-staging-compose.mjs | 119 +++++++++++ .../js-sdk/scripts/test-staging-config.mjs | 146 +++++++++++++ .../scripts/test-staging-creator-mode.mjs | 100 +++++++++ .../scripts/test-staging-reader-mode.mjs | 128 ++++++++++++ .../scripts/validate-staging-compose.mjs | 193 ++++++++++++++++++ .../bindings/js/scripts/smoke-examples.mjs | 48 ++++- 33 files changed, 1888 insertions(+), 114 deletions(-) create mode 100644 compose.paykit-staging-demo.yaml create mode 100644 docker/js-staging-demo.Dockerfile create mode 100644 docs/PAYKIT_STAGING_DEMO.md create mode 100644 examples/js-sdk/demo-network.js create mode 100644 examples/js-sdk/reader-persistence.js create mode 100644 examples/js-sdk/reader-staging-paykit.js create mode 100644 examples/js-sdk/scripts/init-staging-config.mjs create mode 100644 examples/js-sdk/scripts/lib/demo-runtime.mjs create mode 100644 examples/js-sdk/scripts/lib/staging-config.mjs create mode 100644 examples/js-sdk/scripts/reset-paykit-staging-demo-local.mjs create mode 100644 examples/js-sdk/scripts/test-creator-session-state.mjs create mode 100644 examples/js-sdk/scripts/test-reader-persistence.mjs create mode 100644 examples/js-sdk/scripts/test-reset-paykit-staging-demo-local.mjs create mode 100644 examples/js-sdk/scripts/test-staging-compose.mjs create mode 100644 examples/js-sdk/scripts/test-staging-config.mjs create mode 100644 examples/js-sdk/scripts/test-staging-creator-mode.mjs create mode 100644 examples/js-sdk/scripts/test-staging-reader-mode.mjs create mode 100644 examples/js-sdk/scripts/validate-staging-compose.mjs diff --git a/README.md b/README.md index 19b5600..c06e936 100644 --- a/README.md +++ b/README.md @@ -69,7 +69,15 @@ docker compose --file compose.paykit-local-demo.yaml up -d --build 3. In production, use the production Bitkit QR/deep-link path presented by Paykit. When using the local CLI authentication fallback, run `npm --prefix examples/js-sdk ...` commands from the repository host. Do not wrap `authenticate` or `authenticate-paykit` in `docker compose exec`; those wrappers load private role state on the host and bridge only the bounded native-helper request into the demo container. The helper is supplied only by the Paykit local-demo image/runtime stage, not the normal production package/runtime. Follow the manual bearer-URL log retrieval and retention guidance in the example README. -Its external build contexts use anonymously reachable public repositories selected by immutable version tags; no sibling Paykit or Pubky checkout is required. Pubky Testnet is built from the `pubky/pubky-core` `v0.11.0` tag, Paykit libraries use the `v0.1.0-rc48` tag, Paykit Server uses `v0.1.0-rc2`, and Paykit's compatible Locks context uses `v0.1.0-rc1`. The local Paykit Server worktree override remains available through `PAYKIT_SERVER_CONTEXT`. The full Paykit demo adds Paykit Server at . The reader remains at in every local flow. Payment remains a manual operator action. +Its external build contexts use anonymously reachable public repositories selected by immutable version tags; no sibling Paykit or Pubky checkout is required. Pubky Testnet is built from the public `pubky/pubky-core` `v0.11.0` tag, Paykit libraries use the `v0.1.0-rc48` tag, Paykit Server uses `v0.1.0-rc2`, and Paykit's compatible Locks context uses `v0.1.0-rc1`. The local Paykit Server worktree override remains available through `PAYKIT_SERVER_CONTEXT`. The full Paykit demo adds Paykit Server at . The reader remains at in every local flow. Payment remains a manual operator action. + +For the helper-free loopback browser demo against deployed staging Locks and Paykit services: + +```bash +docker compose --file compose.paykit-staging-demo.yaml up -d --build +``` + +This staging-specific model uses fixed deployed origins, public Pubky SDK defaults, and two distinct external Bitkit identities. It does not run or reset remote services. See [Paykit staging browser demo](docs/PAYKIT_STAGING_DEMO.md). ## Documentation @@ -79,6 +87,7 @@ Its external build contexts use anonymously reachable public repositories select - [Domain model](docs/DOMAIN_MODEL.md) - [Terminology](docs/THESAURUS.md) - [Local operator demo](docs/LOCAL_OPERATOR_DEMO.md) +- [Paykit staging browser demo](docs/PAYKIT_STAGING_DEMO.md) - [Security policy](SECURITY.md) - [Support](SUPPORT.md) diff --git a/compose.paykit-staging-demo.yaml b/compose.paykit-staging-demo.yaml new file mode 100644 index 0000000..f3fd8b7 --- /dev/null +++ b/compose.paykit-staging-demo.yaml @@ -0,0 +1,87 @@ +# Local browser demos against deployed staging services only; no backend services run here. +name: pubky-locks-paykit-staging-demo + +x-demo-build: &demo-build + context: . + dockerfile: docker/js-staging-demo.Dockerfile + +services: + staging-config: + image: pubky-locks-paykit-staging-demo:local + build: *demo-build + user: "0:0" + working_dir: /workspace + command: + - /bin/sh + - -euc + - | + node examples/js-sdk/scripts/init-staging-config.mjs + mkdir -p /workspace/.local/paykit-staging-demo/creator-session + chown -R 1000:1000 /workspace/.local/paykit-staging-demo + chmod 0700 /workspace/.local/paykit-staging-demo/creator-session + volumes: + - ./.local/paykit-staging-demo:/workspace/.local/paykit-staging-demo + + creator-demo: + image: pubky-locks-paykit-staging-demo:local + build: *demo-build + user: "1000:1000" + working_dir: /workspace + depends_on: + staging-config: + condition: service_completed_successfully + environment: + LOCKS_DEMO_MODE: staging + LOCKS_DEMO_CONFIG_PATH: /workspace/.local/paykit-staging-demo/config/config.json + LOCKS_DEMO_CREATOR_SESSION_PATH: /workspace/.local/paykit-staging-demo/creator-session/content-creator-session.json + PUBKY_LOCK_DEBUG: "0" + ports: + - "127.0.0.1:8080:8080" + volumes: + - ./.local/paykit-staging-demo/config:/workspace/.local/paykit-staging-demo/config:ro + - ./.local/paykit-staging-demo/creator-session:/workspace/.local/paykit-staging-demo/creator-session + command: + - npm + - --prefix + - examples/js-sdk + - run + - start-server + - -- + - --external-wallet + - --staging + healthcheck: + test: ["CMD", "node", "-e", "fetch('http://127.0.0.1:8080/config.json').then((response) => { if (!response.ok) process.exit(1); }).catch(() => process.exit(1))"] + interval: 2s + timeout: 2s + retries: 30 + start_period: 5s + + reader-demo: + image: pubky-locks-paykit-staging-demo:local + build: *demo-build + user: "1000:1000" + working_dir: /workspace + depends_on: + staging-config: + condition: service_completed_successfully + environment: + LOCKS_DEMO_MODE: staging + LOCKS_DEMO_CONFIG_PATH: /workspace/.local/paykit-staging-demo/config/config.json + ports: + - "127.0.0.1:8088:8088" + volumes: + - ./.local/paykit-staging-demo/config:/workspace/.local/paykit-staging-demo/config:ro + command: + - npm + - --prefix + - examples/js-sdk + - run + - start-reader-server + - -- + - --staging + healthcheck: + test: ["CMD", "node", "-e", "fetch('http://127.0.0.1:8088/api/health').then((response) => { if (!response.ok) process.exit(1); }).catch(() => process.exit(1))"] + interval: 2s + timeout: 2s + retries: 30 + start_period: 5s diff --git a/docker/js-staging-demo.Dockerfile b/docker/js-staging-demo.Dockerfile new file mode 100644 index 0000000..804dc95 --- /dev/null +++ b/docker/js-staging-demo.Dockerfile @@ -0,0 +1,25 @@ +# syntax=docker/dockerfile:1.7@sha256:a57df69d0ea827fb7266491f2813635de6f17269be881f696fbfdf2d83dda33e +FROM rust:1.91.1-slim-bookworm@sha256:8514999d4786ef12efe89239e86b3d0a021b94b9d35108c8efe6c79ca7dc1a65 AS locks-sdk-wasm +ENV RUSTUP_TOOLCHAIN=1.91.1 +RUN apt-get update \ + && apt-get install -y --no-install-recommends build-essential ca-certificates libssl-dev pkg-config \ + && rm -rf /var/lib/apt/lists/* +RUN rustup target add wasm32-unknown-unknown \ + && cargo install wasm-pack --version 0.13.1 --locked +WORKDIR /workspace +COPY . . +RUN cd locks-sdk/bindings/js && wasm-pack build --target web --out-dir pkg + +FROM node:22-bookworm-slim@sha256:813a7480f28fdadac1f7f5c824bcdad435b5bc1322a5968bbbdef8d058f9dff4 +WORKDIR /workspace +RUN apt-get update \ + && apt-get install -y --no-install-recommends ca-certificates \ + && rm -rf /var/lib/apt/lists/* +COPY --chown=node:node examples/js-sdk/package.json examples/js-sdk/package-lock.json /workspace/examples/js-sdk/ +RUN npm --prefix examples/js-sdk ci --ignore-scripts \ + && npm cache clean --force +COPY --chown=node:node examples/js-sdk /workspace/examples/js-sdk +COPY --from=locks-sdk-wasm --chown=node:node /workspace/locks-sdk/bindings/js/pkg /workspace/locks-sdk/bindings/js/pkg +RUN mkdir -p /workspace/.local/paykit-staging-demo \ + && chown -R node:node /workspace +USER node:node diff --git a/docs/PAYKIT_STAGING_DEMO.md b/docs/PAYKIT_STAGING_DEMO.md new file mode 100644 index 0000000..ca22eaf --- /dev/null +++ b/docs/PAYKIT_STAGING_DEMO.md @@ -0,0 +1,116 @@ +# Paykit staging browser demo + +This demo runs only local Creator and reader browser apps. It uses deployed staging services: + +- Locks: `https://locks.staging.pubky.app` +- Paykit: `https://paykit.staging.pubky.app` + +It does not run or reset Locks, Paykit, PostgreSQL, Bitcoin, Fulcrum, Pubky testnet, or wallet-helper services. It uses the standard public Pubky network and SDK defaults. + +## Prerequisites + +- Docker Compose with BuildKit support. +- Two distinct Bitkit staging identities/devices connected to the regtest used by Paykit staging: + - Creator/payee Bitkit; + - reader/payer Bitkit. +- Reader Bitkit is funded and handles payment through its own staging/regtest flow. + +Do not use one Bitkit identity for both roles. + +## Start + +From repository root: + +```bash +docker compose --file compose.paykit-staging-demo.yaml up -d --build +``` + +No environment file or URL override is accepted. Startup: + +1. builds one helper-free JS/WASM image; +2. fetches and validates Locks `/.well-known/locks-server` over HTTPS; +3. writes public config under `.local/paykit-staging-demo/config/`; +4. starts Creator and reader apps only after config succeeds. + +Open: + +- Creator: +- Reader: + +Creator scoped Pubky session state persists under `.local/paykit-staging-demo/creator-session/` with owner-only permissions. Locks frontend session and pasted reader Pubky remain browser-memory only. + +## Walkthrough + +### Creator/payee device + +1. Open Creator app. +2. Authenticate the demo Creator with Creator Bitkit. +3. Authenticate to deployed Lock Server through hosted connect. +4. Select `paykit-payment`. +5. Complete Paykit setup with Creator Bitkit. +6. Publish guarded content and copy resulting content-lock resource. + +### Reader/payer device + +1. Ensure Paykit is enabled in separate reader Bitkit identity. +2. Copy reader Bitkit's canonical public Pubky. +3. Open reader app and load Creator's content-lock resource. +4. Paste reader Pubky. Reader and Creator identities must differ. +5. Click **Check Paykit data**. + - no data: submission remains blocked; enable Paykit in Bitkit, then retry; + - lookup unavailable: submission remains blocked; retry later; + - data present: submission is enabled, but usable receiver validation still occurs during invoice creation. +6. Submit payment proof bundle. +7. Receive and pay Payment Request in reader Bitkit. +8. Resume payment verification polling. +9. After Locks reports completion, issue access credential. +10. Read guarded content and verify expected bytes. + +Payment Request receipt and payment confirmation are intermediate milestones. Full E2E passes only after access credential issuance and successful guarded-content read. + +## Data-presence limitation + +`Locks.hasPaykitData` checks whether any child exists under reader's public Paykit v0 namespace. `true` does not prove marker validity, supported capabilities, freshness, wallet readiness, or payment success. Paykit invoice creation remains authoritative. + +## Reset local client state + +```bash +npm --prefix examples/js-sdk run reset-paykit-staging-demo-local +``` + +This stops only local staging-demo containers and removes only `.local/paykit-staging-demo/`. It does not call remote endpoints or reset deployed staging state. + +## Known external blocker + +At last verification, this secret-free probe returned `400 {"error":"invalid_request"}`: + +```bash +curl --silent --show-error --output /dev/null --write-out '%{http_code}\n' \ + 'https://paykit.staging.pubky.app/setup?return_to=http%3A%2F%2F127.0.0.1%3A8080&state=staging-demo-probe' +``` + +Deployment-policy changes are outside this branch. If Creator setup still fails, report: + +- exact source branch/commit or tree identity; +- failed stage; +- endpoint path; +- HTTP status and coarse public error; +- timestamp; +- reproducible secret-free command. + +Never include authorization URLs, bearer/session tokens, one-time codes, wallet material, private content, or remote configuration values. + +## Verification + +```bash +npm --prefix examples/js-sdk run test:staging-config +npm --prefix examples/js-sdk run test:staging-compose +npm --prefix examples/js-sdk run test:staging-creator-mode +npm --prefix examples/js-sdk run test:staging-reader-mode +npm --prefix examples/js-sdk run test:reset-paykit-staging-demo-local +npm --prefix examples/js-sdk run check +npm --prefix locks-sdk/bindings/js run smoke:examples +git diff --check +``` + +Image/runtime verification additionally requires Docker daemon access for the exact startup command. diff --git a/examples/js-sdk/README.md b/examples/js-sdk/README.md index 1bfe43a..0aedad5 100644 --- a/examples/js-sdk/README.md +++ b/examples/js-sdk/README.md @@ -256,6 +256,16 @@ npm --prefix examples/js-sdk run reset-paykit-demo Do not use `docker compose --file compose.paykit-local-demo.yaml down -v` unless you intentionally want to delete the persistent Lock Server identity volume. +### Helper-free staging services demo + +To run only the Creator and reader browser apps against fixed deployed staging services: + +```bash +docker compose --file compose.paykit-staging-demo.yaml up -d --build +``` + +This path uses two distinct external Bitkit staging identities, the standard public Pubky network, and no native Paykit helpers or local backend services. Creator and reader remain on ports 8080 and 8088. See [`docs/PAYKIT_STAGING_DEMO.md`](../../docs/PAYKIT_STAGING_DEMO.md) for the exact role split, pasted-reader-Pubky gate, local-client reset, known external setup blocker, and full acceptance milestones. + ### Direct npm server ```bash diff --git a/examples/js-sdk/app-iframe.js b/examples/js-sdk/app-iframe.js index de3d4a8..a42b415 100644 --- a/examples/js-sdk/app-iframe.js +++ b/examples/js-sdk/app-iframe.js @@ -7,7 +7,9 @@ import { startCreatorConnect, } from './creator-complete-flow.js'; import { + captureCreatorOperation, commitIdentityScopedCreatorSession, + creatorOperationMatches, invalidateIdentityScopedCreatorState, } from './creator-identity.js'; import { buildCreatorLockPolicy } from './creator-lock-policy.js'; @@ -16,6 +18,7 @@ import { buildPaykitSetupRequest, decidePaykitSetupReadiness, } from './paykit-setup.js'; +import { pkarrRelaysForDemoConfig } from './demo-network.js'; import init, { Locks } from '../../locks-sdk/bindings/js/pkg/locks_sdk_wasm.js'; // Shared creator-page iframe flow — direct postMessage delivery (ADR 0019). @@ -82,6 +85,8 @@ const el = { creatorResult: document.querySelector('#creator-result'), viewerResource: document.querySelector('#viewer-resource'), }; +let pointerOperationToken = null; +let publicationOperationToken = null; await init(); await bootstrap(); @@ -129,7 +134,7 @@ window.addEventListener('message', async (event) => { state: receivedState, expectedState: expectedConnectState, expectedCreatorPubky, - pkarrRelays: [state.config.testnet.pkarrRelay], + pkarrRelays: pkarrRelaysForDemoConfig(state.config), }); const commit = await commitIdentityScopedCreatorSession({ state, @@ -140,7 +145,7 @@ window.addEventListener('message', async (event) => { revokeSession: (staleSessionSecret) => signOutCreator({ lockServer: state.config.lockServer.pubky, sessionSecret: staleSessionSecret, - pkarrRelays: [state.config.testnet.pkarrRelay], + pkarrRelays: pkarrRelaysForDemoConfig(state.config), }), }); if (!commit.accepted) { @@ -277,7 +282,9 @@ function openPaykitSetupIframe(setupUrl) { title.style.cssText = 'margin:0;padding-right:40px;'; const description = document.createElement('p'); - description.textContent = 'Complete the Paykit instructions for the current creator. From the repository root, use this explicit Compose command:'; + description.textContent = state.config.mode === 'staging' + ? 'Complete setup for the current Creator with the Creator Bitkit identity. Use a different Bitkit identity for the reader.' + : 'Complete the Paykit instructions for the current creator. From the repository root, use this explicit Compose command:'; description.style.cssText = 'margin:0;'; const companionCommand = document.createElement('code'); @@ -291,7 +298,9 @@ function openPaykitSetupIframe(setupUrl) { frame.referrerPolicy = 'no-referrer'; frame.style.cssText = 'width:100%;height:min(520px,70vh);border:0;display:block;'; - card.append(closeBtn, title, description, companionCommand, frame); + card.append(closeBtn, title, description); + if (state.config.mode !== 'staging') card.append(companionCommand); + card.append(frame); overlay.append(card); document.body.append(overlay); state.paykitSetupFrame = frame; @@ -334,10 +343,10 @@ function showLockAuthComplete() { async function bootstrap() { state.config = await fetchJson('/config.json'); await postClientLog('info', 'bootstrap-config-loaded', { + mode: state.config.mode ?? 'local-testnet', lockServerPubky: state.config.lockServer.pubky, lockServerUrl: state.config.lockServer.url, - pkarrRelay: state.config.testnet.pkarrRelay, - httpRelay: state.config.testnet.httpRelay, + customPkarrRelays: pkarrRelaysForDemoConfig(state.config), callback: `${window.location.origin}/auth/lock-server/callback`, hasLockSession: Boolean(state.feLockSessionToken), }); @@ -372,13 +381,13 @@ el.startLockAuth.addEventListener('click', async () => { lockServerPubky: state.config.lockServer.pubky, returnTo, state: connectState, - pkarrRelays: [state.config.testnet.pkarrRelay], + pkarrRelays: pkarrRelaysForDemoConfig(state.config), }); const { connectUrl } = await startCreatorConnect({ lockServer: state.config.lockServer.pubky, returnTo, state: connectState, - pkarrRelays: [state.config.testnet.pkarrRelay], + pkarrRelays: pkarrRelaysForDemoConfig(state.config), }); // Opt into direct postMessage delivery and remember the origin we will accept messages from. const deliveryUrl = new URL(connectUrl); @@ -393,19 +402,25 @@ el.startLockAuth.addEventListener('click', async () => { }); el.configurePointer.addEventListener('click', async () => { + const operation = captureCreatorOperation(state); + const token = Symbol('configure-pointer'); + pointerOperationToken = token; try { - const sessionSecret = state.feLockSessionToken; await configureLockServicePointer({ lockServer: state.config.lockServer.pubky, - sessionSecret, - pkarrRelays: [state.config.testnet.pkarrRelay], + sessionSecret: operation.sessionSecret, + pkarrRelays: pkarrRelaysForDemoConfig(state.config), }); - localStorage.setItem(pointerConfiguredKey(state.creatorPubky), 'true'); + if (pointerOperationToken !== token || !creatorOperationMatches(state, operation)) return; + localStorage.setItem(pointerConfiguredKey(operation.creatorPubky), 'true'); el.publishingStatus.textContent = 'Lock Service Pointer configured. Upload a file to create locked content.'; el.publishingStatus.className = 'ok'; refreshPublishingState(); } catch (error) { + if (pointerOperationToken !== token || !creatorOperationMatches(state, operation)) return; showError(el.publishingStatus, error); + } finally { + if (pointerOperationToken === token) pointerOperationToken = null; } }); @@ -426,6 +441,9 @@ el.retryPaykitSetup.addEventListener('click', () => { el.lockedContentForm.addEventListener('submit', async (event) => { event.preventDefault(); + const operation = captureCreatorOperation(state); + const token = Symbol('publish-content-lock'); + publicationOperationToken = token; try { const primaryFile = el.primaryContentFile.files?.[0]; if (!primaryFile) throw new Error('select a primary file first'); @@ -434,27 +452,32 @@ el.lockedContentForm.addEventListener('submit', async (event) => { const secondaryFiles = Array.from(el.secondaryContentFiles.files ?? []); const resources = await buildResourcesFromFiles(primaryFile, secondaryFiles, filename); + if (publicationOperationToken !== token || !creatorOperationMatches(state, operation)) return; const { criteria, lockLogic } = buildCreatorLockPolicy({ lockType: el.lockType.value, criterionId: el.criterionId.value, devStaticSatisfied: el.criterionSatisfied.value === 'true', amountSats: el.paykitAmountSats.value, - recipientPubky: state.creatorPubky, + recipientPubky: operation.creatorPubky, paykitSetupComplete: state.paykitSetupComplete, }); const result = await publishLockedContent({ lockServer: state.config.lockServer.pubky, - sessionSecret: state.feLockSessionToken, + sessionSecret: operation.sessionSecret, resources, criteria, lockLogic, accessTtlSeconds: Number(el.accessTtl.value), - pkarrRelays: [state.config.testnet.pkarrRelay], + pkarrRelays: pkarrRelaysForDemoConfig(state.config), }); + if (publicationOperationToken !== token || !creatorOperationMatches(state, operation)) return; el.creatorResult.textContent = JSON.stringify(result, null, 2); el.viewerResource.textContent = result.contentLockResource; } catch (error) { + if (publicationOperationToken !== token || !creatorOperationMatches(state, operation)) return; showError(el.publishingStatus, error); + } finally { + if (publicationOperationToken === token) publicationOperationToken = null; } }); @@ -477,7 +500,7 @@ async function refreshDemoAuthStatus() { revokeSession: (sessionSecret) => signOutCreator({ lockServer: state.config.lockServer.pubky, sessionSecret, - pkarrRelays: [state.config.testnet.pkarrRelay], + pkarrRelays: pkarrRelaysForDemoConfig(state.config), }), }); if (requestId !== state.demoAuthStatusRequestId) return; @@ -491,7 +514,9 @@ async function refreshDemoAuthStatus() { state.creatorPubky = creatorPubky; state.demoAuthenticated = status.authenticated; if (status.authenticated) { - el.demoAuthStatus.textContent = `Authenticated as ${status.pubky} on ${status.homeserver}`; + el.demoAuthStatus.textContent = status.homeserver + ? `Authenticated as ${status.pubky} on ${status.homeserver}` + : `Authenticated as ${status.pubky}`; el.demoAuthStatus.className = 'ok'; el.startDemoAuth.disabled = true; el.demoAuthCommand.textContent = ''; @@ -560,7 +585,7 @@ async function refreshPaykitSetupReadiness({ openSetupWhenRequired = true } = {} const result = await queryPaykitSetupStatus({ lockServer: state.config.lockServer.pubky, sessionSecret, - pkarrRelays: [state.config.testnet.pkarrRelay], + pkarrRelays: pkarrRelaysForDemoConfig(state.config), }); if ( requestId !== state.paykitSetupStatusRequestId diff --git a/examples/js-sdk/creator-identity.js b/examples/js-sdk/creator-identity.js index b2d3212..46f0c49 100644 --- a/examples/js-sdk/creator-identity.js +++ b/examples/js-sdk/creator-identity.js @@ -47,3 +47,17 @@ export async function invalidateIdentityScopedCreatorState({ state, revokeSessio return { revoked: false }; } } + +export function captureCreatorOperation(state) { + return Object.freeze({ + generation: state.creatorIdentityGeneration, + creatorPubky: state.creatorPubky, + sessionSecret: state.feLockSessionToken, + }); +} + +export function creatorOperationMatches(state, operation) { + return operation.generation === state.creatorIdentityGeneration + && operation.creatorPubky === state.creatorPubky + && operation.sessionSecret === state.feLockSessionToken; +} diff --git a/examples/js-sdk/demo-network.js b/examples/js-sdk/demo-network.js new file mode 100644 index 0000000..2c24f45 --- /dev/null +++ b/examples/js-sdk/demo-network.js @@ -0,0 +1,22 @@ +export function pkarrRelaysForDemoConfig(config) { + if (config?.mode === 'staging') return []; + const relay = config?.testnet?.pkarrRelay; + if (typeof relay !== 'string' || relay.length === 0) { + throw new Error('demo config is missing PKARR relay'); + } + return [relay]; +} + +export function demoAuthRelayForConfig(config) { + if (config?.mode === 'staging') return undefined; + const relay = config?.testnet?.httpRelay; + if (typeof relay !== 'string' || relay.length === 0) { + throw new Error('demo config is missing HTTP auth relay'); + } + const url = new URL(relay); + const normalizedPath = url.pathname.endsWith('/') ? url.pathname : `${url.pathname}/`; + if (!normalizedPath.endsWith('/inbox/')) { + url.pathname = `${normalizedPath}inbox/`.replace(/\/+/g, '/'); + } + return url.toString(); +} diff --git a/examples/js-sdk/package.json b/examples/js-sdk/package.json index 452ae7a..8e715cc 100644 --- a/examples/js-sdk/package.json +++ b/examples/js-sdk/package.json @@ -5,6 +5,7 @@ "type": "module", "scripts": { "init-config": "node scripts/init-config.mjs", + "init-staging-config": "node scripts/init-staging-config.mjs", "create-user": "node scripts/create-user.mjs", "publish-creator-profile": "node scripts/publish-creator-profile.mjs", "authenticate": "node scripts/authenticate.mjs", @@ -12,16 +13,25 @@ "prepare-paykit-reader": "node scripts/prepare-paykit-reader.mjs", "receive-paykit-request": "node scripts/receive-paykit-request.mjs", "test:paykit-reader-worker": "node scripts/test-paykit-reader-worker.mjs", + "test:staging-config": "node scripts/test-staging-config.mjs", + "test:staging-compose": "node scripts/test-staging-compose.mjs", + "test:staging-creator-mode": "node scripts/test-staging-creator-mode.mjs", + "test:staging-reader-mode": "node scripts/test-staging-reader-mode.mjs", + "test:reader-persistence": "node scripts/test-reader-persistence.mjs", + "test:creator-session-state": "node scripts/test-creator-session-state.mjs", "start-server": "node scripts/start-demo-server.mjs", "start-reader-server": "node scripts/start-reader-demo-server.mjs", "init-paykit-compose": "node scripts/init-paykit-compose.mjs", "generate-paykit-account-tpub": "node scripts/generate-paykit-account-tpub.mjs", "reset-paykit-demo": "node scripts/reset-paykit-demo.mjs", + "reset-paykit-staging-demo-local": "node scripts/reset-paykit-staging-demo-local.mjs", + "test:reset-paykit-staging-demo-local": "node scripts/test-reset-paykit-staging-demo-local.mjs", "validate:paykit-compose": "node scripts/validate-paykit-compose.mjs", + "validate:staging-compose": "node scripts/validate-staging-compose.mjs", "check:paykit-setup-contract": "node scripts/check-paykit-setup-contract.mjs", "smoke:paykit-compose": "npm run validate:paykit-compose && npm run check:paykit-setup-contract && npm run test:paykit-reader-worker && node scripts/smoke-paykit-compose.mjs", "smoke": "npm --prefix ../../locks-sdk/bindings/js run smoke:examples", - "check": "node --check scripts/init-config.mjs && node --check scripts/init-paykit-compose.mjs && node --check scripts/homegate-bridge.mjs && node --check scripts/generate-paykit-account-tpub.mjs && node --check scripts/reset-paykit-demo.mjs && node --check scripts/validate-paykit-compose.mjs && node --check scripts/check-paykit-setup-contract.mjs && node --check scripts/electrum-readiness.mjs && node --check scripts/smoke-paykit-compose.mjs && node --check scripts/test-paykit-reader-worker.mjs && node --check scripts/create-user.mjs && node --check scripts/publish-creator-profile.mjs && node --check scripts/authenticate.mjs && node --check scripts/authenticate-paykit.mjs && node --check scripts/register-paykit-reader.mjs && node --check scripts/prepare-paykit-reader.mjs && node --check scripts/receive-paykit-request.mjs && node --check scripts/lib/creator-session-state.mjs && node --check scripts/lib/creator-static-path.mjs && node --check scripts/lib/paykit-reader-helper.mjs && node --check scripts/lib/paykit-reader-status.mjs && node --check scripts/lib/paykit-reader-worker.mjs && node --check scripts/start-demo-server.mjs && node --check scripts/start-reader-demo-server.mjs && node --check app.js && node --check app-iframe.js && node --check creator-identity.js && node --check creator-lock-policy.js && node --check paykit-setup.js && node --check creator-complete-flow.js && node --check reader-app.js && node --check reader-flow.js" + "check": "node --check scripts/init-config.mjs && node --check scripts/init-staging-config.mjs && node --check scripts/lib/staging-config.mjs && node --check scripts/lib/demo-runtime.mjs && node --check scripts/test-staging-config.mjs && node --check scripts/validate-staging-compose.mjs && node --check scripts/test-staging-compose.mjs && node --check scripts/test-staging-creator-mode.mjs && node --check scripts/test-staging-reader-mode.mjs && node --check scripts/test-reader-persistence.mjs && node --check scripts/test-creator-session-state.mjs && node --check scripts/reset-paykit-staging-demo-local.mjs && node --check scripts/test-reset-paykit-staging-demo-local.mjs && node --check scripts/init-paykit-compose.mjs && node --check scripts/homegate-bridge.mjs && node --check scripts/generate-paykit-account-tpub.mjs && node --check scripts/reset-paykit-demo.mjs && node --check scripts/validate-paykit-compose.mjs && node --check scripts/check-paykit-setup-contract.mjs && node --check scripts/electrum-readiness.mjs && node --check scripts/smoke-paykit-compose.mjs && node --check scripts/test-paykit-reader-worker.mjs && node --check scripts/create-user.mjs && node --check scripts/publish-creator-profile.mjs && node --check scripts/authenticate.mjs && node --check scripts/authenticate-paykit.mjs && node --check scripts/register-paykit-reader.mjs && node --check scripts/prepare-paykit-reader.mjs && node --check scripts/receive-paykit-request.mjs && node --check scripts/lib/creator-session-state.mjs && node --check scripts/lib/creator-static-path.mjs && node --check scripts/lib/paykit-reader-helper.mjs && node --check scripts/lib/paykit-reader-status.mjs && node --check scripts/lib/paykit-reader-worker.mjs && node --check scripts/start-demo-server.mjs && node --check scripts/start-reader-demo-server.mjs && node --check demo-network.js && node --check reader-persistence.js && node --check reader-staging-paykit.js && node --check app.js && node --check app-iframe.js && node --check creator-identity.js && node --check creator-lock-policy.js && node --check paykit-setup.js && node --check creator-complete-flow.js && node --check reader-app.js && node --check reader-flow.js" }, "dependencies": { "@synonymdev/pubky": "^0.11.0" diff --git a/examples/js-sdk/reader-app.js b/examples/js-sdk/reader-app.js index 7da7ba3..6f45511 100644 --- a/examples/js-sdk/reader-app.js +++ b/examples/js-sdk/reader-app.js @@ -1,6 +1,7 @@ import { classifyPaymentLifecycle, completeDevVerification, + hasPaykitData, issueAccessCredential, loadContentLock, lookupVerificationTask, @@ -13,6 +14,12 @@ import { selectCurrentPaykitPaymentRequest, workflowHandleMatches, } from './reader-flow.js'; +import { pkarrRelaysForDemoConfig } from './demo-network.js'; +import { + checkExternalReaderPaykitData, + createPaykitDataCheckController, +} from './reader-staging-paykit.js'; +import { buildPersistedReaderState, restorePersistedReaderState } from './reader-persistence.js'; const STATE_KEY = 'pubky-locks-reader-demo.state'; @@ -26,6 +33,7 @@ const state = { readerPublicKey: '', paykitReaderPrepared: false, paykitReaderState: 'starting', + paykitDataMessage: '', paykitPaymentRequest: null, baselinePaymentRequestId: null, loadingLock: false, @@ -44,6 +52,7 @@ const state = { let workflowIncarnation = 0; const paykitReaderStatusRequests = createLatestRequestGate(); +const paykitDataChecks = createPaykitDataCheckController(); let activeLoadToken = null; let activeSubmissionToken = null; let activePollToken = null; @@ -61,6 +70,7 @@ const el = { verifierType: document.querySelector('#verifier-type'), proofSatisfied: document.querySelector('#proof-satisfied'), paykitReaderCommands: document.querySelector('#paykit-reader-commands'), + paykitReaderGuidance: document.querySelector('#paykit-reader-guidance'), readerPublicKey: document.querySelector('#reader-public-key'), refreshPaykitReader: document.querySelector('#refresh-paykit-reader'), paykitReaderStatus: document.querySelector('#paykit-reader-status'), @@ -88,14 +98,20 @@ async function bootstrap() { const resource = new URL(window.location.href).searchParams.get('resource')?.trim(); if (resource) state.resource = resource; bindEvents(); - await refreshPaykitReaderStatus(); + if (state.config.mode === 'staging') { + state.paykitReaderState = 'unchecked'; + } else { + await refreshPaykitReaderStatus(); + } render(); - setInterval(() => { void refreshPaykitReaderStatus(); }, 1_000); + if (state.config.mode !== 'staging') { + setInterval(() => { void refreshPaykitReaderStatus(); }, 1_000); + } await postClientLog('info', 'reader-bootstrap-config-loaded', { + mode: state.config.mode ?? 'local-testnet', lockServerPubky: state.config.lockServer.pubky, lockServerUrl: state.config.lockServer.url, - pkarrRelay: state.config.testnet.pkarrRelay, - location: window.location.href, + customPkarrRelays: pkarrRelaysForDemoConfig(state.config), hasState: Boolean(localStorage.getItem(STATE_KEY)), }); } @@ -114,6 +130,7 @@ function bindEvents() { readerPublicKey: '', paykitReaderPrepared: false, paykitReaderState: 'starting', + paykitDataMessage: '', paykitPaymentRequest: null, baselinePaymentRequestId: null, loadingLock: false, @@ -150,6 +167,16 @@ function bindEvents() { render(); }); + el.readerPublicKey.addEventListener('input', () => { + if (state.config.mode !== 'staging') return; + paykitDataChecks.invalidate(); + state.readerPublicKey = el.readerPublicKey.value.trim(); + state.paykitReaderPrepared = false; + state.paykitReaderState = 'unchecked'; + state.paykitDataMessage = ''; + render(); + }); + el.refreshPaykitReader.addEventListener('click', refreshPaykitReaderStatus); el.load.addEventListener('click', loadLock); el.submitProof.addEventListener('click', submitProof); @@ -165,6 +192,34 @@ function bindEvents() { } async function refreshPaykitReaderStatus() { + if (state.config.mode === 'staging') { + state.paykitReaderPrepared = false; + state.paykitReaderState = 'checking'; + state.paykitDataMessage = 'Checking public Paykit v0 data...'; + render(); + try { + if (!state.creator) throw new Error('Load the content lock before checking the reader.'); + const result = await paykitDataChecks.check({ + incarnation: workflowIncarnation, + resource: state.resource, + readerPubky: state.readerPublicKey, + creatorPubky: state.creator, + lookup: (readerPublicKey) => hasPaykitData({ readerPublicKey }), + isCurrent: paykitDataSnapshotMatches, + }); + if (!result) return; + state.readerPublicKey = result.readerPubky; + state.paykitReaderPrepared = result.canSubmit; + state.paykitReaderState = result.state; + state.paykitDataMessage = result.message; + } catch (error) { + state.paykitReaderPrepared = false; + state.paykitReaderState = 'invalid'; + state.paykitDataMessage = error.message ?? String(error); + } + render(); + return; + } const request = paykitReaderStatusRequests.begin(workflowIncarnation); try { const response = await fetch('/api/paykit-reader/status', { method: 'GET', cache: 'no-store' }); @@ -266,6 +321,7 @@ async function submitProof() { resource: state.resource, verifierType: state.verifierType, readerPublicKey: state.readerPublicKey, + paykitCreator: state.loaded?.creator, paykitReaderPrepared: state.paykitReaderPrepared, proofSatisfied: state.proofSatisfied, primaryPath: state.lockResources.find((resource) => resource.kind === 'primary')?.readPath ?? '', @@ -285,6 +341,23 @@ async function submitProof() { resource: snapshot.resource, pkarrRelays: snapshot.pkarrRelays, }; + if (state.config.mode === 'staging' && snapshot.verifierType === 'paykit-payment') { + const paykitData = await checkExternalReaderPaykitData({ + readerPubky: snapshot.readerPublicKey, + creatorPubky: snapshot.paykitCreator, + lookup: (readerPublicKey) => hasPaykitData({ readerPublicKey }), + }); + if ( + activeSubmissionToken !== submissionToken + || !workflowMatches(snapshot) + || state.loaded?.creator !== snapshot.paykitCreator + || state.readerPublicKey !== snapshot.readerPublicKey + ) return; + state.paykitReaderPrepared = paykitData.canSubmit; + state.paykitReaderState = paykitData.state; + state.paykitDataMessage = paykitData.message; + if (!paykitData.canSubmit) throw new Error(paykitData.message); + } const result = snapshot.verifierType === 'paykit-payment' ? await submitPaykitPaymentProof({ ...common, @@ -534,11 +607,27 @@ function render() { el.verifierType.value = state.verifierType; el.verifierType.disabled = true; el.readerPublicKey.value = state.readerPublicKey ?? ''; + const stagingMode = state.config.mode === 'staging'; + el.paykitReaderGuidance.textContent = stagingMode + ? 'Use a second Bitkit identity: paste its public Pubky and check public Paykit v0 data before submitting.' + : 'The Paykit reader identity is prepared automatically by the local demo.'; + el.readerPublicKey.readOnly = !stagingMode; + el.refreshPaykitReader.textContent = stagingMode ? 'Check Paykit data' : 'Refresh Paykit reader'; const paymentMode = state.verifierType === 'paykit-payment'; el.proofSatisfied.closest('label').hidden = paymentMode; el.paykitReaderCommands.hidden = !paymentMode; el.load.disabled = state.loadingLock || state.submittingProof; - if (state.paykitReaderState === 'request_received') { + if (stagingMode) { + el.paykitReaderStatus.textContent = state.paykitDataMessage + || 'Paste the distinct reader Bitkit Pubky, then check public Paykit data.'; + el.paykitReaderStatus.className = state.paykitReaderState === 'present' + ? 'ok' + : ['absent', 'unavailable'].includes(state.paykitReaderState) + ? 'warning' + : state.paykitReaderState === 'invalid' + ? 'error' + : 'muted'; + } else if (state.paykitReaderState === 'request_received') { el.paykitReaderStatus.textContent = 'Paykit reader received and validated the Payment Request.'; el.paykitReaderStatus.className = 'ok'; } else if (state.paykitReaderState === 'waiting') { @@ -557,7 +646,9 @@ function render() { el.paykitReaderStatus.textContent = 'Paykit reader worker is starting.'; el.paykitReaderStatus.className = 'muted'; } - el.paykitReaderPayment.textContent = state.paykitPaymentRequest + el.paykitReaderPayment.textContent = stagingMode + ? (state.bundleId ? 'Complete the Payment Request in the external reader Bitkit, then resume payment verification polling.' : '') + : state.paykitPaymentRequest ? format({ payment_request_id: state.paykitPaymentRequest.payment_request_id, asset: state.paykitPaymentRequest.asset, @@ -691,7 +782,7 @@ function restoreState() { const raw = localStorage.getItem(STATE_KEY); if (!raw) return; try { - Object.assign(state, JSON.parse(raw), { + Object.assign(state, restorePersistedReaderState(JSON.parse(raw)), { loadingLock: false, submittingProof: false, paymentPolling: false, @@ -708,29 +799,24 @@ function restoreState() { } function persistState() { - const { - config: _config, - loadingLock: _loadingLock, - submittingProof: _submittingProof, - paymentPolling: _paymentPolling, - paykitReaderPrepared: _paykitReaderPrepared, - readerPublicKey: _readerPublicKey, - paykitReaderState: _paykitReaderState, - paykitPaymentRequest: _paykitPaymentRequest, - baselinePaymentRequestId: _baselinePaymentRequestId, - readResult: _readResult, - ...persisted - } = state; - localStorage.setItem(STATE_KEY, JSON.stringify(persisted)); + localStorage.setItem(STATE_KEY, JSON.stringify(buildPersistedReaderState(state))); } function pkarrRelays() { - return [state.config.testnet.pkarrRelay]; + return pkarrRelaysForDemoConfig(state.config); +} + +function paykitDataSnapshotMatches(snapshot) { + return snapshot.incarnation === workflowIncarnation + && snapshot.resource === state.resource + && snapshot.creatorPubky === state.creator + && snapshot.readerPubky === state.readerPublicKey; } function invalidateWorkflow() { workflowIncarnation += 1; paykitReaderStatusRequests.invalidate(); + paykitDataChecks.invalidate(); if (state.paykitPaymentRequest?.payment_request_id) { state.baselinePaymentRequestId = state.paykitPaymentRequest.payment_request_id; } @@ -749,6 +835,11 @@ function clearVerificationState({ clearLoaded = false } = {}) { state.lockResources = []; state.guardedResourcePath = ''; state.verifierType = 'dev-static'; + if (state.config?.mode === 'staging') { + state.paykitReaderPrepared = false; + state.paykitReaderState = 'unchecked'; + state.paykitDataMessage = ''; + } } state.creator = null; state.bundleId = null; diff --git a/examples/js-sdk/reader-flow.js b/examples/js-sdk/reader-flow.js index 6a70a5a..7122d7e 100644 --- a/examples/js-sdk/reader-flow.js +++ b/examples/js-sdk/reader-flow.js @@ -13,6 +13,11 @@ export function buildLocksOptions({ pkarrRelays = [] } = {}) { return options; } +export async function hasPaykitData({ readerPublicKey }) { + await init(); + return Locks.hasPaykitData(readerPublicKey); +} + export async function loadContentLock({ resource, pkarrRelays = [] } = {}) { await init(); const options = buildLocksOptions({ pkarrRelays }); diff --git a/examples/js-sdk/reader-persistence.js b/examples/js-sdk/reader-persistence.js new file mode 100644 index 0000000..e118c0b --- /dev/null +++ b/examples/js-sdk/reader-persistence.js @@ -0,0 +1,21 @@ +const PUBLIC_KEYS = [ + 'resource', + 'guardedResourcePath', + 'lockResources', + 'proofSatisfied', + 'verifierType', + 'loaded', +]; + +export function buildPersistedReaderState(state) { + return Object.fromEntries(PUBLIC_KEYS.map((key) => [key, state[key]])); +} + +export function restorePersistedReaderState(value) { + if (value === null || typeof value !== 'object' || Array.isArray(value)) return {}; + return Object.fromEntries( + PUBLIC_KEYS + .filter((key) => Object.hasOwn(value, key)) + .map((key) => [key, value[key]]), + ); +} diff --git a/examples/js-sdk/reader-staging-paykit.js b/examples/js-sdk/reader-staging-paykit.js new file mode 100644 index 0000000..da4ee8b --- /dev/null +++ b/examples/js-sdk/reader-staging-paykit.js @@ -0,0 +1,90 @@ +import { PublicKey } from './node_modules/@synonymdev/pubky/index.js'; + +export function validateExternalReaderPubky(readerPubky, creatorPubky) { + const normalized = typeof readerPubky === 'string' ? readerPubky.trim() : ''; + const canonicalReader = canonicalPubky(normalized); + if (!canonicalReader || canonicalReader !== normalized) { + throw new Error('Enter the canonical Bitkit reader Pubky.'); + } + const canonicalCreator = canonicalPubky(creatorPubky); + if (!canonicalCreator || canonicalReader === canonicalCreator) { + throw new Error('Creator and reader require distinct Bitkit identities.'); + } + return canonicalReader; +} + +function canonicalPubky(value) { + if (typeof value !== 'string') return null; + let publicKey; + try { + publicKey = PublicKey.from(value); + return publicKey.toString(); + } catch { + return null; + } finally { + publicKey?.free(); + } +} + +export async function checkExternalReaderPaykitData({ + readerPubky, + creatorPubky, + lookup, +}) { + const validated = validateExternalReaderPubky(readerPubky, creatorPubky); + try { + const present = await lookup(validated); + return present + ? { + state: 'present', + readerPubky: validated, + canSubmit: true, + message: 'Paykit v0 data is present. Invoice creation will validate the usable Bitkit receiver.', + } + : { + state: 'absent', + readerPubky: validated, + canSubmit: false, + message: 'No Paykit v0 data found. Enable Paykit in Bitkit, then retry.', + }; + } catch { + return { + state: 'unavailable', + readerPubky: validated, + canSubmit: false, + message: 'Paykit data lookup is unavailable. Retry.', + }; + } +} + +export function createPaykitDataCheckController() { + let generation = 0; + return { + invalidate() { + generation += 1; + }, + async check({ + incarnation, + resource, + creatorPubky, + readerPubky, + lookup, + isCurrent, + }) { + const requestGeneration = ++generation; + const snapshot = Object.freeze({ + incarnation, + resource, + creatorPubky, + readerPubky, + }); + const result = await checkExternalReaderPaykitData({ + readerPubky, + creatorPubky, + lookup, + }); + if (requestGeneration !== generation || !isCurrent(snapshot)) return null; + return result; + }, + }; +} diff --git a/examples/js-sdk/reader.html b/examples/js-sdk/reader.html index b3e01d2..1bcb98c 100644 --- a/examples/js-sdk/reader.html +++ b/examples/js-sdk/reader.html @@ -22,7 +22,7 @@

Pubky Locks JS SDK reader demo

-

This local testnet demo exercises the unauthenticated reader flow against a configured Lock Server.

+

This demo exercises the unauthenticated reader flow against a configured Lock Server.

Local-dev warning: bundle IDs and access credentials are bearer-like secrets. This page displays them for debugging only.

@@ -59,12 +59,12 @@

2. Submit proof bundle