From 2403278b208760b9ad0fbfac99d4058c3e3c5c2a Mon Sep 17 00:00:00 2001 From: dzdidi Date: Tue, 4 Aug 2026 08:26:24 -0300 Subject: [PATCH 01/12] 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/12] 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/authenticate-paykit.mjs b/examples/js-sdk/scripts/authenticate-paykit.mjs index b42323b..9d45ebf 100644 --- a/examples/js-sdk/scripts/authenticate-paykit.mjs +++ b/examples/js-sdk/scripts/authenticate-paykit.mjs @@ -1,5 +1,6 @@ #!/usr/bin/env node import { spawn } from 'node:child_process'; +import { existsSync } from 'node:fs'; import { createInterface } from 'node:readline/promises'; import { stdin, stdout } from 'node:process'; import { fileURLToPath } from 'node:url'; @@ -9,6 +10,7 @@ import { parseArgs, requiredRole } from './lib/paths.mjs'; import { loadRoleSecret } from './lib/pubky.mjs'; const DEFAULT_HELPER_PATH = '/usr/local/bin/paykit-companion-auth'; +const COMPOSE_HELPER_PATH = fileURLToPath(new URL('./paykit-companion-auth-compose.sh', import.meta.url)); const DEFAULT_TIMEOUT_MS = 240_000; const DEFAULT_KILL_GRACE_MS = 2_000; const MAX_INPUT_BYTES = 16 * 1024; @@ -121,6 +123,18 @@ export function companionResultCategory(result) { return { exitCode: 1, stream: 'stderr', message: 'Paykit companion authentication failed.' }; } +export function resolveCompanionHelperPath({ + env = process.env, + nativeHelperPath = DEFAULT_HELPER_PATH, + composeHelperPath = COMPOSE_HELPER_PATH, + nativeHelperAvailable = existsSync, +} = {}) { + if (typeof env.PAYKIT_COMPANION_AUTH_BIN === 'string' && env.PAYKIT_COMPANION_AUTH_BIN) { + return env.PAYKIT_COMPANION_AUTH_BIN; + } + return nativeHelperAvailable(nativeHelperPath) ? nativeHelperPath : composeHelperPath; +} + export async function runBoundedHelper({ helperPath, helperArgs = [], @@ -265,7 +279,7 @@ export async function runBoundedHelper({ } export async function runCompanionHelper({ - helperPath = process.env.PAYKIT_COMPANION_AUTH_BIN || DEFAULT_HELPER_PATH, + helperPath = resolveCompanionHelperPath(), input, timeoutMs = DEFAULT_TIMEOUT_MS, killGraceMs = DEFAULT_KILL_GRACE_MS, 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-config.mjs b/examples/js-sdk/scripts/init-config.mjs index 735f292..dc709b7 100644 --- a/examples/js-sdk/scripts/init-config.mjs +++ b/examples/js-sdk/scripts/init-config.mjs @@ -3,7 +3,7 @@ import { demoConfigPath, parseArgs, writeJson } from './lib/paths.mjs'; import { buildDefaultDemoConfig } from './lib/config.mjs'; // Defaults: read lock_server_public_key from ~/.pubky-lock/config.toml and write ./.local/demo-config/config.json. -// Local testnet defaults: http://localhost:15411, http://localhost:15412, localhost:6881. +// Local testnet defaults: http://127.0.0.1:15411, http://127.0.0.1:15412, 127.0.0.1:6881. const args = parseArgs(); const output = typeof args.output === 'string' ? args.output : demoConfigPath; 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/config.mjs b/examples/js-sdk/scripts/lib/config.mjs index 3255d0d..e3f1eeb 100644 --- a/examples/js-sdk/scripts/lib/config.mjs +++ b/examples/js-sdk/scripts/lib/config.mjs @@ -9,7 +9,7 @@ export const defaultLockServerConfigPath = '~/.pubky-lock/config.toml'; export const defaultDemoConfig = { demoServer: { - url: 'http://localhost:8080', + url: 'http://127.0.0.1:8080', }, lockServer: { url: 'http://127.0.0.1:3000', @@ -17,13 +17,13 @@ export const defaultDemoConfig = { configPath: defaultLockServerConfigPath, }, paykit: { - url: 'http://localhost:3001', + url: 'http://127.0.0.1:3001', }, testnet: { homeserver: 'pubky8pinxxgqs41n4aididenw5apqp1urfmzdztr8jt4abrkdn435ewo', - httpRelay: 'http://localhost:15412', - pkarrRelay: 'http://localhost:15411', - dhtBootstrap: 'localhost:6881', + httpRelay: 'http://127.0.0.1:15412', + pkarrRelay: 'http://127.0.0.1:15411', + dhtBootstrap: '127.0.0.1:6881', }, }; @@ -155,7 +155,7 @@ listen_addr = "0.0.0.0:3001" trusted_public_key = "${lockServerPubky}" [setup] -allowed_origins = ["http://localhost:8080"] +allowed_origins = ["http://127.0.0.1:8080", "http://localhost:8080"] [paykit] receiver_path = "bitkit/server" 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/lib/paykit-reader-helper.mjs b/examples/js-sdk/scripts/lib/paykit-reader-helper.mjs index 09420fb..4d56cfe 100644 --- a/examples/js-sdk/scripts/lib/paykit-reader-helper.mjs +++ b/examples/js-sdk/scripts/lib/paykit-reader-helper.mjs @@ -18,8 +18,9 @@ const PREPARE_TIMEOUT_MS = 120_000; const RECEIVE_TIMEOUT_MS = 310_000; const REGISTRATION_TIMEOUT_MS = 30_000; const REGISTRATION_SCRIPT = fileURLToPath(new URL('../register-paykit-reader.mjs', import.meta.url)); -const COMPOSE_FILE = './compose.paykit-local-demo.yaml'; +const COMPOSE_FILE = 'compose.paykit-local-demo.yaml'; const COMPOSE_COMMAND = `docker compose --file ${COMPOSE_FILE}`; +const OPERATOR_BITCOIN_CLI = 'bitcoin-cli -conf=/home/bitcoin/.bitcoin/bitcoin.conf -regtest -rpcwallet=miner'; const REQUIRED_ENV = [ 'PAYKIT_READER_STATE_PATH', 'PAYKIT_READER_PUBKY_TESTNET_HOST', @@ -28,6 +29,7 @@ const REQUIRED_ENV = [ 'PAYKIT_READER_SERVER_PATH', ]; const MINING_COMMAND = "docker compose exec -T bitcoin sh -ec 'bitcoin-cli -conf=\"$BITCOIN_DATA/bitcoin.conf\" -regtest -rpcwallet=miner generatetoaddress 6 \"$(bitcoin-cli -conf=\"$BITCOIN_DATA/bitcoin.conf\" -regtest -rpcwallet=miner getnewaddress)\"'"; +const OPERATOR_MINING_COMMAND = `${COMPOSE_COMMAND} exec -T bitcoin sh -ec '${OPERATOR_BITCOIN_CLI} generatetoaddress 6 $(${OPERATOR_BITCOIN_CLI} getnewaddress)'`; const FAILURE_CODES = new Set([ 'invalid_input', 'invalid_config', @@ -131,7 +133,7 @@ export function parseReaderHelperSuccess({ operation, stdout }) { || value.status !== 'received' || !/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/.test(value.payment_request_id) || !/^bcrt1[02-9ac-hj-np-z]{8,86}$/.test(value.address) - || value.asset !== 'BTC' + || value.asset !== 'btc' || !/^[1-9][0-9]*$/.test(value.amount_sats) || value.optional_mining_command !== MINING_COMMAND ) { @@ -147,30 +149,23 @@ export function parseReaderHelperSuccess({ operation, stdout }) { } return { ...value, - payment_command: `${COMPOSE_COMMAND}${value.payment_command.slice('docker compose'.length)}`, - optional_mining_command: `${COMPOSE_COMMAND}${value.optional_mining_command.slice('docker compose'.length)}`, + asset: 'BTC', + payment_command: `${COMPOSE_COMMAND} exec -T bitcoin sh -ec '${OPERATOR_BITCOIN_CLI} sendtoaddress ${value.address} ${paymentMatch[2]}'`, + optional_mining_command: OPERATOR_MINING_COMMAND, }; } export function validateReaderOperatorResult(value) { - const commandPrefix = `${COMPOSE_COMMAND} exec`; + const payment = /^docker compose --file compose\.paykit-local-demo\.yaml exec -T bitcoin sh -ec 'bitcoin-cli -conf=\/home\/bitcoin\/\.bitcoin\/bitcoin\.conf -regtest -rpcwallet=miner sendtoaddress (bcrt1[02-9ac-hj-np-z]{8,86}) ((?:0|[1-9][0-9]*)(?:\.[0-9]{1,8})?)'$/.exec(value?.payment_command); if ( !value - || typeof value.payment_command !== 'string' - || typeof value.optional_mining_command !== 'string' - || !value.payment_command.startsWith(commandPrefix) - || !value.optional_mining_command.startsWith(commandPrefix) + || !payment + || payment[1] !== value.address + || btcToSats(payment[2]) !== BigInt(value.amount_sats) + || value.optional_mining_command !== OPERATOR_MINING_COMMAND ) { throw new Error('invalid reader helper output'); } - parseReaderHelperSuccess({ - operation: 'receive', - stdout: `${JSON.stringify({ - ...value, - payment_command: `docker compose${value.payment_command.slice(COMPOSE_COMMAND.length)}`, - optional_mining_command: `docker compose${value.optional_mining_command.slice(COMPOSE_COMMAND.length)}`, - })}\n`, - }); return value; } @@ -191,15 +186,23 @@ export function requireReaderEnvironment(env = process.env) { } } +export async function readReaderCreatorProfile({ + env = process.env, + readProfile = readJson, + loadProfile = loadRoleProfile, +} = {}) { + return env.PAYKIT_READER_CREATOR_PROFILE_PATH + ? readProfile(env.PAYKIT_READER_CREATOR_PROFILE_PATH) + : loadProfile('content-creator'); +} + export async function resolveReaderEnvironment({ env = process.env, loadProfile = loadRoleProfile, } = {}) { const resolved = { ...env }; if (!resolved.PAYKIT_READER_SERVER_PUBKY) { - const profile = resolved.PAYKIT_READER_CREATOR_PROFILE_PATH - ? await readJson(resolved.PAYKIT_READER_CREATOR_PROFILE_PATH).catch(() => undefined) - : await loadProfile('content-creator').catch(() => undefined); + const profile = await readReaderCreatorProfile({ env: resolved, loadProfile }).catch(() => undefined); if (profile?.role !== 'content-creator' || !isCanonicalPubky(profile.pubky)) { throw new Error('valid content-creator profile is required for Paykit reader setup'); } diff --git a/examples/js-sdk/scripts/lib/paykit-reader-status.mjs b/examples/js-sdk/scripts/lib/paykit-reader-status.mjs index 49da7e4..33d99f0 100644 --- a/examples/js-sdk/scripts/lib/paykit-reader-status.mjs +++ b/examples/js-sdk/scripts/lib/paykit-reader-status.mjs @@ -96,6 +96,9 @@ export function validatePaykitReaderWorkerStatus(value) { if (value.state === 'starting' && exactKeys(value, ['version', 'state'])) { return Object.freeze({ ...value }); } + if (value.state === 'waiting_for_creator' && exactKeys(value, ['version', 'state'])) { + return Object.freeze({ ...value }); + } if ( value.state === 'waiting' && exactKeys(value, ['version', 'state', 'reader_pubky']) @@ -165,7 +168,12 @@ export async function readPaykitReaderWorkerStatus(path = paykitReaderWorkerStat } } -export function buildPaykitReaderBrowserStatus(worker, profile, { currentOwner = false } = {}) { +export function buildPaykitReaderBrowserStatus( + worker, + profile, + { currentOwner = false, waitingForCreator = false } = {}, +) { + if (waitingForCreator) return { version: 1, state: 'waiting_for_creator' }; if (!currentOwner) return { version: 1, state: 'starting' }; if (!worker) return { version: 1, state: 'starting' }; if ( diff --git a/examples/js-sdk/scripts/lib/paykit-reader-worker.mjs b/examples/js-sdk/scripts/lib/paykit-reader-worker.mjs index d84ea6a..efa05e4 100644 --- a/examples/js-sdk/scripts/lib/paykit-reader-worker.mjs +++ b/examples/js-sdk/scripts/lib/paykit-reader-worker.mjs @@ -93,6 +93,37 @@ export async function supervisePaykitReaderWorker(task, { onTerminalFailure } = return result; } +export async function waitForCreatorProfile({ + signal, + readProfile, + wait = waitForDelay, +} = {}) { + if (!signal || typeof readProfile !== 'function') { + throw new Error('Paykit reader creator wait dependencies are incomplete'); + } + while (!signal.aborted) { + let profile; + try { + profile = await readProfile(); + } catch (error) { + if (error?.code !== 'ENOENT') throw error; + profile = null; + } + if (profile === null) { + await wait(INITIAL_RETRY_MS, signal); + continue; + } + if ( + profile.role !== 'content-creator' + || !/^pubky[ybndrfg8ejkmcpqxot1uwisza345h769]{52}$/.test(profile.pubky) + ) { + throw new Error('Paykit reader creator profile is invalid'); + } + return profile; + } + return null; +} + export async function runPaykitReaderWorker({ signal, runOperation = runReaderOperation, diff --git a/examples/js-sdk/scripts/paykit-companion-auth-compose.sh b/examples/js-sdk/scripts/paykit-companion-auth-compose.sh new file mode 100755 index 0000000..0dd98ab --- /dev/null +++ b/examples/js-sdk/scripts/paykit-companion-auth-compose.sh @@ -0,0 +1,16 @@ +#!/bin/sh +set -eu + +if [ "$#" -ne 0 ]; then + echo "paykit-companion-auth-compose does not accept arguments" >&2 + exit 2 +fi + +script_dir=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) +repo_root=$(CDPATH= cd -- "$script_dir/../../.." && pwd) + +exec docker compose \ + --project-directory "$repo_root" \ + --file "$repo_root/compose.paykit-local-demo.yaml" \ + exec -T creator-demo \ + /usr/local/bin/paykit-companion-auth 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..42ad8a0 100644 --- a/examples/js-sdk/scripts/smoke-paykit-compose.mjs +++ b/examples/js-sdk/scripts/smoke-paykit-compose.mjs @@ -10,7 +10,7 @@ import { } from './lib/config.mjs'; import { repoRoot, writeSecret } from './lib/paths.mjs'; import { initializePaykitCompose } from './init-paykit-compose.mjs'; -import { resolveReaderEnvironment } from './lib/paykit-reader-helper.mjs'; +import { readReaderCreatorProfile, resolveReaderEnvironment } from './lib/paykit-reader-helper.mjs'; import { extractBip84AccountXpub } from './generate-paykit-account-tpub.mjs'; import { resolveCreatorStaticPath } from './lib/creator-static-path.mjs'; import { publishCreatorProfile } from './publish-creator-profile.mjs'; @@ -26,7 +26,9 @@ assert.match(composeSource, /^# Local development and demonstration only;/); assert.match(composeSource, /^name: pubky-locks-paykit-demo$/m); assert.match(creatorAppSource, /hasExactKeys\(event\.data, \['type', 'state', 'code'\]\)/); assert.match(creatorAppSource, /body: JSON\.stringify\(\{ level \}\)/); +assert.match(creatorAppSource, /\[result\.authorizationUrl, result\.command\]\.filter\(Boolean\)/); assert.doesNotMatch(creatorServerSource, /JSON\.stringify\(entry\)|url\.search/); +assert.match(creatorServerSource, /if \(!externalWallet\) \{\s+result\.command =/); assert.doesNotMatch(lockAuthoritySource, /dev: legacy-connect authorization URL/); assert.match(defaultComposeSource, /^services:/); assert.doesNotMatch(defaultComposeSource, /^ paykit-server:/m); @@ -89,7 +91,7 @@ await assert.rejects( /content-creator profile/, ); const config = buildPaykitServerConfig({ lockServerPubky }); -assert.equal(config, `[http]\nlisten_addr = "0.0.0.0:3001"\n\n[locks]\ntrusted_public_key = "${lockServerPubky}"\n\n[setup]\nallowed_origins = ["http://localhost:8080"]\n\n[paykit]\nreceiver_path = "bitkit/server"\nreceiver_path_priority = ["bitkit"]\nnetwork = "testnet"\n\n[bitcoin]\nnetwork = "regtest"\n\n[electrum]\nendpoint = "tcp://fulcrum:50001"\npoll_interval = "1s"\nrequest_timeout = "10s"\nconnect_retries = 1\n\n[outbox]\npoll_interval = "500ms"\nbatch_size = 16\nlease_duration = "30s"\nretry_initial = "1s"\nretry_max = "5m"\n`); +assert.equal(config, `[http]\nlisten_addr = "0.0.0.0:3001"\n\n[locks]\ntrusted_public_key = "${lockServerPubky}"\n\n[setup]\nallowed_origins = ["http://127.0.0.1:8080", "http://localhost:8080"]\n\n[paykit]\nreceiver_path = "bitkit/server"\nreceiver_path_priority = ["bitkit"]\nnetwork = "testnet"\n\n[bitcoin]\nnetwork = "regtest"\n\n[electrum]\nendpoint = "tcp://fulcrum:50001"\npoll_interval = "1s"\nrequest_timeout = "10s"\nconnect_retries = 1\n\n[outbox]\npoll_interval = "500ms"\nbatch_size = 16\nlease_duration = "30s"\nretry_initial = "1s"\nretry_max = "5m"\n`); assert.throws(() => buildPaykitServerConfig({ lockServerPubky: 'invalid' }), /Lock Server Pubky/); assert.deepEqual(validatePaykitComposeEnvironment({ PAYKIT_DATABASE_URL: 'postgres://paykit:secret@paykit-postgres:5432/paykit', @@ -151,6 +153,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 +172,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`); } @@ -233,6 +237,13 @@ try { role: 'content-creator', pubky: creatorPubky, }); + assert.deepEqual(await readReaderCreatorProfile({ + env: { PAYKIT_READER_CREATOR_PROFILE_PATH: publicProfilePath }, + loadProfile: async () => { throw new Error('private creator profile must not be loaded'); }, + }), { + role: 'content-creator', + pubky: creatorPubky, + }); const readerFromPublicProfile = await resolveReaderEnvironment({ env: { ...readerBaseEnvironment, @@ -241,6 +252,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 +285,7 @@ for (const service of [ 'bitcoin-bootstrap:', 'fulcrum:', 'electrum-readiness:', + 'homegate-bridge:', 'paykit-config:', 'demo-config:', 'paykit-server:', @@ -279,12 +299,14 @@ for (const required of [ 'node:22-bookworm-slim@sha256:813a7480f28fdadac1f7f5c824bcdad435b5bc1322a5968bbbdef8d058f9dff4', 'additional_contexts:', 'PUBKY_CORE_REV: 75eb1324f86e8caa16c41f18a2cd6b8e1909ee7b', - 'https://github.com/pubky/paykit-server.git#f38c7915e6b9b104e040773e78438f8aa984c46c', - 'https://github.com/pubky/paykit-rs.git#52a852995bfc457b78d32f5a45f6741766a89bba:paykit-lib', - 'https://github.com/pubky/paykit-rs.git#52a852995bfc457b78d32f5a45f6741766a89bba:paykit-sdk', + 'https://github.com/pubky/paykit-server.git#5ed3e8e849a16045c26c37a75068625dda333785', + 'https://github.com/pubky/paykit-rs.git#6b241878a9bba5cecea919c0298c3f90624be6ff:paykit-lib', + 'https://github.com/pubky/paykit-rs.git#6b241878a9bba5cecea919c0298c3f90624be6ff:paykit-sdk', '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 +319,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 +350,12 @@ 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('rm -f /workspace/.local/creator-public/profile.json'), 'creator must clear stale public identity before external wallet auth'); +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 +363,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..0ac43ea 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); @@ -39,6 +41,8 @@ if (allowUnhealthy && preflightStatus.checks.some((check) => !check.ok)) { console.warn('Starting despite unhealthy preflight because --allow-unhealthy was provided.'); } +if (externalWallet) await readCurrentCreatorSession(); + const server = createServer(async (request, response) => { try { const url = new URL(request.url, config.demoServer.url); @@ -105,17 +109,19 @@ 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) => { - console.error('demo auth failed'); + console.error(`demo auth failed: ${error instanceof Error ? error.message : String(error)}`); }) .finally(() => { activeDemoAuthFlow = null; @@ -124,17 +130,20 @@ async function startDemoAuth() { demoAuthPromise = null; }); } - return { + const result = { authenticated: false, role: 'content-creator', authorizationUrl: activeDemoAuthUrl, startedAt: activeDemoAuthStartedAt, - command: `npm --prefix examples/js-sdk run authenticate -- --role content-creator --auth "${activeDemoAuthUrl}"`, }; + if (!externalWallet) { + result.command = `npm --prefix examples/js-sdk run authenticate -- --role content-creator --auth "${activeDemoAuthUrl}"`; + } + return result; } 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 +166,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 +247,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..12ad8ef 100644 --- a/examples/js-sdk/scripts/start-reader-demo-server.mjs +++ b/examples/js-sdk/scripts/start-reader-demo-server.mjs @@ -6,9 +6,11 @@ import { extname, join, normalize } from 'node:path'; import { readDemoConfig, validateDemoConfig, pubkyAuthRelayInboxUrl, withInternalServiceUrls } from './lib/config.mjs'; import { examplesRoot, parseArgs, readJson, repoRoot, roleProfilePath } from './lib/paths.mjs'; import { resolveExistingPathWithin } from './lib/creator-static-path.mjs'; +import { readReaderCreatorProfile } from './lib/paykit-reader-helper.mjs'; import { runPaykitReaderWorker, supervisePaykitReaderWorker, + waitForCreatorProfile, } from './lib/paykit-reader-worker.mjs'; import { buildPaykitReaderBrowserStatus, @@ -24,9 +26,14 @@ const readerUrl = new URL(config.demoServer.url); readerUrl.port = String(args.port ?? 8081); const readerServerUrl = readerUrl.toString().replace(/\/$/, ''); const preflightStatus = await runPreflight(serviceConfig); -const workerEnabled = process.env.PAYKIT_READER_WORKER_ENABLED === '1'; +const externalReaderPubky = process.env.PAYKIT_EXTERNAL_READER_PUBKY?.trim() ?? ''; +if (externalReaderPubky && !/^pubky[ybndrfg8ejkmcpqxot1uwisza345h769]{52}$/.test(externalReaderPubky)) { + throw new Error('PAYKIT_EXTERNAL_READER_PUBKY must be a canonical Pubky'); +} +const workerEnabled = !externalReaderPubky && process.env.PAYKIT_READER_WORKER_ENABLED === '1'; const workerController = new AbortController(); let workerOwnsState = false; +let workerWaitingForCreator = workerEnabled; logStartupDiagnostics(config, preflightStatus); @@ -62,6 +69,7 @@ const server = createServer(async (request, response) => { if (request.method === 'GET' && url.pathname === '/api/paykit-reader/status') { const status = await publicPaykitReaderStatus({ currentOwner: workerEnabled && workerOwnsState, + waitingForCreator: workerWaitingForCreator, }); return sendJson(response, status, ['starting', 'failed'].includes(status.state) ? 503 : 200); } @@ -96,11 +104,7 @@ server.listen(Number(readerUrl.port), () => { const workerTask = workerEnabled ? supervisePaykitReaderWorker( - runPaykitReaderWorker({ - signal: workerController.signal, - writeWorkerStatus: writePaykitReaderWorkerStatus, - onOwnershipChange: (owned) => { workerOwnsState = owned; }, - }), + runWorkerAfterCreatorProfile(), { onTerminalFailure: handleTerminalWorkerFailure }, ) : Promise.resolve({ status: 'stopped' }); @@ -118,16 +122,34 @@ function publicBrowserConfig(source) { }; } +async function runWorkerAfterCreatorProfile() { + await waitForCreatorProfile({ + signal: workerController.signal, + readProfile: readReaderCreatorProfile, + }); + if (workerController.signal.aborted) return { status: 'stopped' }; + workerWaitingForCreator = false; + return runPaykitReaderWorker({ + signal: workerController.signal, + writeWorkerStatus: writePaykitReaderWorkerStatus, + onOwnershipChange: (owned) => { workerOwnsState = owned; }, + }); +} + export async function publicPaykitReaderStatus({ readWorker = readPaykitReaderWorkerStatus, readProfile = () => readJson(roleProfilePath('content-viewer')), currentOwner = false, + waitingForCreator = false, } = {}) { + if (externalReaderPubky) { + return { version: 1, state: 'waiting', reader_pubky: externalReaderPubky }; + } const [worker, profile] = await Promise.all([ Promise.resolve().then(readWorker).catch(() => null), Promise.resolve().then(readProfile).catch(() => null), ]); - return buildPaykitReaderBrowserStatus(worker, profile, { currentOwner }); + return buildPaykitReaderBrowserStatus(worker, profile, { currentOwner, waitingForCreator }); } async function handleTerminalWorkerFailure(error) { @@ -176,6 +198,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/test-paykit-reader-worker.mjs b/examples/js-sdk/scripts/test-paykit-reader-worker.mjs index b76e93d..a9fe8b0 100644 --- a/examples/js-sdk/scripts/test-paykit-reader-worker.mjs +++ b/examples/js-sdk/scripts/test-paykit-reader-worker.mjs @@ -13,6 +13,7 @@ import { acquirePaykitReaderOwnership, runPaykitReaderWorker, supervisePaykitReaderWorker, + waitForCreatorProfile, } from './lib/paykit-reader-worker.mjs'; import { buildPaykitReaderBrowserStatus, @@ -29,8 +30,8 @@ const received = { address: 'bcrt1qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqdku202', asset: 'BTC', amount_sats: '50000', - payment_command: "docker compose --file ./compose.paykit-local-demo.yaml exec -T bitcoin sh -ec 'bitcoin-cli -conf=\"$BITCOIN_DATA/bitcoin.conf\" -regtest -rpcwallet=miner sendtoaddress \"bcrt1qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqdku202\" \"0.00050000\"'", - optional_mining_command: "docker compose --file ./compose.paykit-local-demo.yaml exec -T bitcoin sh -ec 'bitcoin-cli -conf=\"$BITCOIN_DATA/bitcoin.conf\" -regtest -rpcwallet=miner generatetoaddress 6 \"$(bitcoin-cli -conf=\"$BITCOIN_DATA/bitcoin.conf\" -regtest -rpcwallet=miner getnewaddress)\"'", + payment_command: "docker compose --file compose.paykit-local-demo.yaml exec -T bitcoin sh -ec 'bitcoin-cli -conf=/home/bitcoin/.bitcoin/bitcoin.conf -regtest -rpcwallet=miner sendtoaddress bcrt1qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqdku202 0.00050000'", + optional_mining_command: "docker compose --file compose.paykit-local-demo.yaml exec -T bitcoin sh -ec 'bitcoin-cli -conf=/home/bitcoin/.bitcoin/bitcoin.conf -regtest -rpcwallet=miner generatetoaddress 6 $(bitcoin-cli -conf=/home/bitcoin/.bitcoin/bitcoin.conf -regtest -rpcwallet=miner getnewaddress)'", }; const operations = []; @@ -115,6 +116,35 @@ assert.deepEqual(parsePaykitReaderBrowserStatus({ reader_pubky: readerPubky, error: 'receive_timeout', }); +assert.deepEqual( + parsePaykitReaderBrowserStatus({ version: 1, state: 'waiting_for_creator' }), + { version: 1, state: 'waiting_for_creator' }, +); + +let creatorProfileReads = 0; +const creatorProfileWaits = []; +assert.deepEqual(await waitForCreatorProfile({ + signal: new AbortController().signal, + readProfile: async () => { + creatorProfileReads += 1; + if (creatorProfileReads < 3) { + const error = new Error('profile not published yet'); + error.code = 'ENOENT'; + throw error; + } + return { role: 'content-creator', pubky: readerPubky }; + }, + wait: async (milliseconds) => { creatorProfileWaits.push(milliseconds); }, +}), { role: 'content-creator', pubky: readerPubky }); +assert.equal(creatorProfileReads, 3); +assert.deepEqual(creatorProfileWaits, [1_000, 1_000]); +await assert.rejects( + waitForCreatorProfile({ + signal: new AbortController().signal, + readProfile: async () => { throw new SyntaxError('invalid profile JSON'); }, + }), + /invalid profile JSON/, +); const newerReceived = { ...received, @@ -256,6 +286,13 @@ try { ), { version: 1, state: 'starting' }, ); + assert.deepEqual( + buildPaykitReaderBrowserStatus(null, null, { + currentOwner: false, + waitingForCreator: true, + }), + { version: 1, state: 'waiting_for_creator' }, + ); } finally { await rm(statusRoot, { recursive: true, force: true }); } @@ -407,4 +444,4 @@ assert.deepEqual(await timedRegistration, { status: 'timeout' }); assert.equal(registrationTimedOutSignal, true); assert.equal(timedRegistrationCleanupComplete, true); -console.log('Paykit reader worker check passed'); \ No newline at end of file +console.log('Paykit reader worker check passed'); 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..5910ecc 100644 --- a/locks-sdk/bindings/js/scripts/smoke-examples.mjs +++ b/locks-sdk/bindings/js/scripts/smoke-examples.mjs @@ -27,6 +27,7 @@ const files = { createUser: join(examplesDir, 'scripts', 'create-user.mjs'), authenticate: join(examplesDir, 'scripts', 'authenticate.mjs'), authenticatePaykit: join(examplesDir, 'scripts', 'authenticate-paykit.mjs'), + composeCompanionHelper: join(examplesDir, 'scripts', 'paykit-companion-auth-compose.sh'), preparePaykitReader: join(examplesDir, 'scripts', 'prepare-paykit-reader.mjs'), receivePaykitRequest: join(examplesDir, 'scripts', 'receive-paykit-request.mjs'), registerPaykitReader: join(examplesDir, 'scripts', 'register-paykit-reader.mjs'), @@ -57,6 +58,8 @@ const required = { 'npm --prefix examples/js-sdk run init-config', 'npm --prefix examples/js-sdk run create-user -- --role content-creator', 'npm --prefix examples/js-sdk run authenticate -- --role content-creator', + 'docker compose --file compose.paykit-local-demo.yaml up -d --build', + 'http://localhost:8080/examples/js-sdk/', 'npm --prefix examples/js-sdk run start-server', 'npm --prefix examples/js-sdk run start-reader-server', './.local/demo-config/config.json', @@ -76,7 +79,7 @@ const required = { 'Paykit browser = http://localhost:3001', 'opens `GET http://localhost:3001/setup` in a Paykit-origin iframe', 'exact iframe window and origin with the pending state', - 'docker compose -f compose.paykit-local-demo.yaml exec creator-demo npm --prefix examples/js-sdk run authenticate-paykit -- --role content-creator', + 'npm --prefix examples/js-sdk run authenticate-paykit -- --role content-creator', 'Non-TTY stdin is exactly those three ordered lines', 'in-process Paykit reader worker starts with `reader-demo`', 'sole mutable owner of `./.local/paykit-reader/state.v1`', @@ -112,6 +115,7 @@ const required = { "from './creator-complete-flow.js'", 'POST /api/demo-auth/start', 'GET /api/demo-auth/status', + 'const returnTo = `${window.location.origin}/auth/lock-server/callback`', "deliveryUrl.searchParams.set('delivery', 'postmessage')", 'openLockAuthIframe(deliveryUrl.toString())', 'frame.src = connectUrl', @@ -153,7 +157,7 @@ const required = { 'el.paykitPaymentFields.hidden = !paymentSelected', 'el.paykitAmountSats.required = paymentSelected', 'openPaykitSetupIframe', - 'docker compose -f compose.paykit-local-demo.yaml exec creator-demo npm --prefix examples/js-sdk run authenticate-paykit -- --role content-creator', + 'npm --prefix examples/js-sdk run authenticate-paykit -- --role content-creator', 'acceptPaykitSetupEvent', 'state.paykitSetupComplete = true', "el.paykitSetupStatus.className = 'ok'", @@ -170,13 +174,14 @@ const required = { index: ['iframe modal', 'id="demo-auth"', 'id="creator-publishing"', '/examples/js-sdk/app.js', 'Select primary file', 'id="primary-content-file"', 'Select secondary files', 'id="secondary-content-files"', 'multiple', 'id="selected-resources"', 'id="selected-resource-list"', 'id="lock-type"', '', '', 'id="dev-static-fields"', 'id="paykit-payment-fields" hidden', 'id="paykit-amount-sats"', 'id="paykit-setup-status"', 'id="retry-paykit-setup"'], iframe: ['iframe modal', 'id="demo-auth"', 'id="creator-publishing"', '/examples/js-sdk/app-iframe.js', 'id="lock-type"', '', '', 'id="dev-static-fields"', 'id="paykit-payment-fields" hidden', 'id="paykit-amount-sats"', 'id="paykit-setup-status"', 'id="retry-paykit-setup"'], flows: ['Both creator pages use iframe auth', '/examples/js-sdk/', '/examples/js-sdk/iframe.html'], - readerHtml: ['id="content-lock-resource"', 'id="lock-resources"', 'id="primary-resource-list"', 'id="secondary-resource-list"', 'id="reset-reader-state"', 'id="read-content"', 'id="reader-public-key" readonly', 'id="refresh-paykit-reader"', 'id="paykit-reader-status"', 'id="paykit-reader-payment"', 'id="paykit-reader-commands"', 'id="poll-payment"', 'paykit-payment', 'Paykit reader worker runs automatically', '/reader-app.js'], - initConfig: ['~/.pubky-lock/config.toml', './.local/demo-config/config.json', 'lock_server_public_key', 'http://localhost:15411', 'http://localhost:15412', 'localhost:6881'], + readerHtml: ['id="content-lock-resource"', 'id="lock-resources"', 'id="primary-resource-list"', 'id="secondary-resource-list"', 'id="reset-reader-state"', 'id="read-content"', 'id="reader-public-key" readonly', 'id="refresh-paykit-reader"', 'id="paykit-reader-status"', 'id="paykit-reader-payment"', 'id="paykit-reader-commands"', 'id="poll-payment"', 'paykit-payment', 'Paykit reader identity is prepared automatically', '/reader-app.js'], + initConfig: ['~/.pubky-lock/config.toml', './.local/demo-config/config.json', 'lock_server_public_key', 'http://127.0.0.1:15411', 'http://127.0.0.1:15412', '127.0.0.1:6881'], createUser: ['requiredRole', 'Keypair.random()', 'createRecoveryFile', 'profile.json', '--force', 'content-creator', 'content-viewer', 'lock-server', 'clearPreparedReaderStatus', 'clearCreatorDemoSession'], authenticate: ['requiredRole', 'readAuthFromPrompt', 'signer.signup', 'approveAuthRequest', '--auth', 'already'], authenticatePaykit: [ 'PAYKIT_COMPANION_AUTH_BIN', '/usr/local/bin/paykit-companion-auth', + 'paykit-companion-auth-compose.sh', 'loadRoleSecret', 'content-creator', 'version: 1', @@ -191,6 +196,11 @@ const required = { "child.kill('SIGTERM')", "child.kill('SIGKILL')", ], + composeCompanionHelper: [ + 'compose.paykit-local-demo.yaml', + 'exec -T creator-demo', + '/usr/local/bin/paykit-companion-auth', + ], preparePaykitReader: ['runReaderOperation', "operation: 'prepare'", 'content-viewer', 'writePreparedReaderStatus', 'assertStandaloneReaderOperationAllowed', 'acquirePaykitReaderOwnership', 'ownership.release()'], receivePaykitRequest: ['runReaderOperation', "operation: 'receive'", 'content-viewer', 'assertStandaloneReaderOperationAllowed', 'acquirePaykitReaderOwnership', 'ownership.release()'], paykitReaderLib: [ @@ -214,7 +224,7 @@ const required = { startServer: ['createServer', '--allow-unhealthy', 'pubkyAuthRelayInboxUrl', '/api/demo-auth/start', '/api/demo-auth/status', '/config.json', 'awaitApproval', 'content-creator-session.json', 'readCreatorDemoSessionForCurrentRole', 'writeCreatorDemoSessionForCurrentRole', '/healthz', '/readyz', 'paykit: source.paykit'], startReaderServer: ['createServer', '--allow-unhealthy', 'runPaykitReaderWorker', 'supervisePaykitReaderWorker', 'workerOwnsState', 'handleTerminalWorkerFailure', 'writePaykitReaderWorkerStatus', 'readPaykitReaderWorkerStatus', 'AbortController', 'SIGTERM', '/reader/', '/config.json', '/api/health', '/api/preflight', '/api/debug/config', '/api/paykit-reader/status', '/api/client-log', "'cache-control': 'no-store'", '8081', 'never proxy'], pathsLib: ['localPath', 'roleDir', 'demoConfigPath', 'contentCreatorSessionPath', 'paykitReaderPreparedPath', 'paykitReaderOwnershipPath', 'prepared.v1.json', 'owner.lock'], - configLib: ['readDemoConfig', 'writeDemoConfig', 'parseLockServerTomlPublicKey', 'pubkyAuthRelayInboxUrl', 'validateDemoConfig', "url: 'http://localhost:3001'", "['paykit', 'url']"], + configLib: ['readDemoConfig', 'writeDemoConfig', 'parseLockServerTomlPublicKey', 'pubkyAuthRelayInboxUrl', 'validateDemoConfig', "url: 'http://127.0.0.1:3001'", "['paykit', 'url']"], pubkyLib: ["from '@synonymdev/pubky'", 'Pubky.testnet', 'Keypair.fromRecoveryFile', 'keypair.secret()', 'loadRoleSecret', 'AuthFlowKind', 'PublicKey.from'], creator: [ "from '../../locks-sdk/bindings/js/pkg/locks_sdk_wasm.js'", @@ -385,14 +395,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 +413,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 +433,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 +989,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, @@ -1002,15 +1033,16 @@ const receivedOutput = { status: 'received', payment_request_id: 'b7f9c2a1-6d43-4b0e-a8d4-0fe2c712ab33', address: 'bcrt1qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqdku202', - asset: 'BTC', + asset: 'btc', amount_sats: '50000', payment_command: "docker compose exec -T bitcoin sh -ec 'bitcoin-cli -conf=\"$BITCOIN_DATA/bitcoin.conf\" -regtest -rpcwallet=miner sendtoaddress \"bcrt1qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqdku202\" \"0.00050000\"'", optional_mining_command: "docker compose exec -T bitcoin sh -ec 'bitcoin-cli -conf=\"$BITCOIN_DATA/bitcoin.conf\" -regtest -rpcwallet=miner generatetoaddress 6 \"$(bitcoin-cli -conf=\"$BITCOIN_DATA/bitcoin.conf\" -regtest -rpcwallet=miner getnewaddress)\"'", }; const operatorReceivedOutput = { ...receivedOutput, - payment_command: receivedOutput.payment_command.replace('docker compose', 'docker compose --file ./compose.paykit-local-demo.yaml'), - optional_mining_command: receivedOutput.optional_mining_command.replace('docker compose', 'docker compose --file ./compose.paykit-local-demo.yaml'), + asset: 'BTC', + payment_command: `docker compose --file compose.paykit-local-demo.yaml exec -T bitcoin sh -ec 'bitcoin-cli -conf=/home/bitcoin/.bitcoin/bitcoin.conf -regtest -rpcwallet=miner sendtoaddress ${receivedOutput.address} 0.00050000'`, + optional_mining_command: "docker compose --file compose.paykit-local-demo.yaml exec -T bitcoin sh -ec 'bitcoin-cli -conf=/home/bitcoin/.bitcoin/bitcoin.conf -regtest -rpcwallet=miner generatetoaddress 6 $(bitcoin-cli -conf=/home/bitcoin/.bitcoin/bitcoin.conf -regtest -rpcwallet=miner getnewaddress)'", }; assert.deepEqual(parseReaderHelperSuccess({ operation: 'receive', @@ -1018,6 +1050,7 @@ assert.deepEqual(parseReaderHelperSuccess({ }), operatorReceivedOutput); for (const invalid of [ `${JSON.stringify({ ...receivedOutput, extra: true })}\n`, + `${JSON.stringify({ ...receivedOutput, asset: 'BTC' })}\n`, `${JSON.stringify({ ...receivedOutput, payment_command: 'echo unsafe' })}\n`, `${JSON.stringify({ ...receivedOutput, @@ -1042,12 +1075,29 @@ const { collectPaykitInputs, parsePaykitInputLines, requirePaykitCreatorRole, + resolveCompanionHelperPath, runCompanionHelper, } = await import(pathToFileURL(files.authenticatePaykit).href); const { Keypair, secretFromRecoveryFile } = await import(pathToFileURL(files.pubkyLib).href); const authUrl = 'pubkyauth://signin?secret=test-auth-secret'; const accountXpub = 'tpub-test-account-xpub'; +assert.equal(resolveCompanionHelperPath({ + env: {}, + nativeHelperPath: '/native/helper', + composeHelperPath: '/compose/helper', + nativeHelperAvailable: () => false, +}), '/compose/helper'); +assert.equal(resolveCompanionHelperPath({ + env: {}, + nativeHelperPath: '/native/helper', + composeHelperPath: '/compose/helper', + nativeHelperAvailable: () => true, +}), '/native/helper'); +assert.equal(resolveCompanionHelperPath({ + env: { PAYKIT_COMPANION_AUTH_BIN: '/override/helper' }, + nativeHelperAvailable: () => false, +}), '/override/helper'); const parsedLines = parsePaykitInputLines(`${authUrl}\n${accountXpub}\n7\n`); assert.deepEqual(parsedLines, { authUrl, accountXpub, accountIndex: 7 }); for (const invalid of [ diff --git a/locks-sdk/src/transport.rs b/locks-sdk/src/transport.rs index 67f6b42..785cfc6 100644 --- a/locks-sdk/src/transport.rs +++ b/locks-sdk/src/transport.rs @@ -1,4 +1,5 @@ use std::collections::BTreeMap; +use std::net::IpAddr; use crate::error::{LocksSdkError, Result}; use url::Url; @@ -51,7 +52,11 @@ fn apply_endpoint_to_url( .domain .as_deref() .ok_or(LocksSdkError::MissingBrowserEndpointDomain)?; - let is_testnet_domain = domain == "localhost" || testnet_host == Some(domain); + let is_testnet_domain = domain == "localhost" + || domain + .parse::() + .is_ok_and(|address| address.is_loopback()) + || testnet_host == Some(domain); if is_testnet_domain { url.set_scheme("http") @@ -171,6 +176,26 @@ mod tests { assert_eq!(request.url.as_str(), "http://localhost:55433/connect"); } + #[test] + fn browser_request_rewrite_uses_http_port_for_loopback_ip_endpoint() { + let mut params = BTreeMap::new(); + params.insert(HTTP_PORT_PARAM, 55433); + let endpoint = BrowserEndpoint { + domain: Some("127.0.0.1".to_owned()), + port: Some(443), + params, + }; + + let request = rewrite_browser_request( + "https://_pubky.pubky7ir1ttte48bcp4zjychjyscicrwi1j34mtt91ptsafdbjmr8g9eo/connect", + &endpoint, + None, + ) + .unwrap(); + + assert_eq!(request.url.as_str(), "http://127.0.0.1:55433/connect"); + } + #[test] fn browser_request_rewrite_requires_http_port_for_localhost_endpoint() { let endpoint = BrowserEndpoint { diff --git a/locks-server/src/app_state/pubky_clients.rs b/locks-server/src/app_state/pubky_clients.rs index 586e431..b7b2766 100644 --- a/locks-server/src/app_state/pubky_clients.rs +++ b/locks-server/src/app_state/pubky_clients.rs @@ -1,45 +1,35 @@ use crate::config::PubkyNetwork; -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub(super) enum PubkyClientConstructor { - Mainnet, - Testnet, -} +const LOCAL_TESTNET_HOST: &str = "127.0.0.1"; #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub(super) enum PubkyHttpClientConstructor { Mainnet, - Testnet, -} - -pub(super) fn pubky_client_constructor(network: PubkyNetwork) -> PubkyClientConstructor { - match network { - PubkyNetwork::Mainnet => PubkyClientConstructor::Mainnet, - PubkyNetwork::Testnet => PubkyClientConstructor::Testnet, - } + Testnet(&'static str), } pub(super) fn pubky_http_client_constructor(network: PubkyNetwork) -> PubkyHttpClientConstructor { match network { PubkyNetwork::Mainnet => PubkyHttpClientConstructor::Mainnet, - PubkyNetwork::Testnet => PubkyHttpClientConstructor::Testnet, + PubkyNetwork::Testnet => PubkyHttpClientConstructor::Testnet(LOCAL_TESTNET_HOST), } } pub(super) fn build_pubky_http_client(network: PubkyNetwork) -> pubky::PubkyHttpClient { match pubky_http_client_constructor(network) { PubkyHttpClientConstructor::Mainnet => pubky::PubkyHttpClient::new(), - PubkyHttpClientConstructor::Testnet => pubky::PubkyHttpClient::testnet(), + PubkyHttpClientConstructor::Testnet(host) => { + let mut builder = pubky::PubkyHttpClient::builder(); + builder.testnet_with_host(host); + builder.pkarr(|client| client.no_dht()); + builder.build() + } } .expect("Pubky HTTP client construction must succeed for Pubky runtime composition") } pub(super) fn build_pubky_client(network: PubkyNetwork) -> pubky::Pubky { - match pubky_client_constructor(network) { - PubkyClientConstructor::Mainnet => pubky::Pubky::new(), - PubkyClientConstructor::Testnet => pubky::Pubky::testnet(), - } - .expect("Pubky client construction must succeed for legacy connect flows") + pubky::Pubky::with_client(build_pubky_http_client(network)) } pub(super) fn pubky_auth_relay_for_network(network: PubkyNetwork) -> Option { diff --git a/locks-server/src/app_state/test_support.rs b/locks-server/src/app_state/test_support.rs index 94aa3c4..963b572 100644 --- a/locks-server/src/app_state/test_support.rs +++ b/locks-server/src/app_state/test_support.rs @@ -8,8 +8,7 @@ use sqlx::postgres::PgPoolOptions; use time::macros::datetime; use crate::app_state::pubky_clients::{ - PubkyClientConstructor, PubkyHttpClientConstructor, pubky_auth_relay_for_network, - pubky_client_constructor, pubky_http_client_constructor, + PubkyHttpClientConstructor, pubky_auth_relay_for_network, pubky_http_client_constructor, }; use crate::app_state::{AppState, OsRandomTaskIdGenerator, RuntimeStorageKind}; use crate::config::{ @@ -205,18 +204,6 @@ async fn postgres_state_has_rate_limiter_configured_from_runtime_config() { ); } -#[test] -fn pubky_client_constructor_follows_configured_network() { - assert_eq!( - pubky_client_constructor(PubkyNetwork::Mainnet), - PubkyClientConstructor::Mainnet - ); - assert_eq!( - pubky_client_constructor(PubkyNetwork::Testnet), - PubkyClientConstructor::Testnet - ); -} - #[test] fn pubky_http_client_constructor_follows_configured_network() { assert_eq!( @@ -225,7 +212,7 @@ fn pubky_http_client_constructor_follows_configured_network() { ); assert_eq!( pubky_http_client_constructor(PubkyNetwork::Testnet), - PubkyHttpClientConstructor::Testnet + PubkyHttpClientConstructor::Testnet("127.0.0.1") ); } diff --git a/locks-server/src/pkdns.rs b/locks-server/src/pkdns.rs index ae9f950..9871f65 100644 --- a/locks-server/src/pkdns.rs +++ b/locks-server/src/pkdns.rs @@ -120,7 +120,11 @@ pub fn create_signed_packet( let mut svcb = SVCB::new(10, root_name.clone()); if let Some(port) = config.public_icann_http_port { let http_port_be_bytes = port.to_be_bytes(); - if domain == "localhost" { + if domain == "localhost" + || domain + .parse::() + .is_ok_and(|address| address.is_loopback()) + { svcb.set_param(SVCParam::Unknown( pubky_common::constants::reserved_param_keys::HTTP_PORT, Cow::Owned(http_port_be_bytes.to_vec()), @@ -212,6 +216,39 @@ mod tests { })); } + #[test] + fn create_signed_packet_publishes_http_port_for_loopback_ip_domain() { + let keypair = Keypair::from_secret(&[9_u8; 32]); + let config = PkdnsConfig { + public_ip: IpAddr::V4(Ipv4Addr::LOCALHOST), + public_pubky_tls_port: Some(6287), + public_icann_http_port: Some(3000), + icann_domain: Some("127.0.0.1".to_owned()), + pkarr_relays: Vec::new(), + key_republisher_interval_seconds: 3600, + }; + + let packet = create_signed_packet(&config, &keypair).unwrap(); + let http_endpoint = packet + .all_resource_records() + .find_map(|record| match &record.rdata { + RData::HTTPS(https) if https.0.target.to_string().contains("127.0.0.1") => { + Some(https) + } + _ => None, + }) + .unwrap(); + + assert!(matches!( + http_endpoint + .0 + .iter_params() + .find(|param| param.key_code() + == pubky_common::constants::reserved_param_keys::HTTP_PORT), + Some(SVCParam::Unknown(_, value)) if value.as_ref() == 3000_u16.to_be_bytes() + )); + } + #[test] fn load_lock_server_keypair_rejects_secret_seed_that_does_not_match_configured_public_key() { let temp_dir = tempfile::tempdir().unwrap(); From 50491033420980b8c5a68a0e5d0b03cac86be7e7 Mon Sep 17 00:00:00 2001 From: DZ Date: Tue, 25 Aug 2026 10:26:54 -0300 Subject: [PATCH 07/12] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- examples/js-sdk/README.md | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/examples/js-sdk/README.md b/examples/js-sdk/README.md index 3f8be39..24ec249 100644 --- a/examples/js-sdk/README.md +++ b/examples/js-sdk/README.md @@ -133,11 +133,11 @@ The browser-facing demo config still uses `localhost`; container-internal health `pubky-core/pubky-testnet` local static development uses: ```text -PKARR relay = http://localhost:15411 -HTTP/auth relay = http://localhost:15412 -Pubky Auth inbox = http://localhost:15412/inbox/ -DHT bootstrap = localhost:6881 -Paykit browser = http://localhost:3001 +PKARR relay = http://127.0.0.1:15411 +HTTP/auth relay = http://127.0.0.1:15412 +Pubky Auth inbox = http://127.0.0.1:15412/inbox/ +DHT bootstrap = 127.0.0.1:6881 +Paykit browser = http://127.0.0.1:3001 ``` These values are written to: From 1e5fcc67a3f8e25c77757957bc54979e59d05616 Mon Sep 17 00:00:00 2001 From: DZ Date: Tue, 25 Aug 2026 10:27:11 -0300 Subject: [PATCH 08/12] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 764d1da..e1f6d10 100644 --- a/README.md +++ b/README.md @@ -65,7 +65,7 @@ and Fulcrum: docker compose --file compose.paykit-local-demo.yaml up -d --build ``` -2. Open the content-creator demo at . +2. Open the content-creator demo at . 3. 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` From 890f2d614b30f379a2669966fef88c158aa0e4b9 Mon Sep 17 00:00:00 2001 From: DZ Date: Tue, 25 Aug 2026 10:28:28 -0300 Subject: [PATCH 09/12] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .../scripts/lib/paykit-reader-worker.mjs | 27 ++++++++++++++----- 1 file changed, 21 insertions(+), 6 deletions(-) diff --git a/examples/js-sdk/scripts/lib/paykit-reader-worker.mjs b/examples/js-sdk/scripts/lib/paykit-reader-worker.mjs index efa05e4..59e2002 100644 --- a/examples/js-sdk/scripts/lib/paykit-reader-worker.mjs +++ b/examples/js-sdk/scripts/lib/paykit-reader-worker.mjs @@ -33,15 +33,30 @@ export async function acquirePaykitReaderOwnership( path = paykitReaderOwnershipPath, { spawnProcess = spawn } = {}, ) { - await mkdir(dirname(path), { recursive: true, mode: 0o700 }); - await chmod(dirname(path), 0o700); - const handle = await open(path, 'a', 0o600); - await handle.close(); - await chmod(path, 0o600); + const directory = dirname(path); + await mkdir(directory, { recursive: true, mode: 0o700 }); + + let existing; + try { + existing = await lstat(path); + } catch (error) { + if (error?.code !== 'ENOENT') throw error; + existing = null; + } + if (existing && (!existing.isFile() || existing.isSymbolicLink())) { + throw new Error('Paykit reader ownership lock must be a regular file'); + } + if (!existing) { + const created = await open(path, 'wx', 0o600); + await created.close(); + } + + await chmod(directory, 0o700); const metadata = await lstat(path); - if (!metadata.isFile() || (metadata.mode & 0o077) !== 0) { + if (!metadata.isFile() || metadata.isSymbolicLink() || (metadata.mode & 0o077) !== 0) { throw new Error('Paykit reader ownership lock permissions are unsafe'); } + await chmod(path, 0o600); const holder = spawnProcess('/usr/bin/flock', [ '--nonblock', From 6ea078f8957654d579058c65025b46acf5e80f1b Mon Sep 17 00:00:00 2001 From: dzdidi Date: Tue, 25 Aug 2026 10:58:38 -0300 Subject: [PATCH 10/12] match loopback localhost Signed-off-by: dzdidi --- compose.paykit-local-demo.yaml | 8 ++--- docker-compose.yml | 8 ++--- docs/RUNTIME.md | 2 +- docs/SDK.md | 10 +++--- docs/SDK_LIVE_SMOKE.md | 6 ++-- examples/js-sdk/README.md | 34 +++++++++---------- .../bindings/js/scripts/live-smoke-check.mjs | 4 +-- .../bindings/js/scripts/smoke-examples.mjs | 10 +++--- locks-server/src/app_state/pubky_clients.rs | 2 +- locks-server/src/app_state/test_support.rs | 2 +- 10 files changed, 43 insertions(+), 43 deletions(-) diff --git a/compose.paykit-local-demo.yaml b/compose.paykit-local-demo.yaml index 97391ae..eaf0fb3 100644 --- a/compose.paykit-local-demo.yaml +++ b/compose.paykit-local-demo.yaml @@ -348,8 +348,8 @@ services: network_mode: service:pubky-testnet environment: LOCKS_INTERNAL_LOCK_SERVER_URL: http://127.0.0.1:3000 - LOCKS_INTERNAL_HTTP_RELAY: http://localhost:15412 - LOCKS_INTERNAL_PKARR_RELAY: http://localhost:15411 + LOCKS_INTERNAL_HTTP_RELAY: http://127.0.0.1:15412 + LOCKS_INTERNAL_PKARR_RELAY: http://127.0.0.1:15411 LOCKS_INTERNAL_DHT_BOOTSTRAP: 127.0.0.1:6881 PAYKIT_COMPANION_AUTH_BIN: /usr/local/bin/paykit-companion-auth PUBKY_LOCK_DEBUG: ${PUBKY_LOCK_DEBUG:-0} @@ -384,8 +384,8 @@ services: network_mode: service:pubky-testnet environment: LOCKS_INTERNAL_LOCK_SERVER_URL: http://127.0.0.1:3000 - LOCKS_INTERNAL_HTTP_RELAY: http://localhost:15412 - LOCKS_INTERNAL_PKARR_RELAY: http://localhost:15411 + LOCKS_INTERNAL_HTTP_RELAY: http://127.0.0.1:15412 + LOCKS_INTERNAL_PKARR_RELAY: http://127.0.0.1:15411 LOCKS_INTERNAL_DHT_BOOTSTRAP: 127.0.0.1:6881 PAYKIT_READER_DEMO_BIN: /usr/local/bin/paykit-reader-demo PAYKIT_READER_STATE_PATH: /workspace/.local/paykit-reader/state.v1 diff --git a/docker-compose.yml b/docker-compose.yml index 2ac9475..3b3d0e1 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -70,8 +70,8 @@ services: network_mode: service:pubky-testnet environment: LOCKS_INTERNAL_LOCK_SERVER_URL: http://127.0.0.1:3000 - LOCKS_INTERNAL_HTTP_RELAY: http://localhost:15412 - LOCKS_INTERNAL_PKARR_RELAY: http://localhost:15411 + LOCKS_INTERNAL_HTTP_RELAY: http://127.0.0.1:15412 + LOCKS_INTERNAL_PKARR_RELAY: http://127.0.0.1:15411 LOCKS_INTERNAL_DHT_BOOTSTRAP: 127.0.0.1:6881 volumes: - .:/workspace @@ -99,8 +99,8 @@ services: network_mode: service:pubky-testnet environment: LOCKS_INTERNAL_LOCK_SERVER_URL: http://127.0.0.1:3000 - LOCKS_INTERNAL_HTTP_RELAY: http://localhost:15412 - LOCKS_INTERNAL_PKARR_RELAY: http://localhost:15411 + LOCKS_INTERNAL_HTTP_RELAY: http://127.0.0.1:15412 + LOCKS_INTERNAL_PKARR_RELAY: http://127.0.0.1:15411 LOCKS_INTERNAL_DHT_BOOTSTRAP: 127.0.0.1:6881 volumes: - .:/workspace diff --git a/docs/RUNTIME.md b/docs/RUNTIME.md index 5acb082..ee4d783 100644 --- a/docs/RUNTIME.md +++ b/docs/RUNTIME.md @@ -115,7 +115,7 @@ pkarr_relays = [] key_republisher_interval_seconds = 3600 ``` -`public_pubky_tls_port` and `public_icann_http_port` advertise externally reachable ports. `icann_domain` is browser/ICANN fallback target. Local testnet operators should set `pkarr_relays = ["http://localhost:15411"]`. +`public_pubky_tls_port` and `public_icann_http_port` advertise externally reachable ports. `icann_domain` is browser/ICANN fallback target. Local testnet operators should set `pkarr_relays = ["http://127.0.0.1:15411"]`. PKARR publishing starts when environment is `staging`/`production` or creator-authority acquisition is enabled, and republishes every `key_republisher_interval_seconds` seconds. diff --git a/docs/SDK.md b/docs/SDK.md index dbadc74..618497f 100644 --- a/docs/SDK.md +++ b/docs/SDK.md @@ -47,7 +47,7 @@ python3 -m http.server 8080 --directory locks-sdk/bindings/js Open: ```text -http://localhost:8080/demo/ +http://127.0.0.1:8080/demo/ ``` The demo imports `../pkg/locks_sdk_wasm.js`, so it only exercises locally generated wasm-pack output. It still requires a real configured Lock Server Pubky with a browser-usable PKARR domain endpoint and working `legacy-connect` creator acquisition. @@ -130,7 +130,7 @@ For local Pubky testnet browser development, configure the local PKARR relay exp import { Locks, LocksOptions } from "locks-sdk-wasm"; const options = new LocksOptions(); -options.addPkarrRelay("http://localhost:15411"); +options.addPkarrRelay("http://127.0.0.1:15411"); const locks = Locks.forServerWithOptions("pubky...", options); ``` @@ -138,9 +138,9 @@ const locks = Locks.forServerWithOptions("pubky...", options); Local `pubky-core/pubky-testnet` defaults are: ```text -PKARR relay = http://localhost:15411 -HTTP/auth relay = http://localhost:15412 -DHT bootstrap = localhost:6881 +PKARR relay = http://127.0.0.1:15411 +HTTP/auth relay = http://127.0.0.1:15412 +DHT bootstrap = 127.0.0.1:6881 ``` This remains the stable browser path. diff --git a/docs/SDK_LIVE_SMOKE.md b/docs/SDK_LIVE_SMOKE.md index a00271b..2b1c586 100644 --- a/docs/SDK_LIVE_SMOKE.md +++ b/docs/SDK_LIVE_SMOKE.md @@ -17,7 +17,7 @@ The command is informational and exits 0. It reports whether the following envir | Variable | Meaning | | --- | --- | | `LOCKS_LIVE_LOCK_SERVER` | Lock Server Pubky with a browser-usable PKARR endpoint | -| `LOCKS_LIVE_PKARR_RELAY` | PKARR relay URL. Local `pubky-testnet` uses `http://localhost:15411` | +| `LOCKS_LIVE_PKARR_RELAY` | PKARR relay URL. Local `pubky-testnet` uses `http://127.0.0.1:15411` | | `LOCKS_LIVE_CREATOR` | Creator Pubky publishing `/pub/locks.app/config.json` | | `LOCKS_LIVE_CONTENT_LOCK_RESOURCE` | Canonical `pubky.../pub/locks.app/.json` resource | | `LOCKS_LIVE_DEMO_ORIGIN` | Browser origin allowed by `creator_authority_acquisition.legacy_connect.allowed_return_origins` | @@ -34,7 +34,7 @@ python3 -m http.server 8080 --directory locks-sdk/bindings/js Open: ```text -http://localhost:8080/demo/ +http://127.0.0.1:8080/demo/ ``` Then verify: @@ -43,7 +43,7 @@ Then verify: ```ts const options = new LocksOptions(); - options.addPkarrRelay(process.env.LOCKS_LIVE_PKARR_RELAY ?? "http://localhost:15411"); + options.addPkarrRelay(process.env.LOCKS_LIVE_PKARR_RELAY ?? "http://127.0.0.1:15411"); ``` 2. `Locks.forServerWithOptions(lockServer, options)` displays the configured Lock Server Pubky. diff --git a/examples/js-sdk/README.md b/examples/js-sdk/README.md index 24ec249..1c3192b 100644 --- a/examples/js-sdk/README.md +++ b/examples/js-sdk/README.md @@ -56,10 +56,10 @@ Required tools/services: - Postgres reachable by the Lock Server. - A local Pubky testnet exposing: ```text - PKARR relay http://localhost:15411 - HTTP/auth relay http://localhost:15412 - Auth inbox http://localhost:15412/inbox/ - DHT bootstrap localhost:6881 + PKARR relay http://127.0.0.1:15411 + HTTP/auth relay http://127.0.0.1:15412 + Auth inbox http://127.0.0.1:15412/inbox/ + DHT bootstrap 127.0.0.1:6881 ``` For direct npm development, build the local WASM SDK package first: @@ -105,9 +105,9 @@ The compose stack starts: - Postgres on host port `55433` - Pubky testnet on `15411`, `15412`, `6881`, homeserver HTTP on `6286`, and homeserver admin on `6288` -- Lock Server on `http://localhost:3000` -- creator demo on `http://localhost:8080/examples/js-sdk/` -- reader demo on `http://localhost:8081/reader/` +- Lock Server on `http://127.0.0.1:3000` +- creator demo on `http://127.0.0.1:8080/examples/js-sdk/` +- reader demo on `http://127.0.0.1:8081/reader/` The Pubky testnet image is built from the public `pubky/pubky-core` repository at the revision pinned in `docker-compose.yml`; no sibling checkout is required. @@ -126,7 +126,7 @@ If writer authentication fails after a local testnet restart with a malformed or docker compose exec creator-demo npm --prefix examples/js-sdk run create-user -- --role content-creator --force ``` -The browser-facing demo config still uses `localhost`; container-internal health checks/auth use Docker service names through `LOCKS_INTERNAL_*` environment overrides. +The browser-facing demo config uses `127.0.0.1`; container-internal health checks/auth use explicit loopback or Docker service names through `LOCKS_INTERNAL_*` environment overrides. ## Local Pubky testnet defaults @@ -189,7 +189,7 @@ docker compose --file compose.paykit-local-demo.yaml up -d --build 2. Open the content-creator demo: ```text -http://localhost:8080/examples/js-sdk/ +http://127.0.0.1:8080/examples/js-sdk/ ``` 3. Approve browser requests with the external wallet under test. When using the local @@ -210,14 +210,14 @@ checkout is required. `compose.paykit-local-demo.yaml` is intentionally limited to local development and demonstration. When `.local` is absent, the one-shot `compose-bootstrap` service creates the ignored owner-only credentials and non-state configuration before dependent services start. Existing generated credentials are validated and reused. For a quiet configuration check without printing generated environment values, run `npm --prefix examples/js-sdk run validate:paykit-compose`; the wrapper inspects a captured `docker compose --file compose.paykit-local-demo.yaml config --no-env-resolution` model. -This starts separate Locks and Paykit PostgreSQL services, Bitcoin Core regtest, a 101-block wallet bootstrap, Fulcrum readiness through `server.version`, Pubky testnet, a local Homegate-compatible signup bridge, Locks, Paykit Server, and both browser demos. All published ports bind to host loopback. Paykit is browser-visible at `http://localhost:3001`, the Homegate bridge at `http://localhost:6288`, and Fulcrum at `tcp://localhost:60001`. Locks reaches Paykit at `http://127.0.0.1:3001` inside the shared Pubky network namespace. The unprivileged creator and reader images contain the reviewed native helpers and a package built in the image; they receive only their explicit runtime directories, never the repository root or Lock Server identity volume. +This starts separate Locks and Paykit PostgreSQL services, Bitcoin Core regtest, a 101-block wallet bootstrap, Fulcrum readiness through `server.version`, Pubky testnet, a local Homegate-compatible signup bridge, Locks, Paykit Server, and both browser demos. All published ports bind to host loopback. Paykit is browser-visible at `http://127.0.0.1:3001`, the Homegate bridge at `http://127.0.0.1:6288`, and Fulcrum at `tcp://127.0.0.1:60001`. Locks reaches Paykit at `http://127.0.0.1:3001` inside the shared Pubky network namespace. The unprivileged creator and reader images contain the reviewed native helpers and a package built in the image; they receive only their explicit runtime directories, never the repository root or Lock Server identity volume. Open: ```text -Creator: http://localhost:8080/examples/js-sdk/ -Reader: http://localhost:8088/reader/ -Paykit: http://localhost:3001/setup +Creator: http://127.0.0.1:8080/examples/js-sdk/ +Reader: http://127.0.0.1:8088/reader/ +Paykit: http://127.0.0.1:3001/setup ``` The reader displays directly runnable Bitcoin commands without JSON-style escaped quotes. @@ -251,7 +251,7 @@ npm --prefix examples/js-sdk run start-server -- --allow-unhealthy Open: ```text -http://localhost:8080/examples/js-sdk/ +http://127.0.0.1:8080/examples/js-sdk/ ``` ## Run the reader demo server @@ -271,7 +271,7 @@ npm --prefix examples/js-sdk run start-reader-server -- --allow-unhealthy Open: ```text -http://localhost:8081/reader/ +http://127.0.0.1:8081/reader/ ``` Reader-server responsibilities are intentionally narrow: @@ -324,7 +324,7 @@ Both creator pages open the Lock Server `/connect` shell in an iframe modal. The The shell returns `{ state, code }` directly to the parent with `postMessage`. The parent accepts the result only from the exact Lock Server origin and iframe window, then validates the state before exchanging the one-time code. The configured callback URL supplies the parent target origin; the browser does not navigate to it: ```text -http://localhost:8080/auth/lock-server/callback +http://127.0.0.1:8080/auth/lock-server/callback ``` Approve the Lock Server auth string with the same identity. In Compose, scan or paste it into the same external wallet. For direct npm development, use: @@ -358,7 +358,7 @@ Rules: - payment recipient is the authenticated content creator; it is not user-editable - payment is the content lock's sole criterion and the lock logic references exactly that criterion - payment publishing is rejected until Paykit setup succeeds for the current authenticated creator -- selecting `paykit-payment` opens `GET http://localhost:3001/setup` in a Paykit-origin iframe +- selecting `paykit-payment` opens `GET http://127.0.0.1:3001/setup` in a Paykit-origin iframe - the parent accepts completion only from that exact iframe window and origin with the pending state - the success callback is only `{ type: "paykit-setup-callback", state }`; failures add only `error: "setup-failed"`, and account data stays inside Paykit diff --git a/locks-sdk/bindings/js/scripts/live-smoke-check.mjs b/locks-sdk/bindings/js/scripts/live-smoke-check.mjs index 4efa5d3..37639e4 100644 --- a/locks-sdk/bindings/js/scripts/live-smoke-check.mjs +++ b/locks-sdk/bindings/js/scripts/live-smoke-check.mjs @@ -1,6 +1,6 @@ const required = [ ['LOCKS_LIVE_LOCK_SERVER', 'Lock Server Pubky with a browser-usable PKARR endpoint'], - ['LOCKS_LIVE_PKARR_RELAY', 'PKARR relay URL; local pubky-testnet uses http://localhost:15411'], + ['LOCKS_LIVE_PKARR_RELAY', 'PKARR relay URL; local pubky-testnet uses http://127.0.0.1:15411'], ['LOCKS_LIVE_CREATOR', 'Creator Pubky that publishes /pub/locks.app/config.json'], ['LOCKS_LIVE_CONTENT_LOCK_RESOURCE', 'Canonical pubky.../pub/locks.app/.json resource'], ['LOCKS_LIVE_DEMO_ORIGIN', 'Origin allowed by creator_authority_acquisition.legacy_connect.allowed_return_origins'], @@ -21,7 +21,7 @@ for (const [name, description] of required) { console.log('\nManual smoke sequence once prerequisites exist:'); console.log('1. npm --prefix locks-sdk/bindings/js run build'); console.log('2. python3 -m http.server 8080 --directory locks-sdk/bindings/js'); -console.log('3. Open http://localhost:8080/demo/ from the allowed origin / configured browser context.'); +console.log('3. Open http://127.0.0.1:8080/demo/ from the allowed origin / configured browser context.'); console.log('4. Build LocksOptions and add LOCKS_LIVE_PKARR_RELAY with addPkarrRelay().'); console.log('5. Verify Locks.forServerWithOptions/createConnectUrl/exchangeFrontendSessionCode against the live Lock Server.'); console.log('6. Verify Locks.forCreatorWithOptions and Locks.readContentLockWithOptions using the creator and content-lock resource.'); diff --git a/locks-sdk/bindings/js/scripts/smoke-examples.mjs b/locks-sdk/bindings/js/scripts/smoke-examples.mjs index 5910ecc..7283267 100644 --- a/locks-sdk/bindings/js/scripts/smoke-examples.mjs +++ b/locks-sdk/bindings/js/scripts/smoke-examples.mjs @@ -59,13 +59,13 @@ const required = { 'npm --prefix examples/js-sdk run create-user -- --role content-creator', 'npm --prefix examples/js-sdk run authenticate -- --role content-creator', 'docker compose --file compose.paykit-local-demo.yaml up -d --build', - 'http://localhost:8080/examples/js-sdk/', + 'http://127.0.0.1:8080/examples/js-sdk/', 'npm --prefix examples/js-sdk run start-server', 'npm --prefix examples/js-sdk run start-reader-server', './.local/demo-config/config.json', './.local/content-creator/recovery_file', - 'http://localhost:15411', - 'http://localhost:8081/reader/', + 'http://127.0.0.1:15411', + 'http://127.0.0.1:8081/reader/', '/priv/locks.app/content/', 'Viewer content lock resource', 'Reset reader state', @@ -76,8 +76,8 @@ const required = { 'asset is fixed to `BTC`', 'recipient is the authenticated content creator', 'must both be approved by that same content-creator identity', - 'Paykit browser = http://localhost:3001', - 'opens `GET http://localhost:3001/setup` in a Paykit-origin iframe', + 'Paykit browser = http://127.0.0.1:3001', + 'opens `GET http://127.0.0.1:3001/setup` in a Paykit-origin iframe', 'exact iframe window and origin with the pending state', 'npm --prefix examples/js-sdk run authenticate-paykit -- --role content-creator', 'Non-TTY stdin is exactly those three ordered lines', diff --git a/locks-server/src/app_state/pubky_clients.rs b/locks-server/src/app_state/pubky_clients.rs index b7b2766..59220d2 100644 --- a/locks-server/src/app_state/pubky_clients.rs +++ b/locks-server/src/app_state/pubky_clients.rs @@ -36,7 +36,7 @@ pub(super) fn pubky_auth_relay_for_network(network: PubkyNetwork) -> Option None, PubkyNetwork::Testnet => Some( - "http://localhost:15412/inbox/" + "http://127.0.0.1:15412/inbox/" .parse() .expect("local testnet auth relay URL must be valid"), ), diff --git a/locks-server/src/app_state/test_support.rs b/locks-server/src/app_state/test_support.rs index 963b572..aaef3d4 100644 --- a/locks-server/src/app_state/test_support.rs +++ b/locks-server/src/app_state/test_support.rs @@ -223,7 +223,7 @@ fn testnet_creator_connect_uses_local_pubky_auth_relay() { pubky_auth_relay_for_network(PubkyNetwork::Testnet) .unwrap() .as_str(), - "http://localhost:15412/inbox/" + "http://127.0.0.1:15412/inbox/" ); } From d3eac2eb23b9304be60007443521d28f27512196 Mon Sep 17 00:00:00 2001 From: dzdidi Date: Tue, 25 Aug 2026 13:22:16 -0300 Subject: [PATCH 11/12] allign docs, fix outputs Signed-off-by: dzdidi --- README.md | 11 ++++---- compose.paykit-local-demo.yaml | 4 +-- docker-compose.yml | 2 +- examples/js-sdk/README.md | 21 ++++++++++---- examples/js-sdk/scripts/lib/pubky.mjs | 25 +++++++++++++---- .../scripts/paykit-companion-auth-compose.sh | 2 ++ .../js-sdk/scripts/smoke-paykit-compose.mjs | 5 ++-- .../scripts/start-reader-demo-server.mjs | 2 +- .../bindings/js/scripts/smoke-examples.mjs | 28 +++++++++++++++++-- 9 files changed, 75 insertions(+), 25 deletions(-) diff --git a/README.md b/README.md index e1f6d10..4fdc898 100644 --- a/README.md +++ b/README.md @@ -50,11 +50,11 @@ key in the private `lock-home` volume. Set `PUBKY_LOCK_CREATOR_AUTH_ENCRYPTION_KEY` before startup only when you need to supply your own 32-byte base64url key. -Verified browser-facing defaults are: +Verified browser-facing defaults for the basic `docker-compose.yml` stack are: - Lock Server: - creator demo: -- reader demo: +- reader demo: For the opt-in payment-lock demonstration, including Paykit Server, Bitcoin regtest, and Fulcrum: @@ -73,9 +73,10 @@ docker compose --file compose.paykit-local-demo.yaml up -d --build only the bounded native-helper request into the demo container. Its external build contexts use anonymously reachable public repositories pinned to -immutable commits; no sibling Paykit or Pubky checkout is required. The full demo adds -Paykit Server at and publishes the reader at -. Pubky Testnet is built from `pubky/pubky-core` source at +immutable commits; no sibling Paykit or Pubky checkout is required. The full Paykit demo adds +Paykit Server at . The reader remains at + in every local flow. Pubky Testnet is built from +`pubky/pubky-core` source at commit `75eb1324f86e8caa16c41f18a2cd6b8e1909ee7b`, not from a released Pubky image or version. Payment remains a manual operator action. diff --git a/compose.paykit-local-demo.yaml b/compose.paykit-local-demo.yaml index eaf0fb3..961c71f 100644 --- a/compose.paykit-local-demo.yaml +++ b/compose.paykit-local-demo.yaml @@ -171,7 +171,7 @@ services: - "127.0.0.1:${LOCKS_SERVER_PORT:-3000}:3000" - "127.0.0.1:${LOCKS_PAYKIT_PORT:-3001}:3001" - "127.0.0.1:${LOCKS_CREATOR_DEMO_PORT:-8080}:8080" - - "127.0.0.1:${LOCKS_READER_DEMO_PORT:-8088}:8081" + - "127.0.0.1:${LOCKS_READER_DEMO_PORT:-8088}:8088" volumes: - ./.local/pubky-homeserver:/run/compose-local/pubky-homeserver:ro command: ["pubky-testnet", "--homeserver-config", "/run/compose-local/pubky-homeserver/config.toml"] @@ -414,7 +414,7 @@ services: - CMD - node - -e - - fetch('http://127.0.0.1:8081/api/paykit-reader/status').then((response) => { if (!response.ok) process.exit(1); }).catch(() => process.exit(1)) + - fetch('http://127.0.0.1:8088/api/paykit-reader/status').then((response) => { if (!response.ok) process.exit(1); }).catch(() => process.exit(1)) interval: 2s timeout: 2s retries: 30 diff --git a/docker-compose.yml b/docker-compose.yml index 3b3d0e1..3dee10d 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -32,7 +32,7 @@ services: - "${LOCKS_HOMESERVER_ADMIN_PORT:-6288}:6288" - "${LOCKS_SERVER_PORT:-3000}:3000" - "${LOCKS_CREATOR_DEMO_PORT:-8080}:8080" - - "${LOCKS_READER_DEMO_PORT:-8081}:8081" + - "${LOCKS_READER_DEMO_PORT:-8088}:8088" volumes: - ./docker/pubky-homeserver.compose.toml:/etc/pubky-homeserver/config.toml:ro command: ["pubky-testnet", "--homeserver-config", "/etc/pubky-homeserver/config.toml"] diff --git a/examples/js-sdk/README.md b/examples/js-sdk/README.md index 1c3192b..6c7a6ae 100644 --- a/examples/js-sdk/README.md +++ b/examples/js-sdk/README.md @@ -107,7 +107,7 @@ The compose stack starts: - Pubky testnet on `15411`, `15412`, `6881`, homeserver HTTP on `6286`, and homeserver admin on `6288` - Lock Server on `http://127.0.0.1:3000` - creator demo on `http://127.0.0.1:8080/examples/js-sdk/` -- reader demo on `http://127.0.0.1:8081/reader/` +- reader demo on `http://127.0.0.1:8088/reader/` The Pubky testnet image is built from the public `pubky/pubky-core` repository at the revision pinned in `docker-compose.yml`; no sibling checkout is required. @@ -174,6 +174,14 @@ Existing keypairs are reused. To regenerate one role: npm --prefix examples/js-sdk run create-user -- --role content-creator --force ``` +Deleting `.local` deletes the local recovery-file identities as well as disposable demo state. +Authentication never silently regenerates a missing identity because that would approve as a +different Pubky. Recreate the fallback identity explicitly before authenticating: + +```bash +npm --prefix examples/js-sdk run create-user -- --role content-creator +``` + Replacing the content-creator identity clears any persisted demo-auth session for the old key before and after rotation. The demo server also validates persisted and newly approved sessions against the current role profile, so an approval that completes during rotation cannot restore the old identity. Authenticate the demo again before continuing. ## Run the demo server @@ -220,6 +228,8 @@ Reader: http://127.0.0.1:8088/reader/ Paykit: http://127.0.0.1:3001/setup ``` +The reader uses port `8088` for both direct npm and Compose; there is no host/container remap. + The reader displays directly runnable Bitcoin commands without JSON-style escaped quotes. The generated send command has this shape, with the current request address and amount substituted for `BCRT_ADDRESS` and `BTC_AMOUNT`: @@ -228,7 +238,7 @@ substituted for `BCRT_ADDRESS` and `BTC_AMOUNT`: docker compose --file compose.paykit-local-demo.yaml exec -T bitcoin sh -ec 'bitcoin-cli -conf=/home/bitcoin/.bitcoin/bitcoin.conf -regtest -rpcwallet=miner sendtoaddress BCRT_ADDRESS BTC_AMOUNT' ``` -The Compose reader process still listens on container port `8081`; only its host mapping is `8088`. To remove the four explicit disposable database/Bitcoin/Fulcrum volumes, empty bootstrap scratch directory, and encrypted reader-helper state while preserving generated credentials/config, role identities, and Lock Server identity: +The Compose reader process listens on port `8088`. To remove the four explicit disposable database/Bitcoin/Fulcrum volumes, empty bootstrap scratch directory, and encrypted reader-helper state while preserving generated credentials/config, role identities, and Lock Server identity: ```bash npm --prefix examples/js-sdk run reset-paykit-demo @@ -254,9 +264,10 @@ Open: http://127.0.0.1:8080/examples/js-sdk/ ``` -## Run the reader demo server +## Run the reader demo server directly with npm -The reader demo is a separate static/debug server on port `8081` inside the same package: +Outside the Paykit Compose flow, the reader demo is a separate static/debug server on the +same port `8088` inside the same package: ```bash npm --prefix examples/js-sdk run start-reader-server @@ -271,7 +282,7 @@ npm --prefix examples/js-sdk run start-reader-server -- --allow-unhealthy Open: ```text -http://127.0.0.1:8081/reader/ +http://127.0.0.1:8088/reader/ ``` Reader-server responsibilities are intentionally narrow: diff --git a/examples/js-sdk/scripts/lib/pubky.mjs b/examples/js-sdk/scripts/lib/pubky.mjs index 68d9743..3d8ec0b 100644 --- a/examples/js-sdk/scripts/lib/pubky.mjs +++ b/examples/js-sdk/scripts/lib/pubky.mjs @@ -20,9 +20,9 @@ export function randomPassphrase() { return randomBytes(32).toString('base64url'); } -export async function loadRoleKeypair(role) { - const passphrase = (await readFile(rolePassphrasePath(role), 'utf8')).trim(); - const recoveryFile = await readFile(roleRecoveryFilePath(role)); +export async function loadRoleKeypair(role, { readFile: read = readFile } = {}) { + const passphrase = (await readRoleIdentityFile(rolePassphrasePath(role), role, read, 'utf8')).trim(); + const recoveryFile = await readRoleIdentityFile(roleRecoveryFilePath(role), role, read); return Keypair.fromRecoveryFile(new Uint8Array(recoveryFile), passphrase); } @@ -39,12 +39,25 @@ export function secretFromRecoveryFile(recoveryFile, passphrase) { } } -export async function loadRoleSecret(role) { - const passphrase = (await readFile(rolePassphrasePath(role), 'utf8')).trim(); - const recoveryFile = await readFile(roleRecoveryFilePath(role)); +export async function loadRoleSecret(role, { readFile: read = readFile } = {}) { + const passphrase = (await readRoleIdentityFile(rolePassphrasePath(role), role, read, 'utf8')).trim(); + const recoveryFile = await readRoleIdentityFile(roleRecoveryFilePath(role), role, read); return secretFromRecoveryFile(recoveryFile, passphrase); } +async function readRoleIdentityFile(path, role, read, encoding) { + try { + return await read(path, encoding); + } catch (error) { + if (error?.code !== 'ENOENT') throw error; + const missing = new Error( + `missing local identity for ${role}; run \`npm --prefix examples/js-sdk run create-user -- --role ${role}\` before authentication`, + ); + missing.code = 'ROLE_IDENTITY_MISSING'; + throw missing; + } +} + export async function loadRoleProfile(role) { return readJson(new URL(`../../../../.local/${role}/profile.json`, import.meta.url).pathname); } diff --git a/examples/js-sdk/scripts/paykit-companion-auth-compose.sh b/examples/js-sdk/scripts/paykit-companion-auth-compose.sh index 0dd98ab..bd3ce77 100755 --- a/examples/js-sdk/scripts/paykit-companion-auth-compose.sh +++ b/examples/js-sdk/scripts/paykit-companion-auth-compose.sh @@ -8,6 +8,8 @@ fi script_dir=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) repo_root=$(CDPATH= cd -- "$script_dir/../../.." && pwd) +PAYKIT_EXTERNAL_READER_PUBKY=${PAYKIT_EXTERNAL_READER_PUBKY:-} +export PAYKIT_EXTERNAL_READER_PUBKY exec docker compose \ --project-directory "$repo_root" \ diff --git a/examples/js-sdk/scripts/smoke-paykit-compose.mjs b/examples/js-sdk/scripts/smoke-paykit-compose.mjs index 42ad8a0..82d82c8 100644 --- a/examples/js-sdk/scripts/smoke-paykit-compose.mjs +++ b/examples/js-sdk/scripts/smoke-paykit-compose.mjs @@ -304,7 +304,7 @@ for (const required of [ 'https://github.com/pubky/paykit-rs.git#6b241878a9bba5cecea919c0298c3f90624be6ff:paykit-sdk', '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_READER_DEMO_PORT:-8088}:8088', '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', @@ -365,7 +365,8 @@ assert.ok(readerService.includes('npm --prefix examples/js-sdk run create-user - 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('http://127.0.0.1:8088/api/paykit-reader/status'), 'reader health must use the closed worker status endpoint'); +assert.ok(!readerService.includes('8081'), 'reader container must use the canonical port 8088 without remapping'); 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'); diff --git a/examples/js-sdk/scripts/start-reader-demo-server.mjs b/examples/js-sdk/scripts/start-reader-demo-server.mjs index 12ad8ef..db347ba 100644 --- a/examples/js-sdk/scripts/start-reader-demo-server.mjs +++ b/examples/js-sdk/scripts/start-reader-demo-server.mjs @@ -23,7 +23,7 @@ const allowUnhealthy = args['allow-unhealthy'] === true; const config = await readDemoConfig(); const serviceConfig = withInternalServiceUrls(config); const readerUrl = new URL(config.demoServer.url); -readerUrl.port = String(args.port ?? 8081); +readerUrl.port = String(args.port ?? 8088); const readerServerUrl = readerUrl.toString().replace(/\/$/, ''); const preflightStatus = await runPreflight(serviceConfig); const externalReaderPubky = process.env.PAYKIT_EXTERNAL_READER_PUBKY?.trim() ?? ''; diff --git a/locks-sdk/bindings/js/scripts/smoke-examples.mjs b/locks-sdk/bindings/js/scripts/smoke-examples.mjs index 7283267..4e3edc2 100644 --- a/locks-sdk/bindings/js/scripts/smoke-examples.mjs +++ b/locks-sdk/bindings/js/scripts/smoke-examples.mjs @@ -65,7 +65,7 @@ const required = { './.local/demo-config/config.json', './.local/content-creator/recovery_file', 'http://127.0.0.1:15411', - 'http://127.0.0.1:8081/reader/', + 'http://127.0.0.1:8088/reader/', '/priv/locks.app/content/', 'Viewer content lock resource', 'Reset reader state', @@ -198,6 +198,7 @@ const required = { ], composeCompanionHelper: [ 'compose.paykit-local-demo.yaml', + 'PAYKIT_EXTERNAL_READER_PUBKY', 'exec -T creator-demo', '/usr/local/bin/paykit-companion-auth', ], @@ -222,7 +223,7 @@ const required = { registerPaykitReader: ['signupReaderBestEffort', "request.operation !== 'register'", 'registration_failed'], creatorSessionState: ['clearCreatorDemoSession', 'readCreatorDemoSessionForCurrentRole', 'writeCreatorDemoSessionForCurrentRole', 'contentCreatorSessionPath', 'rm'], startServer: ['createServer', '--allow-unhealthy', 'pubkyAuthRelayInboxUrl', '/api/demo-auth/start', '/api/demo-auth/status', '/config.json', 'awaitApproval', 'content-creator-session.json', 'readCreatorDemoSessionForCurrentRole', 'writeCreatorDemoSessionForCurrentRole', '/healthz', '/readyz', 'paykit: source.paykit'], - startReaderServer: ['createServer', '--allow-unhealthy', 'runPaykitReaderWorker', 'supervisePaykitReaderWorker', 'workerOwnsState', 'handleTerminalWorkerFailure', 'writePaykitReaderWorkerStatus', 'readPaykitReaderWorkerStatus', 'AbortController', 'SIGTERM', '/reader/', '/config.json', '/api/health', '/api/preflight', '/api/debug/config', '/api/paykit-reader/status', '/api/client-log', "'cache-control': 'no-store'", '8081', 'never proxy'], + startReaderServer: ['createServer', '--allow-unhealthy', 'runPaykitReaderWorker', 'supervisePaykitReaderWorker', 'workerOwnsState', 'handleTerminalWorkerFailure', 'writePaykitReaderWorkerStatus', 'readPaykitReaderWorkerStatus', 'AbortController', 'SIGTERM', '/reader/', '/config.json', '/api/health', '/api/preflight', '/api/debug/config', '/api/paykit-reader/status', '/api/client-log', "'cache-control': 'no-store'", '8088', 'never proxy'], pathsLib: ['localPath', 'roleDir', 'demoConfigPath', 'contentCreatorSessionPath', 'paykitReaderPreparedPath', 'paykitReaderOwnershipPath', 'prepared.v1.json', 'owner.lock'], configLib: ['readDemoConfig', 'writeDemoConfig', 'parseLockServerTomlPublicKey', 'pubkyAuthRelayInboxUrl', 'validateDemoConfig', "url: 'http://127.0.0.1:3001'", "['paykit', 'url']"], pubkyLib: ["from '@synonymdev/pubky'", 'Pubky.testnet', 'Keypair.fromRecoveryFile', 'keypair.secret()', 'loadRoleSecret', 'AuthFlowKind', 'PublicKey.from'], @@ -330,6 +331,10 @@ for (const [label, snippets] of Object.entries(required)) { } } +if (texts.startReaderServer.includes('8081')) { + throw new Error('reader demo server must use the canonical port 8088'); +} + const readerElementMapStart = texts.readerApp.indexOf('const el = {'); const readerElementMapEnd = texts.readerApp.indexOf('\n};', readerElementMapStart); const readerElementMap = texts.readerApp.slice(readerElementMapStart, readerElementMapEnd); @@ -1078,7 +1083,24 @@ const { resolveCompanionHelperPath, runCompanionHelper, } = await import(pathToFileURL(files.authenticatePaykit).href); -const { Keypair, secretFromRecoveryFile } = await import(pathToFileURL(files.pubkyLib).href); +const { + Keypair, + loadRoleKeypair, + loadRoleSecret, + secretFromRecoveryFile, +} = await import(pathToFileURL(files.pubkyLib).href); + +const missingRoleFile = async () => { + const error = new Error('missing role file'); + error.code = 'ENOENT'; + throw error; +}; +for (const loadIdentity of [loadRoleKeypair, loadRoleSecret]) { + await assert.rejects( + loadIdentity('content-creator', { readFile: missingRoleFile }), + /missing local identity for content-creator; run `npm --prefix examples\/js-sdk run create-user -- --role content-creator` before authentication/, + ); +} const authUrl = 'pubkyauth://signin?secret=test-auth-secret'; const accountXpub = 'tpub-test-account-xpub'; From 6bd120b44f163ff7c5423e6f2f138715e3cf08ed Mon Sep 17 00:00:00 2001 From: DZ Date: Tue, 25 Aug 2026 14:23:54 -0300 Subject: [PATCH 12/12] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- examples/js-sdk/app-iframe.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/js-sdk/app-iframe.js b/examples/js-sdk/app-iframe.js index 88813fd..cb8d576 100644 --- a/examples/js-sdk/app-iframe.js +++ b/examples/js-sdk/app-iframe.js @@ -580,7 +580,7 @@ function showError(target, error) { target.className = 'error'; } -async function postClientLog(level) { +async function postClientLog(level, _event, _details) { try { await fetch('/api/client-log', { method: 'POST',