From 2403278b208760b9ad0fbfac99d4058c3e3c5c2a Mon Sep 17 00:00:00 2001 From: dzdidi Date: Tue, 4 Aug 2026 08:26:24 -0300 Subject: [PATCH 01/14] feat(js-sdk): expose authenticated session metadata --- .../js/scripts/smoke-generated-api.mjs | 1 + locks-sdk/bindings/js/src/locks.rs | 66 +++++++++++++++++-- locks-sdk/bindings/js/src/session.rs | 30 +++++++++ locks-sdk/bindings/js/src/viewer.rs | 38 +++++++++-- 4 files changed, 122 insertions(+), 13 deletions(-) diff --git a/locks-sdk/bindings/js/scripts/smoke-generated-api.mjs b/locks-sdk/bindings/js/scripts/smoke-generated-api.mjs index dec49c6..8106350 100644 --- a/locks-sdk/bindings/js/scripts/smoke-generated-api.mjs +++ b/locks-sdk/bindings/js/scripts/smoke-generated-api.mjs @@ -60,6 +60,7 @@ const requiredSnippets = [ 'lookupVerificationTask(options: VerificationTaskHandleOptions): Promise;', 'issueAccessCredential(options: VerificationTaskHandleOptions): Promise;', 'proxyReadGuardedResource(access_credential: string, path: string): Promise;', + 'proxyReadGuardedResourceResponse(access_credential: string, path: string): Promise;', 'export class VerificationTaskHandleOptions', 'constructor(creator: string, bundle_id: string);', 'export class Session', diff --git a/locks-sdk/bindings/js/src/locks.rs b/locks-sdk/bindings/js/src/locks.rs index 3788884..4c3f7b2 100644 --- a/locks-sdk/bindings/js/src/locks.rs +++ b/locks-sdk/bindings/js/src/locks.rs @@ -343,11 +343,12 @@ impl Locks { .prepare_exchange_request_with_pkarr_resolver(&request, &resolver, None) .await .map_err(|err| invalid_input(err.to_string()))?; - let token = post_json_for_session_token(&request).await?; - Ok(Session::new( - self.inner.restore_session(token), + let response = post_json_for_session(&request).await?; + Ok(Session::new_with_creator( + self.inner.restore_session(&response.session_token), self.inner.clone(), self.options.clone(), + Some(response.creator.to_string()), )) } } @@ -684,7 +685,9 @@ async fn fetch_content_lock_json(request_plan: &JsPreparedContentLockRequest) -> } #[cfg(target_arch = "wasm32")] -async fn post_json_for_session_token(request_plan: &JsPreparedRequest) -> JsResult { +async fn post_json_for_session( + request_plan: &JsPreparedRequest, +) -> JsResult { let request_init = web_sys::RequestInit::new(); request_init.set_method(request_plan.method); request_init.set_mode(web_sys::RequestMode::Cors); @@ -725,17 +728,68 @@ async fn post_json_for_session_token(request_plan: &JsPreparedRequest) -> JsResu .map_err(|err| invalid_input(format!("failed to parse JSON response: {err:?}")))?; let value: Value = serde_wasm_bindgen::from_value(json) .map_err(|err| invalid_input(format!("invalid session response JSON: {err}")))?; - value + parse_frontend_session_response(value).map_err(invalid_input) +} + +#[cfg(any(test, target_arch = "wasm32"))] +#[derive(Debug, Clone, PartialEq, Eq)] +struct FrontendSessionResponse { + session_token: String, + creator: CreatorPubky, +} + +#[cfg(any(test, target_arch = "wasm32"))] +fn parse_frontend_session_response(value: Value) -> Result { + let session_token = value .get("session_token") .and_then(Value::as_str) .map(ToOwned::to_owned) - .ok_or_else(|| invalid_input("frontend session response missing session_token")) + .ok_or_else(|| "frontend session response missing session_token".to_owned())?; + let creator = value + .get("creator") + .and_then(Value::as_str) + .ok_or_else(|| "frontend session response missing creator".to_owned()) + .and_then(|creator| { + CreatorPubky::from_str(creator) + .map_err(|_| "frontend session response contains invalid creator".to_owned()) + })?; + Ok(FrontendSessionResponse { + session_token, + creator, + }) } #[cfg(test)] mod tests { use super::*; + #[test] + fn frontend_session_response_preserves_authenticated_creator() { + let response = parse_frontend_session_response(serde_json::json!({ + "session_token": "frontend-session-secret", + "creator": "pubkytkrq8zmwb8a3m9k15csu3q17qmfgqnp9dskbrg9uq1rydpyxp7qy", + "expires_at": "2030-01-01T00:00:00Z" + })) + .unwrap(); + + assert_eq!(response.session_token, "frontend-session-secret"); + assert_eq!( + response.creator.to_string(), + "pubkytkrq8zmwb8a3m9k15csu3q17qmfgqnp9dskbrg9uq1rydpyxp7qy" + ); + } + + #[test] + fn frontend_session_response_rejects_missing_creator() { + let error = parse_frontend_session_response(serde_json::json!({ + "session_token": "frontend-session-secret", + "expires_at": "2030-01-01T00:00:00Z" + })) + .unwrap_err(); + + assert!(error.contains("missing creator")); + } + #[test] fn locks_constructor_is_available_for_valid_lock_server_pubky() { let locks = diff --git a/locks-sdk/bindings/js/src/session.rs b/locks-sdk/bindings/js/src/session.rs index ac68fb7..575adad 100644 --- a/locks-sdk/bindings/js/src/session.rs +++ b/locks-sdk/bindings/js/src/session.rs @@ -240,6 +240,7 @@ impl JsAuthorizedRequestPlan { pub struct Session { inner: locks_sdk::LocksSession, client: locks_sdk::LocksClient, + creator_pubky: Option, #[cfg_attr(not(any(test, target_arch = "wasm32")), allow(dead_code))] options: LocksOptions, } @@ -256,6 +257,11 @@ impl Session { self.client.lock_server().to_string() } + #[wasm_bindgen(js_name = creatorPubky)] + pub fn creator_pubky(&self) -> Option { + self.creator_pubky.clone() + } + #[wasm_bindgen(getter)] pub fn creator(&self) -> Creator { Creator::new(self.clone()) @@ -279,10 +285,20 @@ impl Session { inner: locks_sdk::LocksSession, client: locks_sdk::LocksClient, options: LocksOptions, + ) -> Self { + Self::new_with_creator(inner, client, options, None) + } + + pub(crate) fn new_with_creator( + inner: locks_sdk::LocksSession, + client: locks_sdk::LocksClient, + options: LocksOptions, + creator_pubky: Option, ) -> Self { Self { inner, client, + creator_pubky, options, } } @@ -510,6 +526,20 @@ mod tests { ); } + #[test] + fn exchanged_session_exposes_authenticated_creator_pubky() { + let client = test_client(); + let creator = "pubkytkrq8zmwb8a3m9k15csu3q17qmfgqnp9dskbrg9uq1rydpyxp7qy"; + let session = Session::new_with_creator( + client.restore_session("frontend-session-secret"), + client, + LocksOptions::new(), + Some(creator.to_owned()), + ); + + assert_eq!(session.creator_pubky(), Some(creator.to_owned())); + } + #[test] fn signout_request_uses_current_frontend_session_endpoint_and_bearer() { let client = test_client(); diff --git a/locks-sdk/bindings/js/src/viewer.rs b/locks-sdk/bindings/js/src/viewer.rs index e7afb7d..a9cab03 100644 --- a/locks-sdk/bindings/js/src/viewer.rs +++ b/locks-sdk/bindings/js/src/viewer.rs @@ -210,6 +210,23 @@ impl Viewer { .map_err(|err| invalid_input(err.to_string()))?; fetch_viewer_bytes(&request).await } + + #[cfg(target_arch = "wasm32")] + #[wasm_bindgen(js_name = proxyReadGuardedResourceResponse)] + pub async fn proxy_read_guarded_resource_response( + &self, + access_credential: &str, + path: String, + ) -> JsResult { + let resolver = BrowserPkarrResolver::new_with_options(&self.options) + .map_err(|err| invalid_input(err.to_string()))?; + let request = self + .build_proxy_read_guarded_resource_request(access_credential, path) + .prepare_with_pkarr_resolver(&resolver, None) + .await + .map_err(|err| invalid_input(err.to_string()))?; + fetch_viewer_response(&request).await + } } impl Viewer { @@ -408,13 +425,8 @@ async fn fetch_viewer_json_value(request: &JsPreparedViewerRequest) -> JsResult< #[cfg(target_arch = "wasm32")] async fn fetch_viewer_bytes(request: &JsPreparedViewerRequest) -> JsResult { - let response = fetch_viewer(request).await?; - if !response.ok() { - return Err(invalid_input(format!( - "Lock Server viewer request failed with HTTP {}", - response.status() - ))); - } + let response = fetch_viewer_response(request).await?; + let buffer = wasm_bindgen_futures::JsFuture::from( response .array_buffer() @@ -425,6 +437,18 @@ async fn fetch_viewer_bytes(request: &JsPreparedViewerRequest) -> JsResult JsResult { + let response = fetch_viewer(request).await?; + if !response.ok() { + return Err(invalid_input(format!( + "Lock Server viewer request failed with HTTP {}", + response.status() + ))); + } + Ok(response) +} + #[cfg(target_arch = "wasm32")] async fn fetch_viewer(request: &JsPreparedViewerRequest) -> JsResult { use wasm_bindgen::JsCast; From 0dc46172713e55c3a5bcf4912d72123f4d57facf Mon Sep 17 00:00:00 2001 From: dzdidi Date: Tue, 4 Aug 2026 08:27:49 -0300 Subject: [PATCH 02/14] fix(auth): harden iframe callback delivery --- locks-e2e/tests/legacy_connect_shell_http.rs | 4 ++++ locks-server/src/api/creator_authority.rs | 25 ++++++++------------ 2 files changed, 14 insertions(+), 15 deletions(-) diff --git a/locks-e2e/tests/legacy_connect_shell_http.rs b/locks-e2e/tests/legacy_connect_shell_http.rs index 29c3bdd..dc71e2c 100644 --- a/locks-e2e/tests/legacy_connect_shell_http.rs +++ b/locks-e2e/tests/legacy_connect_shell_http.rs @@ -158,6 +158,10 @@ async fn connect_shell_postmessage_mode_returns_json_and_frames_allowed_parent() // the callback message type, and drops the manual approval button. assert!(shell_html.contains("locks-auth-callback")); assert!(shell_html.contains("TARGET_ORIGIN = \"https://pubky.app\"")); + assert!(shell_html.contains("CALLBACK_STATE = \"opaque-state\"")); + assert!(shell_html.contains("error: \"invalid-response\"")); + assert!(shell_html.contains("error: \"connect-failed\"")); + assert!(!shell_html.contains("connect-failed-\" + res.status")); assert!(!shell_html.contains("I approved this connection")); assert!(!shell_html.contains(" String { let escaped_flow_id = escape_html(flow_id); let escaped_authorization_url = escape_html(authorization_url); @@ -218,7 +211,7 @@ fn render_connect_shell_html( match delivery { // Embedded in the parent app's modal: render only the QR, transparent, no card/title/close. ConnectDeliveryMode::PostMessage => { - let script = render_postmessage_script(flow_id, target_origin); + let script = render_postmessage_script(flow_id, target_origin, callback_state); format!( r#" @@ -254,19 +247,21 @@ fn render_connect_shell_html( /// approval). Before approval the endpoint is effectively idempotent (the pending flow still /// exists), so transient failures — a dropped connection or a gateway timeout from a proxy that /// capped the idle long-poll — are retried with capped exponential backoff. A definitive error -/// (expired/rejected flow) is surfaced to the parent as `{ type, error }` so the embedder is never +/// (expired/rejected flow) is surfaced as a closed `{ type, state, error }` message so the embedder is never /// left hanging. On success it posts `{ type, state, code }` and stops. -fn render_postmessage_script(flow_id: &str, target_origin: &str) -> String { +fn render_postmessage_script(flow_id: &str, target_origin: &str, callback_state: &str) -> String { let flow_id_js = js_string_literal(flow_id); let target_origin_js = js_string_literal(target_origin); let type_js = js_string_literal(POSTMESSAGE_CALLBACK_TYPE); let resize_type_js = js_string_literal(POSTMESSAGE_RESIZE_TYPE); + let callback_state_js = js_string_literal(callback_state); format!( r#" diff --git a/examples/js-sdk/scripts/homegate-bridge.mjs b/examples/js-sdk/scripts/homegate-bridge.mjs new file mode 100644 index 0000000..fee6867 --- /dev/null +++ b/examples/js-sdk/scripts/homegate-bridge.mjs @@ -0,0 +1,55 @@ +#!/usr/bin/env node +import { readFile } from 'node:fs/promises'; +import { createServer } from 'node:http'; + +const listenPort = Number(process.env.HOMEGATE_BRIDGE_PORT ?? 8082); +const configPath = process.env.HOMEGATE_BRIDGE_CONFIG; +const homeserverAdminUrl = process.env.HOMEGATE_BRIDGE_HOMESERVER_ADMIN_URL; +const homeserverAdminPassword = process.env.PUBKY_HOMESERVER_ADMIN_PASSWORD; + +if (!configPath || !homeserverAdminUrl || !homeserverAdminPassword) { + throw new Error('Homegate bridge configuration is incomplete'); +} + +const server = createServer(async (request, response) => { + if (request.method === 'GET' && request.url === '/health') { + return sendJson(response, { ok: true }); + } + if (request.method !== 'POST' || request.url !== '/ip_verification') { + response.writeHead(404).end('not found'); + return; + } + + try { + const [{ testnet }, signupResponse] = await Promise.all([ + readDemoConfig(configPath), + fetch(`${homeserverAdminUrl}/generate_signup_token`, { + headers: { 'x-admin-password': homeserverAdminPassword }, + }), + ]); + if (!signupResponse.ok) throw new Error(`homeserver returned HTTP ${signupResponse.status}`); + const signupCode = (await signupResponse.text()).trim(); + if (!signupCode || typeof testnet?.homeserver !== 'string') { + throw new Error('homeserver signup response is incomplete'); + } + sendJson(response, { signupCode, homeserverPubky: testnet.homeserver }); + } catch { + sendJson(response, { error: 'signup unavailable' }, 503); + } +}); + +server.listen(listenPort, '0.0.0.0', () => { + console.log(`Homegate bridge listening on port ${listenPort}`); +}); + +async function readDemoConfig(path) { + return JSON.parse(await readFile(path, 'utf8')); +} + +function sendJson(response, value, status = 200) { + response.writeHead(status, { + 'cache-control': 'no-store', + 'content-type': 'application/json; charset=utf-8', + }); + response.end(`${JSON.stringify(value)}\n`); +} diff --git a/examples/js-sdk/scripts/init-paykit-compose.mjs b/examples/js-sdk/scripts/init-paykit-compose.mjs index 22c0751..615923e 100644 --- a/examples/js-sdk/scripts/init-paykit-compose.mjs +++ b/examples/js-sdk/scripts/init-paykit-compose.mjs @@ -86,6 +86,7 @@ function generatedPaths(root) { paykitConfig: join(root, 'paykit-config', 'config.toml'), bitcoinRpc: join(root, 'bitcoin-rpc', 'bitcoin-rpc.env'), pubkyHomeserver: join(root, 'pubky-homeserver', 'config.toml'), + homegateBridge: join(root, 'homegate-bridge', 'homegate.env'), }; } @@ -116,6 +117,7 @@ export async function initializePaykitCompose({ 'pubky-homeserver', 'paykit-server', 'paykit-config', + 'homegate-bridge', ].map(async (directory) => { const path = join(root, directory); await mkdir(path, { recursive: true, mode: 0o700 }); @@ -149,6 +151,7 @@ export async function initializePaykitCompose({ databasePassword: secrets.locksPostgresPassword, adminPassword: secrets.pubkyHomeserverAdminPassword, })), + writeSecure(paths.homegateBridge, `PUBKY_HOMESERVER_ADMIN_PASSWORD=${secrets.pubkyHomeserverAdminPassword}\n`), ]); if (lockConfigPath) { diff --git a/examples/js-sdk/scripts/lib/creator-session-state.mjs b/examples/js-sdk/scripts/lib/creator-session-state.mjs index 458a49c..d982fc5 100644 --- a/examples/js-sdk/scripts/lib/creator-session-state.mjs +++ b/examples/js-sdk/scripts/lib/creator-session-state.mjs @@ -25,8 +25,8 @@ export async function readCreatorDemoSessionForCurrentRole({ throw error; } - const profile = await readJson(profilePath); - if (!creatorIdentitiesMatch(session, profile)) { + const profile = profilePath ? await readJson(profilePath) : null; + if (!validCreatorSession(session) || (profile && !creatorIdentitiesMatch(session, profile))) { await clearCreatorDemoSession(sessionPath); return null; } @@ -42,8 +42,8 @@ export async function writeCreatorDemoSessionForCurrentRole( profilePath = defaultProfilePath, } = {}, ) { - const profileBeforeWrite = await readJson(profilePath); - if (!creatorIdentitiesMatch(session, profileBeforeWrite)) { + const profileBeforeWrite = profilePath ? await readJson(profilePath) : null; + if (!validCreatorSession(session) || (profileBeforeWrite && !creatorIdentitiesMatch(session, profileBeforeWrite))) { await clearCreatorDemoSession(sessionPath); throw new Error('creator identity changed during demo authentication'); } @@ -51,16 +51,20 @@ export async function writeCreatorDemoSessionForCurrentRole( await writeJson(sessionPath, session, { mode: 0o600 }); await chmod(sessionPath, 0o600); - const profileAfterWrite = await readJson(profilePath); - if (!creatorIdentitiesMatch(session, profileAfterWrite)) { + const profileAfterWrite = profilePath ? await readJson(profilePath) : null; + if (profileAfterWrite && !creatorIdentitiesMatch(session, profileAfterWrite)) { await clearCreatorDemoSession(sessionPath); throw new Error('creator identity changed during demo authentication'); } } function creatorIdentitiesMatch(session, profile) { - return session?.role === 'content-creator' + return validCreatorSession(session) && profile?.role === 'content-creator' - && typeof session.pubky === 'string' && session.pubky === profile.pubky; } + +function validCreatorSession(session) { + return session?.role === 'content-creator' + && /^pubky[ybndrfg8ejkmcpqxot1uwisza345h769]{52}$/.test(session.pubky ?? ''); +} diff --git a/examples/js-sdk/scripts/publish-creator-profile.mjs b/examples/js-sdk/scripts/publish-creator-profile.mjs index 884151a..d618161 100644 --- a/examples/js-sdk/scripts/publish-creator-profile.mjs +++ b/examples/js-sdk/scripts/publish-creator-profile.mjs @@ -14,12 +14,13 @@ const CANONICAL_PUBKY = /^pubky[ybndrfg8ejkmcpqxot1uwisza345h769]{52}$/; export async function publishCreatorProfile({ source = roleProfilePath('content-creator'), destination = creatorPublicProfilePath, + profile, } = {}) { - const profile = await readJson(source); - if (profile?.role !== 'content-creator' || !CANONICAL_PUBKY.test(profile.pubky ?? '')) { + const creatorProfile = profile ?? await readJson(source); + if (creatorProfile?.role !== 'content-creator' || !CANONICAL_PUBKY.test(creatorProfile.pubky ?? '')) { throw new Error('valid content-creator profile is required'); } - const publicProfile = Object.freeze({ role: 'content-creator', pubky: profile.pubky }); + const publicProfile = Object.freeze({ role: 'content-creator', pubky: creatorProfile.pubky }); await writeJson(destination, publicProfile); return publicProfile; } diff --git a/examples/js-sdk/scripts/smoke-paykit-compose.mjs b/examples/js-sdk/scripts/smoke-paykit-compose.mjs index c459218..d4456fe 100644 --- a/examples/js-sdk/scripts/smoke-paykit-compose.mjs +++ b/examples/js-sdk/scripts/smoke-paykit-compose.mjs @@ -151,6 +151,7 @@ try { 'paykit-server/paykit.env', 'bitcoin-rpc/bitcoin-rpc.env', 'pubky-homeserver/config.toml', + 'homegate-bridge/homegate.env', ]; for (const file of generatedFiles) { assert.equal((await stat(join(generatedRoot, file))).mode & 0o777, 0o600, `${file} must be mode 0600`); @@ -169,6 +170,7 @@ try { 'pubky-homeserver', 'paykit-server', 'paykit-config', + 'homegate-bridge', ]) { assert.equal((await stat(join(generatedRoot, directory))).mode & 0o777, 0o700, `${directory} must be mode 0700`); } @@ -241,6 +243,14 @@ try { loadProfile: async () => { throw new Error('private creator profile must not be loaded'); }, }); assert.equal(readerFromPublicProfile.PAYKIT_READER_SERVER_PUBKY, creatorPubky); + await publishCreatorProfile({ + profile: { role: 'content-creator', pubky: lockServerPubky }, + destination: publicProfilePath, + }); + assert.deepEqual(JSON.parse(await readFile(publicProfilePath, 'utf8')), { + role: 'content-creator', + pubky: lockServerPubky, + }); } finally { await rm(configOnlyRoot, { recursive: true, force: true }); } @@ -266,6 +276,7 @@ for (const service of [ 'bitcoin-bootstrap:', 'fulcrum:', 'electrum-readiness:', + 'homegate-bridge:', 'paykit-config:', 'demo-config:', 'paykit-server:', @@ -285,6 +296,8 @@ for (const required of [ 'https://github.com/pubky/locks.git#df5ea1b6d8dcdec3a9b5a915c3f57bca69d75c8a', '127.0.0.1:${LOCKS_PAYKIT_PORT:-3001}:3001', '127.0.0.1:${LOCKS_READER_DEMO_PORT:-8088}:8081', + '127.0.0.1:${LOCKS_ELECTRUM_PORT:-60001}:50001', + '127.0.0.1:${LOCKS_HOMEGATE_PORT:-6288}:8082', 'bitcoin-cli -conf=\\"$${BITCOIN_DATA}/bitcoin.conf\\" -regtest getblockchaininfo', 'user: "1000:1000"', '.local/bitcoin-bootstrap:/home/bitcoin/.bitcoin', @@ -297,6 +310,7 @@ for (const required of [ './.local/pubky-homeserver:/run/compose-local/pubky-homeserver:ro', './.local/locks-server:/run/compose-local/locks-server:ro', './.local/paykit-server:/run/compose-local/paykit-server:ro', + './.local/homegate-bridge:/run/compose-local/homegate-bridge:ro', 'node examples/js-sdk/scripts/init-paykit-compose.mjs', 'exec /entrypoint.sh Fulcrum', 'PAYKIT_READER_DEMO_BIN:', @@ -327,6 +341,11 @@ for (const privateVolume of ['name: locks_lock-home', 'name: pubky-locks-demo-pu } assert.ok(!compose.includes('- ./:/workspace'), 'services must not mount the repository root'); assert.ok(!compose.includes('- lock-home:/root'), 'demo services must not mount Lock Server identity state'); +const creatorService = compose.slice(compose.indexOf(' creator-demo:'), compose.indexOf('\n reader-demo:')); +assert.ok(creatorService.includes('--external-wallet'), 'creator must use the authenticated external wallet identity'); +assert.ok(!creatorService.includes('create-user -- --role content-creator'), 'external wallet mode must not create a second creator identity'); +assert.ok(!creatorService.includes('.local/content-creator'), 'external wallet mode must not mount creator recovery state'); +assert.ok(!creatorService.includes('--allow-unhealthy'), 'creator preflight must fail closed'); const readerService = compose.slice(compose.indexOf(' reader-demo:'), compose.indexOf('\nvolumes:')); assert.ok(!readerService.includes('.local/js-sdk-demo'), 'reader must not mount creator session state'); assert.ok(!readerService.includes('.local/content-creator'), 'reader must not mount creator recovery state'); @@ -334,10 +353,15 @@ assert.ok(readerService.includes('.local/creator-public'), 'reader requires only assert.ok(readerService.includes('PAYKIT_READER_WORKER_ENABLED: "1"'), 'reader must enable its embedded Paykit worker'); assert.ok(readerService.includes('npm --prefix examples/js-sdk run create-user -- --role content-viewer'), 'reader must create or reuse its recovery identity'); assert.ok(readerService.includes('exec node examples/js-sdk/scripts/start-reader-demo-server.mjs'), 'reader server must replace its bootstrap shell as PID 1'); +assert.ok(!readerService.includes('--allow-unhealthy'), 'reader preflight must fail closed'); assert.ok(readerService.includes('healthcheck:'), 'reader must expose worker-aware Compose health'); assert.ok(readerService.includes('http://127.0.0.1:8081/api/paykit-reader/status'), 'reader health must use the closed worker status endpoint'); assert.ok(readerService.includes('restart: unless-stopped'), 'reader worker must have an explicit restart policy'); assert.ok(!compose.includes('POSTGRES_PASSWORD: locks'), 'database credentials must not be committed inline'); +assert.ok(!compose.includes('./locks-sdk/bindings/js/pkg:/workspace/locks-sdk/bindings/js/pkg'), 'demo images must provide their own WASM package'); +for (const required of ['FROM rust:1.91.1-slim-bookworm@sha256:8514999d4786ef12efe89239e86b3d0a021b94b9d35108c8efe6c79ca7dc1a65 AS locks-sdk-wasm', 'cargo install wasm-pack --version 0.13.1 --locked', 'wasm-pack build --target web --out-dir pkg', 'COPY --from=locks-sdk-wasm']) { + assert.ok(jsDemoDockerfile.includes(required), `JS demo image missing ${required}`); +} assert.ok(locksEntrypoint.includes('LOCKS_PUBLIC_CONFIG'), 'Lock Server must publish an explicit public artifact'); for (const required of ['[paykit]', 'server_url = "http://127.0.0.1:3001"', 'minimum_confirmations = 0']) { assert.ok(locksEntrypoint.includes(required), `Locks generated config missing ${required}`); diff --git a/examples/js-sdk/scripts/start-demo-server.mjs b/examples/js-sdk/scripts/start-demo-server.mjs index 9672471..585f0b8 100644 --- a/examples/js-sdk/scripts/start-demo-server.mjs +++ b/examples/js-sdk/scripts/start-demo-server.mjs @@ -11,9 +11,11 @@ import { writeCreatorDemoSessionForCurrentRole, } from './lib/creator-session-state.mjs'; import { resolveCreatorStaticPath } from './lib/creator-static-path.mjs'; +import { publishCreatorProfile } from './publish-creator-profile.mjs'; const args = parseArgs(); const allowUnhealthy = Boolean(args['allow-unhealthy']); +const externalWallet = Boolean(args['external-wallet']); const config = await readDemoConfig(); const serviceConfig = withInternalServiceUrls(config); const port = Number(new URL(config.demoServer.url).port || 8080); @@ -105,13 +107,15 @@ async function startDemoAuth() { demoAuthPromise = activeDemoAuthFlow .awaitApproval() .then(async (session) => { - await writeCreatorDemoSessionForCurrentRole({ + const creatorSession = { role: 'content-creator', pubky: session.info.publicKey.toString(), capabilities: session.info.capabilities, exported_session: session.export(), authenticated_at: new Date().toISOString(), - }); + }; + await writeCreatorDemoSessionForCurrentRole(creatorSession, sessionStateOptions()); + if (externalWallet) await publishCreatorProfile({ profile: creatorSession }); return session; }) .catch((error) => { @@ -134,7 +138,7 @@ async function startDemoAuth() { } async function demoAuthStatus() { - const session = await readCreatorDemoSessionForCurrentRole(); + const session = await readCurrentCreatorSession(); if (session) { if (debugEnabled) { console.log(`[demo] demo-auth persisted session pubky=${session.pubky} path=./.local/js-sdk-demo/content-creator-session.json`); @@ -157,7 +161,17 @@ async function demoAuthStatus() { } async function hasPersistedDemoSession() { - return Boolean(await readCreatorDemoSessionForCurrentRole()); + return Boolean(await readCurrentCreatorSession()); +} + +function sessionStateOptions() { + return externalWallet ? { profilePath: null } : {}; +} + +async function readCurrentCreatorSession() { + const session = await readCreatorDemoSessionForCurrentRole(sessionStateOptions()); + if (session && externalWallet) await publishCreatorProfile({ profile: session }); + return session; } function publicBrowserConfig(source) { @@ -228,6 +242,9 @@ async function runPreflight(source) { push('config', false, error.message); } + const wasmPackage = join(repoRoot, 'locks-sdk/bindings/js/pkg/locks_sdk_wasm_bg.wasm'); + push('WASM package', existsSync(wasmPackage), existsSync(wasmPackage) ? 'present' : 'missing'); + await checkHttp(`${source.lockServer.url}/healthz`, 'lock-server /healthz', checks, (status) => status >= 200 && status < 300); await checkHttp(`${source.lockServer.url}/readyz`, 'lock-server /readyz', checks, (status) => status >= 200 && status < 300); await checkHttp(source.testnet.pkarrRelay, 'pkarr relay', checks, (status) => status < 500); // status < 500 diff --git a/examples/js-sdk/scripts/start-reader-demo-server.mjs b/examples/js-sdk/scripts/start-reader-demo-server.mjs index e5febc8..fac6f1e 100644 --- a/examples/js-sdk/scripts/start-reader-demo-server.mjs +++ b/examples/js-sdk/scripts/start-reader-demo-server.mjs @@ -176,6 +176,8 @@ async function runPreflight(source) { } catch (error) { checks.push({ name: 'config', ok: false, message: error.message }); } + const wasmPackage = join(repoRoot, 'locks-sdk/bindings/js/pkg/locks_sdk_wasm_bg.wasm'); + checks.push({ name: 'WASM package', ok: existsSync(wasmPackage), message: existsSync(wasmPackage) ? 'present' : 'missing' }); await checkHttp(`${source.lockServer.url}/healthz`, 'lock-server /healthz', checks, (status) => status === 200); await checkHttp(`${source.lockServer.url}/readyz`, 'lock-server /readyz', checks, (status) => status === 200); await checkHttp(source.testnet.pkarrRelay, 'pkarr relay', checks, (status) => status === 200 || status === 404); diff --git a/examples/js-sdk/scripts/validate-paykit-compose.mjs b/examples/js-sdk/scripts/validate-paykit-compose.mjs index d4b8fd8..bd4218b 100644 --- a/examples/js-sdk/scripts/validate-paykit-compose.mjs +++ b/examples/js-sdk/scripts/validate-paykit-compose.mjs @@ -14,6 +14,7 @@ const REQUIRED_SERVICES = [ 'fulcrum', 'electrum-readiness', 'pubky-testnet', + 'homegate-bridge', 'locks-server', 'paykit-config', 'demo-config', @@ -39,6 +40,7 @@ export function validateSafeComposeModel(model) { 'electrum-readiness', 'paykit-config', 'demo-config', + 'homegate-bridge', 'creator-demo', 'reader-demo', ]) { @@ -84,8 +86,10 @@ export function validateSafeComposeModel(model) { ) { throw new Error('bitcoin-bootstrap must use reset-managed scratch state'); } - for (const port of model.services['pubky-testnet'].ports ?? []) { - if (port.host_ip !== '127.0.0.1') throw new Error('published demo ports must bind to loopback'); + for (const service of Object.values(model.services)) { + for (const port of service.ports ?? []) { + if (port.host_ip !== '127.0.0.1') throw new Error('published demo ports must bind to loopback'); + } } return model; } diff --git a/locks-sdk/bindings/js/scripts/smoke-examples.mjs b/locks-sdk/bindings/js/scripts/smoke-examples.mjs index 2317e87..d6e9a0d 100644 --- a/locks-sdk/bindings/js/scripts/smoke-examples.mjs +++ b/locks-sdk/bindings/js/scripts/smoke-examples.mjs @@ -385,14 +385,16 @@ const { const creatorSessionTestDir = mkdtempSync(join(tmpdir(), 'locks-creator-session-')); const creatorSessionTestPath = join(creatorSessionTestDir, 'content-creator-session.json'); const creatorProfileTestPath = join(creatorSessionTestDir, 'profile.json'); +const firstCreatorPubky = 'pubkytkrq8zmwb8a3m9k15csu3q17qmfgqnp9dskbrg9uq1rydpyxp7qy'; +const secondCreatorPubky = 'pubky7ir1ttte48bcp4zjychjyscicrwi1j34mtt91ptsafdbjmr8g9eo'; try { writeFileSync(creatorSessionTestPath, '{"exported_session":"sensitive"}'); await clearCreatorDemoSession(creatorSessionTestPath); assert.equal(existsSync(creatorSessionTestPath), false); await clearCreatorDemoSession(creatorSessionTestPath); - writeFileSync(creatorProfileTestPath, JSON.stringify({ role: 'content-creator', pubky: 'creator-b' })); - writeFileSync(creatorSessionTestPath, JSON.stringify({ role: 'content-creator', pubky: 'creator-a', exported_session: 'old-secret' })); + writeFileSync(creatorProfileTestPath, JSON.stringify({ role: 'content-creator', pubky: secondCreatorPubky })); + writeFileSync(creatorSessionTestPath, JSON.stringify({ role: 'content-creator', pubky: firstCreatorPubky, exported_session: 'old-secret' })); assert.equal(await readCreatorDemoSessionForCurrentRole({ sessionPath: creatorSessionTestPath, profilePath: creatorProfileTestPath, @@ -401,14 +403,14 @@ try { await assert.rejects( writeCreatorDemoSessionForCurrentRole( - { role: 'content-creator', pubky: 'creator-a', exported_session: 'late-old-secret' }, + { role: 'content-creator', pubky: firstCreatorPubky, exported_session: 'late-old-secret' }, { sessionPath: creatorSessionTestPath, profilePath: creatorProfileTestPath }, ), /creator identity changed during demo authentication/, ); assert.equal(existsSync(creatorSessionTestPath), false); - const currentSession = { role: 'content-creator', pubky: 'creator-b', exported_session: 'current-secret' }; + const currentSession = { role: 'content-creator', pubky: secondCreatorPubky, exported_session: 'current-secret' }; await writeCreatorDemoSessionForCurrentRole(currentSession, { sessionPath: creatorSessionTestPath, profilePath: creatorProfileTestPath, @@ -421,6 +423,18 @@ try { currentSession, ); assert.equal(statSync(creatorSessionTestPath).mode & 0o777, 0o600); + const externalSession = { role: 'content-creator', pubky: firstCreatorPubky, exported_session: 'external-secret' }; + await writeCreatorDemoSessionForCurrentRole(externalSession, { + sessionPath: creatorSessionTestPath, + profilePath: null, + }); + assert.deepEqual( + await readCreatorDemoSessionForCurrentRole({ + sessionPath: creatorSessionTestPath, + profilePath: null, + }), + externalSession, + ); } finally { rmSync(creatorSessionTestDir, { recursive: true, force: true }); } @@ -965,8 +979,15 @@ const guardedResponse = new Response(new TextEncoder().encode('payment unlocked' }); const decodedGuarded = await decodeGuardedContentResponse(guardedResponse); assert.equal(decodedGuarded.contentType, 'text/plain; charset=utf-8'); +assert.equal(decodedGuarded.kind, 'text'); assert.equal(decodedGuarded.text, 'payment unlocked'); assert.equal(decodedGuarded.size, 16); +const decodedImage = await decodeGuardedContentResponse(new Response(Uint8Array.of(1, 2, 3), { + headers: { 'content-type': 'image/png' }, +})); +assert.equal(decodedImage.kind, 'image'); +assert.equal(decodedImage.text, null); +assert.deepEqual([...decodedImage.bytes], [1, 2, 3]); const { buildReaderHelperInput, From c6fcbde22c427e8a6844769b479211c27fd9f1dc Mon Sep 17 00:00:00 2001 From: benk10 Date: Thu, 6 Aug 2026 08:20:49 -0500 Subject: [PATCH 07/14] fix: complete external wallet Paykit demo --- compose.paykit-local-demo.yaml | 11 ++++--- docker/locks-server-compose-entrypoint.sh | 2 +- examples/js-sdk/reader-app.js | 2 ++ examples/js-sdk/reader.html | 4 +-- examples/js-sdk/scripts/start-demo-server.mjs | 2 +- .../scripts/start-reader-demo-server.mjs | 9 +++++- locks-server/src/app_state/pubky_clients.rs | 30 +++++++------------ locks-server/src/app_state/test_support.rs | 17 ++--------- 8 files changed, 33 insertions(+), 44 deletions(-) diff --git a/compose.paykit-local-demo.yaml b/compose.paykit-local-demo.yaml index d3b4930..2b4c91f 100644 --- a/compose.paykit-local-demo.yaml +++ b/compose.paykit-local-demo.yaml @@ -281,11 +281,11 @@ services: paykit-server: image: pubky-locks-paykit-server:local build: - context: "https://github.com/pubky/paykit-server.git#f38c7915e6b9b104e040773e78438f8aa984c46c" + context: "https://github.com/pubky/paykit-server.git#4d6992fa3a7dd14479775b2d75f06cdb43f857ea" dockerfile: Dockerfile.local additional_contexts: - paykit-lib: "https://github.com/pubky/paykit-rs.git#52a852995bfc457b78d32f5a45f6741766a89bba:paykit-lib" - paykit-sdk: "https://github.com/pubky/paykit-rs.git#52a852995bfc457b78d32f5a45f6741766a89bba:paykit-sdk" + paykit-lib: "https://github.com/pubky/paykit-rs.git#6b241878a9bba5cecea919c0298c3f90624be6ff:paykit-lib" + paykit-sdk: "https://github.com/pubky/paykit-rs.git#6b241878a9bba5cecea919c0298c3f90624be6ff:paykit-sdk" locks: "https://github.com/pubky/locks.git#df5ea1b6d8dcdec3a9b5a915c3f57bca69d75c8a" depends_on: @@ -393,6 +393,7 @@ services: PAYKIT_READER_RECEIVER_PATH: bitkit/wallet PAYKIT_READER_SERVER_PATH: bitkit/server PAYKIT_READER_WORKER_ENABLED: "1" + PAYKIT_EXTERNAL_READER_PUBKY: ${PAYKIT_EXTERNAL_READER_PUBKY:-} PUBKY_LOCK_DEBUG: ${PUBKY_LOCK_DEBUG:-0} volumes: - ./.local/demo-config:/workspace/.local/demo-config:ro @@ -403,7 +404,9 @@ services: - sh - -euc - | - npm --prefix examples/js-sdk run create-user -- --role content-viewer + if [ -z "$PAYKIT_EXTERNAL_READER_PUBKY" ]; then + npm --prefix examples/js-sdk run create-user -- --role content-viewer + fi exec node examples/js-sdk/scripts/start-reader-demo-server.mjs healthcheck: test: diff --git a/docker/locks-server-compose-entrypoint.sh b/docker/locks-server-compose-entrypoint.sh index c7b015c..ccd040e 100644 --- a/docker/locks-server-compose-entrypoint.sh +++ b/docker/locks-server-compose-entrypoint.sh @@ -102,7 +102,7 @@ public_ip = "127.0.0.1" public_pubky_tls_port = 6287 public_icann_http_port = 3000 icann_domain = "localhost" -pkarr_relays = ["http://localhost:15411"] +pkarr_relays = ["http://127.0.0.1:15411"] key_republisher_interval_seconds = 86400 [rate_limits.verification_submission] diff --git a/examples/js-sdk/reader-app.js b/examples/js-sdk/reader-app.js index 19753d7..3c42374 100644 --- a/examples/js-sdk/reader-app.js +++ b/examples/js-sdk/reader-app.js @@ -85,6 +85,8 @@ async function bootstrap() { el.configStatus.textContent = `Reader demo using Lock Server ${state.config.lockServer.pubky}`; el.configStatus.className = 'ok'; restoreState(); + const resource = new URL(window.location.href).searchParams.get('resource')?.trim(); + if (resource) state.resource = resource; bindEvents(); await refreshPaykitReaderStatus(); render(); diff --git a/examples/js-sdk/reader.html b/examples/js-sdk/reader.html index 2419fdf..b3e01d2 100644 --- a/examples/js-sdk/reader.html +++ b/examples/js-sdk/reader.html @@ -59,9 +59,9 @@

2. Submit proof bundle