diff --git a/README.md b/README.md index e0c6b1b..764d1da 100644 --- a/README.md +++ b/README.md @@ -52,21 +52,30 @@ your own 32-byte base64url key. Verified browser-facing defaults are: -- Lock Server: -- creator demo: -- reader demo: +- Lock Server: +- creator demo: +- reader demo: For the opt-in payment-lock demonstration, including Paykit Server, Bitcoin regtest, -and Fulcrum, use the separate Compose definition: +and Fulcrum: + +1. Build and start the complete stack from the repository root: ```bash -docker compose -f compose.paykit-local-demo.yaml up --build +docker compose --file compose.paykit-local-demo.yaml up -d --build ``` +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` + in `docker compose exec`; those wrappers load private role state on the host and bridge + only the bounded native-helper request into the demo container. + 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 +Paykit Server at and publishes the reader at +. 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 0d5ac90..97391ae 100644 --- a/compose.paykit-local-demo.yaml +++ b/compose.paykit-local-demo.yaml @@ -26,7 +26,8 @@ services: .local/pubky-homeserver \ .local/locks-server \ .local/paykit-server \ - .local/paykit-config + .local/paykit-config \ + .local/homegate-bridge chmod 0600 .local/paykit-server/paykit.env volumes: - ./examples/js-sdk:/workspace/examples/js-sdk:ro @@ -141,6 +142,8 @@ services: depends_on: bitcoin-bootstrap: condition: service_completed_successfully + ports: + - "127.0.0.1:${LOCKS_ELECTRUM_PORT:-60001}:50001" electrum-readiness: image: node:22-bookworm-slim@sha256:813a7480f28fdadac1f7f5c824bcdad435b5bc1322a5968bbbdef8d058f9dff4 @@ -165,7 +168,6 @@ services: - "127.0.0.1:${LOCKS_HTTP_RELAY_PORT:-15412}:15412" - "127.0.0.1:${LOCKS_HOMESERVER_HTTP_PORT:-6286}:6286" - "127.0.0.1:${LOCKS_HOMESERVER_PUBKY_PORT:-6287}:6287" - - "127.0.0.1:${LOCKS_HOMESERVER_ADMIN_PORT:-6288}:6288" - "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" @@ -177,6 +179,38 @@ services: postgres: condition: service_healthy + homegate-bridge: + image: node:22-bookworm-slim@sha256:813a7480f28fdadac1f7f5c824bcdad435b5bc1322a5968bbbdef8d058f9dff4 + user: "1000:1000" + working_dir: /workspace + command: + - /bin/sh + - -euc + - | + set -a + . /run/compose-local/homegate-bridge/homegate.env + set +a + exec node examples/js-sdk/scripts/homegate-bridge.mjs + environment: + HOMEGATE_BRIDGE_CONFIG: /run/compose-local/demo-config/config.json + HOMEGATE_BRIDGE_HOMESERVER_ADMIN_URL: http://pubky-testnet:6288 + ports: + - "127.0.0.1:${LOCKS_HOMEGATE_PORT:-6288}:8082" + volumes: + - ./examples/js-sdk:/workspace/examples/js-sdk:ro + - ./.local/demo-config:/run/compose-local/demo-config:ro + - ./.local/homegate-bridge:/run/compose-local/homegate-bridge:ro + depends_on: + pubky-testnet: + condition: service_started + demo-config: + condition: service_completed_successfully + healthcheck: + test: ["CMD", "node", "-e", "fetch('http://127.0.0.1:8082/health').then((response) => { if (!response.ok) process.exit(1); }).catch(() => process.exit(1))"] + interval: 2s + timeout: 2s + retries: 30 + locks-server: build: context: . @@ -247,11 +281,11 @@ services: paykit-server: image: pubky-locks-paykit-server:local build: - context: "https://github.com/pubky/paykit-server.git#f38c7915e6b9b104e040773e78438f8aa984c46c" + context: "https://github.com/pubky/paykit-server.git#5ed3e8e849a16045c26c37a75068625dda333785" dockerfile: Dockerfile.local additional_contexts: - paykit-lib: "https://github.com/pubky/paykit-rs.git#52a852995bfc457b78d32f5a45f6741766a89bba:paykit-lib" - paykit-sdk: "https://github.com/pubky/paykit-rs.git#52a852995bfc457b78d32f5a45f6741766a89bba:paykit-sdk" + paykit-lib: "https://github.com/pubky/paykit-rs.git#6b241878a9bba5cecea919c0298c3f90624be6ff:paykit-lib" + paykit-sdk: "https://github.com/pubky/paykit-rs.git#6b241878a9bba5cecea919c0298c3f90624be6ff:paykit-sdk" locks: "https://github.com/pubky/locks.git#df5ea1b6d8dcdec3a9b5a915c3f57bca69d75c8a" depends_on: @@ -320,19 +354,16 @@ services: PAYKIT_COMPANION_AUTH_BIN: /usr/local/bin/paykit-companion-auth PUBKY_LOCK_DEBUG: ${PUBKY_LOCK_DEBUG:-0} volumes: - - ./locks-sdk/bindings/js/pkg:/workspace/locks-sdk/bindings/js/pkg:ro - ./.local/demo-config:/workspace/.local/demo-config:ro - ./.local/js-sdk-demo:/workspace/.local/js-sdk-demo - - ./.local/content-creator:/workspace/.local/content-creator - ./.local/creator-public:/workspace/.local/creator-public command: - sh - -lc - | set -eu - npm --prefix examples/js-sdk run create-user -- --role content-creator - npm --prefix examples/js-sdk run publish-creator-profile - npm --prefix examples/js-sdk run start-server -- --allow-unhealthy + rm -f /workspace/.local/creator-public/profile.json + npm --prefix examples/js-sdk run start-server -- --external-wallet reader-demo: restart: unless-stopped @@ -363,9 +394,9 @@ services: PAYKIT_READER_RECEIVER_PATH: bitkit/wallet PAYKIT_READER_SERVER_PATH: bitkit/server PAYKIT_READER_WORKER_ENABLED: "1" + PAYKIT_EXTERNAL_READER_PUBKY: ${PAYKIT_EXTERNAL_READER_PUBKY:-} PUBKY_LOCK_DEBUG: ${PUBKY_LOCK_DEBUG:-0} volumes: - - ./locks-sdk/bindings/js/pkg:/workspace/locks-sdk/bindings/js/pkg:ro - ./.local/demo-config:/workspace/.local/demo-config:ro - ./.local/creator-public:/workspace/.local/creator-public:ro - ./.local/content-viewer:/workspace/.local/content-viewer @@ -374,8 +405,10 @@ services: - sh - -euc - | - npm --prefix examples/js-sdk run create-user -- --role content-viewer - exec node examples/js-sdk/scripts/start-reader-demo-server.mjs --allow-unhealthy + if [ -z "$PAYKIT_EXTERNAL_READER_PUBKY" ]; then + npm --prefix examples/js-sdk run create-user -- --role content-viewer + fi + exec node examples/js-sdk/scripts/start-reader-demo-server.mjs healthcheck: test: - CMD diff --git a/docker/js-demo.Dockerfile b/docker/js-demo.Dockerfile index c2d6ce6..3eb9440 100644 --- a/docker/js-demo.Dockerfile +++ b/docker/js-demo.Dockerfile @@ -1,6 +1,16 @@ # syntax=docker/dockerfile:1.7@sha256:a57df69d0ea827fb7266491f2813635de6f17269be881f696fbfdf2d83dda33e FROM paykit-runtime AS paykit-runtime +FROM rust:1.91.1-slim-bookworm@sha256:8514999d4786ef12efe89239e86b3d0a021b94b9d35108c8efe6c79ca7dc1a65 AS locks-sdk-wasm +RUN apt-get update \ + && apt-get install -y --no-install-recommends build-essential ca-certificates libssl-dev pkg-config \ + && rm -rf /var/lib/apt/lists/* +RUN rustup target add wasm32-unknown-unknown \ + && cargo install wasm-pack --version 0.13.1 --locked +WORKDIR /workspace +COPY . . +RUN cd locks-sdk/bindings/js && wasm-pack build --target web --out-dir pkg + FROM node:22-bookworm-slim@sha256:813a7480f28fdadac1f7f5c824bcdad435b5bc1322a5968bbbdef8d058f9dff4 WORKDIR /workspace RUN apt-get update \ @@ -10,8 +20,9 @@ COPY --chown=node:node examples/js-sdk/package.json examples/js-sdk/package-lock RUN npm --prefix examples/js-sdk ci --ignore-scripts \ && npm cache clean --force COPY --chown=node:node examples/js-sdk /workspace/examples/js-sdk +COPY --from=locks-sdk-wasm --chown=node:node /workspace/locks-sdk/bindings/js/pkg /workspace/locks-sdk/bindings/js/pkg COPY --from=paykit-runtime /usr/local/bin/paykit-companion-auth /usr/local/bin/paykit-companion-auth COPY --from=paykit-runtime /usr/local/bin/paykit-reader-demo /usr/local/bin/paykit-reader-demo -RUN mkdir -p /workspace/locks-sdk/bindings/js/pkg /workspace/.local \ +RUN mkdir -p /workspace/.local \ && chown -R node:node /workspace USER node:node diff --git a/docker/locks-server-compose-entrypoint.sh b/docker/locks-server-compose-entrypoint.sh index c7b015c..e114aec 100644 --- a/docker/locks-server-compose-entrypoint.sh +++ b/docker/locks-server-compose-entrypoint.sh @@ -86,7 +86,7 @@ frontend_session_ttl_seconds = 86400 frontend_session_code_ttl_seconds = 120 [creator_authority_acquisition.legacy_connect] -allowed_return_origins = ["http://localhost:8080"] +allowed_return_origins = ["http://127.0.0.1:8080", "http://localhost:8080"] [secrets] creator_authority_key_env = "PUBKY_LOCK_CREATOR_AUTH_ENCRYPTION_KEY" @@ -101,8 +101,8 @@ network = "testnet" public_ip = "127.0.0.1" public_pubky_tls_port = 6287 public_icann_http_port = 3000 -icann_domain = "localhost" -pkarr_relays = ["http://localhost:15411"] +icann_domain = "127.0.0.1" +pkarr_relays = ["http://127.0.0.1:15411"] key_republisher_interval_seconds = 86400 [rate_limits.verification_submission] diff --git a/examples/js-sdk/README.md b/examples/js-sdk/README.md index a019561..3f8be39 100644 --- a/examples/js-sdk/README.md +++ b/examples/js-sdk/README.md @@ -15,6 +15,7 @@ examples/js-sdk/reader.html examples/js-sdk/reader-app.js examples/js-sdk/reader-flow.js examples/js-sdk/scripts/init-config.mjs +examples/js-sdk/scripts/homegate-bridge.mjs examples/js-sdk/scripts/create-user.mjs examples/js-sdk/scripts/authenticate.mjs examples/js-sdk/scripts/prepare-paykit-reader.mjs @@ -61,7 +62,7 @@ Required tools/services: DHT bootstrap localhost:6881 ``` -Build the local WASM SDK package first: +For direct npm development, build the local WASM SDK package first: ```bash npm --prefix locks-sdk/bindings/js run build @@ -83,6 +84,8 @@ The examples package uses `@synonymdev/pubky` for Node-side Pubky testnet auth/k The supported end-to-end path is the complete local Compose stack documented below. It generates ignored owner-only credentials, starts both databases and both application servers, bootstraps Bitcoin regtest, waits for Fulcrum using `server.version`, and starts the creator and reader demos. +The Compose image builds the JS/WASM package itself. A fresh checkout does not need a host-generated `locks-sdk/bindings/js/pkg` directory. + For direct npm development without Compose, provide a running local Pubky testnet, PostgreSQL, Lock Server, and Paykit Server first. `init-config` reads the Lock Server public key from `~/.pubky-lock/config.toml` by default; it does not read the Lock Server signing secret. ### Basic Locks stack @@ -175,22 +178,39 @@ Replacing the content-creator identity clears any persisted demo-auth session fo ## Run the demo server -### Complete local Compose stack +### Complete local Compose stack: quickstart -Build and start the complete stack: +1. From the repository root, build and start the complete stack in the background: ```bash -docker compose -f compose.paykit-local-demo.yaml up --build +docker compose --file compose.paykit-local-demo.yaml up -d --build ``` +2. Open the content-creator demo: + +```text +http://localhost:8080/examples/js-sdk/ +``` + +3. Approve browser requests with the external wallet under test. When using the local + recovery-file fallback, run authentication commands from the repository host: + +```bash +npm --prefix examples/js-sdk run authenticate -- --role content-creator +npm --prefix examples/js-sdk run authenticate-paykit -- --role content-creator +``` + +Do not wrap these commands in `docker compose exec`. The host wrappers load private role +state locally and bridge only bounded helper input into the relevant container. + The Paykit Server, Paykit Rust, Locks, and Pubky Core build inputs are fetched from anonymous public Git URLs pinned to immutable commits. The active Locks checkout is used only for the Locks and browser-demo images being developed. No sibling repository 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 -f compose.paykit-local-demo.yaml config --no-env-resolution` model. +`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, Locks, Paykit Server, and both browser demos. All published ports bind to host loopback. Paykit is browser-visible at `http://localhost:3001`; Locks reaches it at `http://127.0.0.1:3001` inside the shared Pubky network namespace. The unprivileged creator and reader images contain the reviewed native helpers at `/usr/local/bin`; they receive only their explicit role/runtime directories and generated WASM package, 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://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. Open: @@ -200,13 +220,21 @@ Reader: http://localhost:8088/reader/ Paykit: http://localhost:3001/setup ``` +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`: + +```bash +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: ```bash npm --prefix examples/js-sdk run reset-paykit-demo ``` -Do not use `docker compose -f compose.paykit-local-demo.yaml down -v` unless you intentionally want to delete the persistent Lock Server identity volume. +Do not use `docker compose --file compose.paykit-local-demo.yaml down -v` unless you intentionally want to delete the persistent Lock Server identity volume. ### Direct npm server @@ -265,7 +293,9 @@ POST /api/demo-auth/start GET /api/demo-auth/status ``` -It displays a `pubkyauth://...` string and command like: +It displays a `pubkyauth://...` string. In the Compose external-wallet flow, scan or paste that request into the wallet under test. The approved wallet identity becomes the canonical creator identity and is published for the reader and Paykit services; the demo never imports the wallet private key. + +For direct npm development outside the Compose external-wallet mode, the recovery-file command remains available: ```bash npm --prefix examples/js-sdk run authenticate -- \ @@ -279,7 +309,7 @@ npm --prefix examples/js-sdk run authenticate -- \ npm --prefix examples/js-sdk run authenticate -- --role content-creator ``` -It signs up/registers the `content-creator` with the configured homeserver, approves the auth string, and the demo server persists its session to: +That command signs up/registers the local `content-creator`, approves the auth string, and the demo server persists its session to: ```text ./.local/js-sdk-demo/content-creator-session.json @@ -297,7 +327,7 @@ The shell returns `{ state, code }` directly to the parent with `postMessage`. T http://localhost:8080/auth/lock-server/callback ``` -Approve the Lock Server auth string with the same role: +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: ```bash npm --prefix examples/js-sdk run authenticate -- \ @@ -338,13 +368,13 @@ The Paykit iframe displays the auth URL and both approved local commands. First npm --prefix examples/js-sdk run generate-paykit-account-tpub ``` -This command uses the running Compose regtest node, requests public descriptors only, selects `m/84'/1'/0'`, and intentionally prints only the account-level `tpub` and index at this explicit setup boundary. It never prints or exports the account private key. Then run the companion-auth wrapper: +This command uses the running Compose regtest node, requests public descriptors only, selects `m/84'/1'/0'`, and intentionally prints only the account-level `tpub` and index at this explicit setup boundary. It never prints or exports the account private key. In the external-wallet flow, scan or paste the Paykit authorization request into the same wallet. For direct npm development with a generated creator recovery file, the companion-auth wrapper remains available: ```bash -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 ``` -The command loads the existing encrypted content-creator recovery file and starts `/usr/local/bin/paykit-companion-auth` directly with no arguments. `PAYKIT_COMPANION_AUTH_BIN` may override that executable path for local testing. Interactive input prompts for the Paykit auth URL, account xpub/tpub, and account index. Non-TTY stdin is exactly those three ordered lines, with one optional final newline. Sensitive inputs are sent only through the helper's stdin and are never forwarded in wrapper output. +Run this command from the repository host, where the encrypted content-creator recovery file is stored. The wrapper loads that local identity and streams one bounded JSON request over stdin to `/usr/local/bin/paykit-companion-auth` in the running `creator-demo` container; private role files are not mounted into the container. `PAYKIT_COMPANION_AUTH_BIN` may override the helper executable path for local testing. Interactive input prompts for the Paykit auth URL, account xpub/tpub, and account index. Non-TTY stdin is exactly those three ordered lines, with one optional final newline. Sensitive inputs are sent only through helper stdin and are never forwarded in process arguments, environment variables, or wrapper output. The browser uses the Locks JS/WASM SDK for publishing: @@ -389,7 +419,7 @@ The worker is the sole mutable owner of `./.local/paykit-reader/state.v1`. A dir The native helper is `/usr/local/bin/paykit-reader-demo`; `PAYKIT_READER_DEMO_BIN` is a test-only executable override. Its state path and local Pubky endpoints come from the `PAYKIT_READER_*` Compose environment. Reader homeserver registration runs in a separate direct-spawned Node subprocess with bounded output, timeout, and TERM→KILL cancellation because the Pubky JS API does not expose request cancellation; cancellation waits for child settlement before ownership is released. The worker derives the Paykit peer from the public `content-creator` profile, then passes only the closed native helper environment. The state path must end in `.local/paykit-reader/state.v1`. The helper owns encrypted versioned state, owner-only file permissions, fresh-nonce rewrites, and invariant validation. The worker fences status publication and state checkpoints on current kernel-lock ownership, atomically writes its separate owner-only `worker.v1.json` projection, and clears in-memory readiness immediately if ownership is lost. The HTTP server validates the projection again and requires current in-memory ownership before returning a ready browser status. Terminal worker failure closes PID 1 after a coarse error so Compose restart policy applies. -The reader page persists local progress in browser `localStorage` under `pubky-locks-reader-demo.*` and has a visible **Reset reader state** button. Bundle IDs and access credentials are bearer-like local-dev secrets; the demo displays them for debugging only. +The reader page persists local progress in browser `localStorage` under `pubky-locks-reader-demo.*` and has a visible **Reset reader state** button. Retrieved guarded bytes are never persisted: text and JSON render as text, images use a temporary object URL, and other binary content exposes metadata and a temporary download link. Bundle IDs and access credentials are bearer-like local-dev secrets; the demo displays them for debugging only. ## Static drift check diff --git a/examples/js-sdk/app-iframe.js b/examples/js-sdk/app-iframe.js index 65174e8..88813fd 100644 --- a/examples/js-sdk/app-iframe.js +++ b/examples/js-sdk/app-iframe.js @@ -255,7 +255,7 @@ function openPaykitSetupIframe(setupUrl) { description.style.cssText = 'margin:0;'; const companionCommand = document.createElement('code'); - companionCommand.textContent = 'docker compose -f compose.paykit-local-demo.yaml exec creator-demo npm --prefix examples/js-sdk run authenticate-paykit -- --role content-creator'; + companionCommand.textContent = 'npm --prefix examples/js-sdk run authenticate-paykit -- --role content-creator'; companionCommand.style.cssText = 'display:block;overflow-wrap:anywhere;'; const frame = document.createElement('iframe'); @@ -312,7 +312,7 @@ async function bootstrap() { lockServerUrl: state.config.lockServer.url, pkarrRelay: state.config.testnet.pkarrRelay, httpRelay: state.config.testnet.httpRelay, - callback: state.config.paths.lockServerCallback, + callback: `${window.location.origin}/auth/lock-server/callback`, hasLockSession: Boolean(state.feLockSessionToken), }); await refreshDemoAuthStatus(); @@ -329,7 +329,7 @@ el.startDemoAuth.addEventListener('click', async () => { await refreshDemoAuthStatus(); return; } - el.demoAuthCommand.textContent = `${result.authorizationUrl}\n\n${result.command}`; + el.demoAuthCommand.textContent = [result.authorizationUrl, result.command].filter(Boolean).join('\n\n'); } catch (error) { showError(el.demoAuthStatus, error); } @@ -341,7 +341,7 @@ el.startLockAuth.addEventListener('click', async () => { state.pendingConnectState = connectState; // return_to only supplies the postMessage target origin (this app's origin); no callback page // is navigated to. The server posts { state, code } to origin(return_to). - const returnTo = state.config.paths.lockServerCallback; + const returnTo = `${window.location.origin}/auth/lock-server/callback`; await postClientLog('info', 'lock-auth-start-clicked', { lockServerPubky: state.config.lockServer.pubky, returnTo, diff --git a/examples/js-sdk/package.json b/examples/js-sdk/package.json index 4ef4760..1bfe6b4 100644 --- a/examples/js-sdk/package.json +++ b/examples/js-sdk/package.json @@ -20,7 +20,7 @@ "validate:paykit-compose": "node scripts/validate-paykit-compose.mjs", "smoke:paykit-compose": "npm run validate:paykit-compose && npm run test:paykit-reader-worker && node scripts/smoke-paykit-compose.mjs", "smoke": "npm --prefix ../../locks-sdk/bindings/js run smoke:examples", - "check": "node --check scripts/init-config.mjs && node --check scripts/init-paykit-compose.mjs && node --check scripts/generate-paykit-account-tpub.mjs && node --check scripts/reset-paykit-demo.mjs && node --check scripts/validate-paykit-compose.mjs && node --check scripts/electrum-readiness.mjs && node --check scripts/smoke-paykit-compose.mjs && node --check scripts/test-paykit-reader-worker.mjs && node --check scripts/create-user.mjs && node --check scripts/publish-creator-profile.mjs && node --check scripts/authenticate.mjs && node --check scripts/authenticate-paykit.mjs && node --check scripts/register-paykit-reader.mjs && node --check scripts/prepare-paykit-reader.mjs && node --check scripts/receive-paykit-request.mjs && node --check scripts/lib/creator-session-state.mjs && node --check scripts/lib/creator-static-path.mjs && node --check scripts/lib/paykit-reader-helper.mjs && node --check scripts/lib/paykit-reader-status.mjs && node --check scripts/lib/paykit-reader-worker.mjs && node --check scripts/start-demo-server.mjs && node --check scripts/start-reader-demo-server.mjs && node --check app.js && node --check app-iframe.js && node --check creator-identity.js && node --check creator-lock-policy.js && node --check paykit-setup.js && node --check creator-complete-flow.js && node --check reader-app.js && node --check reader-flow.js" + "check": "node --check scripts/init-config.mjs && node --check scripts/init-paykit-compose.mjs && node --check scripts/homegate-bridge.mjs && node --check scripts/generate-paykit-account-tpub.mjs && node --check scripts/reset-paykit-demo.mjs && node --check scripts/validate-paykit-compose.mjs && node --check scripts/electrum-readiness.mjs && node --check scripts/smoke-paykit-compose.mjs && node --check scripts/test-paykit-reader-worker.mjs && node --check scripts/create-user.mjs && node --check scripts/publish-creator-profile.mjs && node --check scripts/authenticate.mjs && node --check scripts/authenticate-paykit.mjs && node --check scripts/register-paykit-reader.mjs && node --check scripts/prepare-paykit-reader.mjs && node --check scripts/receive-paykit-request.mjs && node --check scripts/lib/creator-session-state.mjs && node --check scripts/lib/creator-static-path.mjs && node --check scripts/lib/paykit-reader-helper.mjs && node --check scripts/lib/paykit-reader-status.mjs && node --check scripts/lib/paykit-reader-worker.mjs && node --check scripts/start-demo-server.mjs && node --check scripts/start-reader-demo-server.mjs && node --check app.js && node --check app-iframe.js && node --check creator-identity.js && node --check creator-lock-policy.js && node --check paykit-setup.js && node --check creator-complete-flow.js && node --check reader-app.js && node --check reader-flow.js" }, "dependencies": { "@synonymdev/pubky": "^0.9.3" diff --git a/examples/js-sdk/reader-app.js b/examples/js-sdk/reader-app.js index 5896333..7da7ba3 100644 --- a/examples/js-sdk/reader-app.js +++ b/examples/js-sdk/reader-app.js @@ -85,6 +85,8 @@ async function bootstrap() { el.configStatus.textContent = `Reader demo using Lock Server ${state.config.lockServer.pubky}`; el.configStatus.className = 'ok'; restoreState(); + const resource = new URL(window.location.href).searchParams.get('resource')?.trim(); + if (resource) state.resource = resource; bindEvents(); await refreshPaykitReaderStatus(); render(); @@ -101,6 +103,7 @@ async function bootstrap() { function bindEvents() { el.reset.addEventListener('click', async () => { invalidateWorkflow(); + clearReadResult(); localStorage.removeItem(STATE_KEY); Object.assign(state, { resource: '', @@ -299,7 +302,7 @@ async function submitProof() { state.completion = null; state.accessCredential = null; state.accessCredentialResponse = null; - state.readResult = null; + clearReadResult(); activeSubmissionToken = null; state.submittingProof = false; persistState(); @@ -453,12 +456,7 @@ async function readPaymentContent(handle, path, accessCredential) { }); if (!workflowMatches(handle)) return; state.guardedResourcePath = path; - state.readResult = { - path, - size: result.size, - contentType: result.contentType, - text: result.text, - }; + setReadResult(path, result); persistState(); render(); } @@ -516,12 +514,7 @@ async function readContent(path) { }); if (!workflowMatches(handle)) return; state.guardedResourcePath = path; - state.readResult = { - path, - size: result.size, - contentType: result.contentType, - text: result.text, - }; + setReadResult(path, result); persistState(); render(); await postClientLog('info', 'reader-proxy-read-succeeded', { @@ -554,6 +547,9 @@ function render() { } else if (state.paykitReaderState === 'retrying') { el.paykitReaderStatus.textContent = 'Paykit reader is retrying private protocol processing.'; el.paykitReaderStatus.className = 'warning'; + } else if (state.paykitReaderState === 'waiting_for_creator') { + el.paykitReaderStatus.textContent = 'Paykit reader is waiting for the content creator to authenticate.'; + el.paykitReaderStatus.className = 'muted'; } else if (state.paykitReaderState === 'failed') { el.paykitReaderStatus.textContent = 'Paykit reader worker failed. Inspect coarse container logs.'; el.paykitReaderStatus.className = 'error'; @@ -639,7 +635,7 @@ function render() { el.readStatus.textContent = state.accessCredential ? 'Ready to read guarded content.' : 'Waiting for access credential.'; el.readStatus.className = 'muted'; } - el.readOutput.textContent = state.readResult ? state.readResult.text : ''; + renderReadOutput(); } function renderLockResources() { @@ -704,6 +700,7 @@ function restoreState() { paykitReaderState: 'starting', paykitPaymentRequest: null, baselinePaymentRequestId: null, + readResult: null, }); } catch { localStorage.removeItem(STATE_KEY); @@ -721,6 +718,7 @@ function persistState() { paykitReaderState: _paykitReaderState, paykitPaymentRequest: _paykitPaymentRequest, baselinePaymentRequestId: _baselinePaymentRequestId, + readResult: _readResult, ...persisted } = state; localStorage.setItem(STATE_KEY, JSON.stringify(persisted)); @@ -759,9 +757,60 @@ function clearVerificationState({ clearLoaded = false } = {}) { state.completion = null; state.accessCredential = null; state.accessCredentialResponse = null; + clearReadResult(); +} + +function setReadResult(path, result) { + clearReadResult(); + const objectUrl = result.kind === 'text' + ? null + : URL.createObjectURL(new Blob([result.bytes], { type: result.contentType })); + state.readResult = { + path, + size: result.size, + contentType: result.contentType, + kind: result.kind, + text: result.text, + objectUrl, + }; +} + +function clearReadResult() { + if (state.readResult?.objectUrl) URL.revokeObjectURL(state.readResult.objectUrl); state.readResult = null; } +function renderReadOutput() { + el.readOutput.replaceChildren(); + const result = state.readResult; + if (!result) return; + + if (result.kind === 'text') { + const output = document.createElement('pre'); + output.textContent = result.text; + el.readOutput.append(output); + return; + } + + if (result.kind === 'image') { + const image = document.createElement('img'); + image.src = result.objectUrl; + image.alt = `Guarded content from ${result.path}`; + el.readOutput.append(image); + return; + } + + const metadata = document.createElement('p'); + metadata.textContent = `${result.contentType}, ${result.size} bytes`; + const download = document.createElement('a'); + download.href = result.objectUrl; + download.download = result.path.split('/').pop() || 'guarded-content'; + download.textContent = 'Download guarded content'; + el.readOutput.append(metadata, download); +} + +window.addEventListener('pagehide', clearReadResult); + function workflowMatches(handle) { return workflowHandleMatches(handle, { incarnation: workflowIncarnation, diff --git a/examples/js-sdk/reader-flow.js b/examples/js-sdk/reader-flow.js index 8c7824a..6a70a5a 100644 --- a/examples/js-sdk/reader-flow.js +++ b/examples/js-sdk/reader-flow.js @@ -222,6 +222,9 @@ export function parsePaykitReaderBrowserStatus(value) { if (value.state === 'starting' && hasExactKeys(value, ['version', 'state'])) { return Object.freeze({ ...value }); } + if (value.state === 'waiting_for_creator' && hasExactKeys(value, ['version', 'state'])) { + return Object.freeze({ ...value }); + } if ( value.state === 'waiting' && hasExactKeys(value, ['version', 'state', 'reader_pubky']) @@ -283,8 +286,8 @@ function hasExactKeys(value, expected) { } function canonicalPaymentCommandMatches(value) { - const payment = /^docker compose --file \.\/compose\.paykit-local-demo\.yaml exec -T bitcoin sh -ec 'bitcoin-cli -conf="\$BITCOIN_DATA\/bitcoin\.conf" -regtest -rpcwallet=miner sendtoaddress "([^"]+)" "((?:0|[1-9][0-9]*)(?:\.[0-9]{1,8})?)"'$/.exec(value.payment_command); - const mining = "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)\"'"; + 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); + const mining = "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)'"; return Boolean(payment) && payment[1] === value.address && browserBtcToSats(payment[2]) === BigInt(value.amount_sats) @@ -307,10 +310,17 @@ export async function decodeGuardedContentResponse(response) { if (!(response instanceof Response)) throw new Error('guarded resource read returned a non-Response value'); const contentType = response.headers.get('content-type') || 'application/octet-stream'; const array = new Uint8Array(await response.arrayBuffer()); + const mediaType = contentType.split(';', 1)[0].trim().toLowerCase(); + const kind = mediaType.startsWith('text/') || mediaType === 'application/json' || mediaType.endsWith('+json') + ? 'text' + : mediaType.startsWith('image/') + ? 'image' + : 'binary'; return { response, bytes: array, - text: new TextDecoder().decode(array), + kind, + text: kind === 'text' ? new TextDecoder().decode(array) : null, size: array.byteLength, contentType, }; diff --git a/examples/js-sdk/reader.html b/examples/js-sdk/reader.html index bacc428..b3e01d2 100644 --- a/examples/js-sdk/reader.html +++ b/examples/js-sdk/reader.html @@ -11,6 +11,7 @@ input, select, button, textarea { font: inherit; } input[type="text"], textarea { width: 100%; box-sizing: border-box; } pre { background: #f7f7f7; padding: 0.75rem; overflow: auto; white-space: pre-wrap; } + #read-output img { display: block; max-width: 100%; height: auto; } .ok { color: #067d17; } .error { color: #a40000; } .muted { color: #666; } @@ -58,9 +59,9 @@

2. Submit proof bundle

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();