diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml index 0a79bac..67f74d3 100644 --- a/.github/workflows/check.yml +++ b/.github/workflows/check.yml @@ -102,6 +102,9 @@ jobs: cargo nextest run -p locks-e2e --test production_creator_publishing_http cargo nextest run -p locks-e2e --test production_creator_authority_acquisition + - name: Install JS example dependencies + run: npm --prefix examples/js-sdk ci + - name: JS/WASM SDK tests run: npm --prefix locks-sdk/bindings/js run test diff --git a/README.md b/README.md index 93a666d..4fdc898 100644 --- a/README.md +++ b/README.md @@ -50,11 +50,35 @@ key in the private `lock-home` volume. Set `PUBKY_LOCK_CREATOR_AUTH_ENCRYPTION_KEY` before startup only when you need to supply your own 32-byte base64url key. -Verified browser-facing defaults are: +Verified browser-facing defaults for the basic `docker-compose.yml` stack are: -- Lock Server: -- creator demo: -- reader demo: +- Lock Server: +- creator demo: +- reader demo: + +For the opt-in payment-lock demonstration, including Paykit Server, Bitcoin regtest, +and Fulcrum: + +1. Build and start the complete stack from the repository root: + +```bash +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 Paykit demo adds +Paykit Server at . The reader remains at + in every local flow. Pubky Testnet is built from +`pubky/pubky-core` source at +commit `75eb1324f86e8caa16c41f18a2cd6b8e1909ee7b`, not from a released Pubky image or +version. Payment remains a manual operator action. ## Documentation diff --git a/compose.paykit-local-demo.yaml b/compose.paykit-local-demo.yaml new file mode 100644 index 0000000..961c71f --- /dev/null +++ b/compose.paykit-local-demo.yaml @@ -0,0 +1,433 @@ +# Local development and demonstration only; this is not a production deployment definition. +name: pubky-locks-paykit-demo + +services: + compose-bootstrap: + image: node:22-bookworm-slim@sha256:813a7480f28fdadac1f7f5c824bcdad435b5bc1322a5968bbbdef8d058f9dff4 + user: "0:0" + working_dir: /workspace + command: + - /bin/sh + - -euc + - | + node examples/js-sdk/scripts/init-paykit-compose.mjs + chown -R 1000:1000 \ + .local/compose-secrets.json \ + .local/js-sdk-demo \ + .local/demo-config \ + .local/creator-public \ + .local/bitcoin-bootstrap \ + .local/content-creator \ + .local/content-viewer \ + .local/paykit-reader \ + .local/locks-postgres \ + .local/paykit-postgres \ + .local/bitcoin-rpc \ + .local/pubky-homeserver \ + .local/locks-server \ + .local/paykit-server \ + .local/paykit-config \ + .local/homegate-bridge + chmod 0600 .local/paykit-server/paykit.env + volumes: + - ./examples/js-sdk:/workspace/examples/js-sdk:ro + - ./.local:/workspace/.local + + postgres: + image: postgres:17-bookworm@sha256:4f736ae292687621d4dbe0d499ffd024a36bd2ee7d8ca6f2ccd4c800f047b394 + entrypoint: ["/bin/bash", "-euc"] + command: + - | + set -a + . /run/compose-local/locks-postgres.env + set +a + exec /usr/local/bin/docker-entrypoint.sh postgres + volumes: + - ./docker/postgres-init/01-create-pubky-homeserver.sql:/docker-entrypoint-initdb.d/01-create-pubky-homeserver.sql:ro + - ./.local/locks-postgres:/run/compose-local:ro + - locks-postgres-data:/var/lib/postgresql/data + depends_on: + compose-bootstrap: + condition: service_completed_successfully + healthcheck: + test: ["CMD-SHELL", "pg_isready -U locks -d locks_test"] + interval: 2s + timeout: 5s + retries: 30 + + paykit-postgres: + image: postgres:17-bookworm@sha256:4f736ae292687621d4dbe0d499ffd024a36bd2ee7d8ca6f2ccd4c800f047b394 + entrypoint: ["/bin/bash", "-euc"] + command: + - | + set -a + . /run/compose-local/paykit-server/postgres.env + set +a + exec /usr/local/bin/docker-entrypoint.sh postgres + volumes: + - ./.local/paykit-postgres:/run/compose-local/paykit-server:ro + - paykit-postgres-data:/var/lib/postgresql/data + depends_on: + compose-bootstrap: + condition: service_completed_successfully + healthcheck: + test: ["CMD-SHELL", "pg_isready -U paykit -d paykit"] + interval: 2s + timeout: 5s + retries: 30 + + bitcoin: + image: bitcoin/bitcoin:29.1@sha256:de62c536feb629bed65395f63afd02e3a7a777a3ec82fbed773d50336a739319 + volumes: + - ./.local/bitcoin-rpc:/run/compose-local:ro + - bitcoin-data:/home/bitcoin/.bitcoin + entrypoint: ["/bin/bash", "-euc"] + command: + - | + set -a + . /run/compose-local/bitcoin-rpc.env + set +a + umask 077 + mkdir -p "$${BITCOIN_DATA}" + printf 'rpcuser=%s\nrpcpassword=%s\n' "$${BITCOIN_RPC_USER}" "$${BITCOIN_RPC_PASSWORD}" > "$${BITCOIN_DATA}/bitcoin.conf" + exec /entrypoint.sh bitcoind \ + -conf="$${BITCOIN_DATA}/bitcoin.conf" \ + -regtest=1 \ + -server=1 \ + -txindex=1 \ + -fallbackfee=0.00001 \ + -rpcbind=0.0.0.0 \ + -rpcallowip=0.0.0.0/0 \ + -rpcport=18443 + depends_on: + compose-bootstrap: + condition: service_completed_successfully + healthcheck: + test: ["CMD-SHELL", "bitcoin-cli -conf=\"$${BITCOIN_DATA}/bitcoin.conf\" -regtest getblockchaininfo >/dev/null"] + interval: 2s + timeout: 5s + retries: 60 + + bitcoin-bootstrap: + image: bitcoin/bitcoin:29.1@sha256:de62c536feb629bed65395f63afd02e3a7a777a3ec82fbed773d50336a739319 + user: "1000:1000" + network_mode: service:bitcoin + entrypoint: ["/bin/bash", "-euc"] + command: + - | + set -a + . /run/compose-local/bitcoin-rpc.env + set +a + exec /usr/local/bin/bitcoin-bootstrap.sh + volumes: + - ./.local/bitcoin-bootstrap:/home/bitcoin/.bitcoin + - ./.local/bitcoin-rpc:/run/compose-local:ro + - ./docker/bitcoin-bootstrap.sh:/usr/local/bin/bitcoin-bootstrap.sh:ro + depends_on: + bitcoin: + condition: service_healthy + + fulcrum: + image: cculianu/fulcrum:v1.11.1@sha256:70f06b93ab5863997992d4b4508312fe81ce576017e16ecc7e69c7d38165bdf2 + entrypoint: ["/bin/sh", "-euc"] + command: + - | + set -a + . /run/compose-local/bitcoin-rpc.env + set +a + exec /entrypoint.sh Fulcrum -b bitcoin:18443 -t 0.0.0.0:50001 + volumes: + - ./.local/bitcoin-rpc:/run/compose-local:ro + - fulcrum-data:/data + 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 + user: "1000:1000" + command: ["node", "/usr/local/bin/electrum-readiness.mjs"] + volumes: + - ./examples/js-sdk/scripts/electrum-readiness.mjs:/usr/local/bin/electrum-readiness.mjs:ro + depends_on: + fulcrum: + condition: service_started + + pubky-testnet: + build: + context: . + dockerfile: docker/pubky-testnet.Dockerfile + args: + PUBKY_CORE_REV: 75eb1324f86e8caa16c41f18a2cd6b8e1909ee7b + ports: + - "127.0.0.1:${LOCKS_DHT_PORT:-6881}:6881/udp" + - "127.0.0.1:${LOCKS_DHT_PORT:-6881}:6881/tcp" + - "127.0.0.1:${LOCKS_PKARR_RELAY_PORT:-15411}:15411" + - "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_SERVER_PORT:-3000}:3000" + - "127.0.0.1:${LOCKS_PAYKIT_PORT:-3001}:3001" + - "127.0.0.1:${LOCKS_CREATOR_DEMO_PORT:-8080}:8080" + - "127.0.0.1:${LOCKS_READER_DEMO_PORT:-8088}:8088" + volumes: + - ./.local/pubky-homeserver:/run/compose-local/pubky-homeserver:ro + command: ["pubky-testnet", "--homeserver-config", "/run/compose-local/pubky-homeserver/config.toml"] + depends_on: + 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: . + dockerfile: Dockerfile + + depends_on: + postgres: + condition: service_healthy + pubky-testnet: + condition: service_started + network_mode: service:pubky-testnet + environment: + RUST_LOG: ${LOCKS_RUST_LOG:-info,pubky::actors::session=warn} + LOCKS_PUBLIC_CONFIG: /run/locks-public/config.toml + volumes: + - lock-home:/var/lib/pubky-lock + - lock-public:/run/locks-public + - ./.local/locks-server:/run/compose-local/locks-server:ro + command: + - /bin/sh + - -euc + - | + set -a + . /run/compose-local/locks-server/compose.env + set +a + exec locks-server-compose-entrypoint.sh + healthcheck: + test: ["CMD-SHELL", "wget -q -O- http://127.0.0.1:3000/readyz >/dev/null"] + interval: 2s + timeout: 5s + retries: 60 + + paykit-config: + image: node:22-bookworm-slim@sha256:813a7480f28fdadac1f7f5c824bcdad435b5bc1322a5968bbbdef8d058f9dff4 + user: "1000:1000" + working_dir: /workspace + command: + - node + - examples/js-sdk/scripts/init-paykit-compose.mjs + - --config-only + - --lock-config + - /run/locks-public/config.toml + volumes: + - ./examples/js-sdk:/workspace/examples/js-sdk:ro + - ./.local/paykit-config:/workspace/.local/paykit-config + - lock-public:/run/locks-public:ro + depends_on: + locks-server: + condition: service_healthy + + demo-config: + image: node:22-bookworm-slim@sha256:813a7480f28fdadac1f7f5c824bcdad435b5bc1322a5968bbbdef8d058f9dff4 + user: "1000:1000" + working_dir: /workspace + command: + - node + - examples/js-sdk/scripts/init-config.mjs + - --lock-config + - /run/locks-public/config.toml + volumes: + - ./examples/js-sdk:/workspace/examples/js-sdk:ro + - ./.local/demo-config:/workspace/.local/demo-config + - lock-public:/run/locks-public:ro + depends_on: + locks-server: + condition: service_healthy + + paykit-server: + image: pubky-locks-paykit-server:local + build: + context: "https://github.com/pubky/paykit-server.git#5ed3e8e849a16045c26c37a75068625dda333785" + dockerfile: Dockerfile.local + additional_contexts: + 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: + paykit-postgres: + condition: service_healthy + pubky-testnet: + condition: service_started + paykit-config: + condition: service_completed_successfully + electrum-readiness: + condition: service_completed_successfully + network_mode: service:pubky-testnet + user: "1000:1000" + environment: + PAYKIT_CONFIG: /etc/paykit-server/config.toml + RUST_LOG: ${RUST_LOG:-info} + entrypoint: ["/bin/bash", "-euc"] + command: + - | + set -a + . /run/compose-local/paykit-server/paykit.env + set +a + exec /usr/local/bin/paykit-server + volumes: + - ./.local/paykit-config:/etc/paykit-server:ro + - ./.local/paykit-server:/run/compose-local/paykit-server:ro + healthcheck: + test: + - CMD + - /bin/bash + - -ec + - >- + check() { + exec 3<>/dev/tcp/127.0.0.1/3001; + printf 'GET %s HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n' "$$1" >&3; + IFS= read -r status <&3; + exec 3<&- 3>&-; + [[ "$$status" == *" 200 "* ]]; + }; + check /health/live && check /health/ready + interval: 2s + timeout: 5s + retries: 90 + + creator-demo: + build: + context: . + dockerfile: docker/js-demo.Dockerfile + additional_contexts: + paykit-runtime: service:paykit-server + working_dir: /workspace + user: "1000:1000" + depends_on: + locks-server: + condition: service_healthy + paykit-server: + condition: service_healthy + demo-config: + condition: service_completed_successfully + network_mode: service:pubky-testnet + environment: + LOCKS_INTERNAL_LOCK_SERVER_URL: http://127.0.0.1:3000 + LOCKS_INTERNAL_HTTP_RELAY: http://127.0.0.1:15412 + LOCKS_INTERNAL_PKARR_RELAY: http://127.0.0.1:15411 + LOCKS_INTERNAL_DHT_BOOTSTRAP: 127.0.0.1:6881 + PAYKIT_COMPANION_AUTH_BIN: /usr/local/bin/paykit-companion-auth + PUBKY_LOCK_DEBUG: ${PUBKY_LOCK_DEBUG:-0} + volumes: + - ./.local/demo-config:/workspace/.local/demo-config:ro + - ./.local/js-sdk-demo:/workspace/.local/js-sdk-demo + - ./.local/creator-public:/workspace/.local/creator-public + command: + - sh + - -lc + - | + set -eu + rm -f /workspace/.local/creator-public/profile.json + npm --prefix examples/js-sdk run start-server -- --external-wallet + + reader-demo: + restart: unless-stopped + build: + context: . + dockerfile: docker/js-demo.Dockerfile + additional_contexts: + paykit-runtime: service:paykit-server + working_dir: /workspace + user: "1000:1000" + depends_on: + locks-server: + condition: service_healthy + paykit-server: + condition: service_healthy + demo-config: + condition: service_completed_successfully + network_mode: service:pubky-testnet + environment: + LOCKS_INTERNAL_LOCK_SERVER_URL: http://127.0.0.1:3000 + LOCKS_INTERNAL_HTTP_RELAY: http://127.0.0.1:15412 + LOCKS_INTERNAL_PKARR_RELAY: http://127.0.0.1:15411 + LOCKS_INTERNAL_DHT_BOOTSTRAP: 127.0.0.1:6881 + PAYKIT_READER_DEMO_BIN: /usr/local/bin/paykit-reader-demo + PAYKIT_READER_STATE_PATH: /workspace/.local/paykit-reader/state.v1 + PAYKIT_READER_CREATOR_PROFILE_PATH: /workspace/.local/creator-public/profile.json + PAYKIT_READER_PUBKY_TESTNET_HOST: pubky-testnet + PAYKIT_READER_RECEIVER_PATH: bitkit/wallet + PAYKIT_READER_SERVER_PATH: bitkit/server + PAYKIT_READER_WORKER_ENABLED: "1" + PAYKIT_EXTERNAL_READER_PUBKY: ${PAYKIT_EXTERNAL_READER_PUBKY:-} + PUBKY_LOCK_DEBUG: ${PUBKY_LOCK_DEBUG:-0} + volumes: + - ./.local/demo-config:/workspace/.local/demo-config:ro + - ./.local/creator-public:/workspace/.local/creator-public:ro + - ./.local/content-viewer:/workspace/.local/content-viewer + - ./.local/paykit-reader:/workspace/.local/paykit-reader + command: + - sh + - -euc + - | + 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 + - node + - -e + - fetch('http://127.0.0.1:8088/api/paykit-reader/status').then((response) => { if (!response.ok) process.exit(1); }).catch(() => process.exit(1)) + interval: 2s + timeout: 2s + retries: 30 + start_period: 10s + +volumes: + lock-home: + lock-public: + locks-postgres-data: + name: pubky-locks-paykit-demo-locks-postgres + paykit-postgres-data: + name: pubky-locks-paykit-demo-paykit-postgres + bitcoin-data: + name: pubky-locks-paykit-demo-bitcoin + fulcrum-data: + name: pubky-locks-paykit-demo-fulcrum diff --git a/docker-compose.yml b/docker-compose.yml index f63d6ba..3dee10d 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -21,7 +21,7 @@ services: context: . dockerfile: docker/pubky-testnet.Dockerfile args: - PUBKY_CORE_REV: f68014c111af0458e6a321e2d87a12479bfb3218 + PUBKY_CORE_REV: 75eb1324f86e8caa16c41f18a2cd6b8e1909ee7b ports: - "${LOCKS_DHT_PORT:-6881}:6881/udp" - "${LOCKS_DHT_PORT:-6881}:6881/tcp" @@ -32,7 +32,7 @@ services: - "${LOCKS_HOMESERVER_ADMIN_PORT:-6288}:6288" - "${LOCKS_SERVER_PORT:-3000}:3000" - "${LOCKS_CREATOR_DEMO_PORT:-8080}:8080" - - "${LOCKS_READER_DEMO_PORT:-8081}:8081" + - "${LOCKS_READER_DEMO_PORT:-8088}:8088" volumes: - ./docker/pubky-homeserver.compose.toml:/etc/pubky-homeserver/config.toml:ro command: ["pubky-testnet", "--homeserver-config", "/etc/pubky-homeserver/config.toml"] @@ -70,8 +70,8 @@ services: network_mode: service:pubky-testnet environment: LOCKS_INTERNAL_LOCK_SERVER_URL: http://127.0.0.1:3000 - LOCKS_INTERNAL_HTTP_RELAY: http://localhost:15412 - LOCKS_INTERNAL_PKARR_RELAY: http://localhost:15411 + LOCKS_INTERNAL_HTTP_RELAY: http://127.0.0.1:15412 + LOCKS_INTERNAL_PKARR_RELAY: http://127.0.0.1:15411 LOCKS_INTERNAL_DHT_BOOTSTRAP: 127.0.0.1:6881 volumes: - .:/workspace @@ -99,8 +99,8 @@ services: network_mode: service:pubky-testnet environment: LOCKS_INTERNAL_LOCK_SERVER_URL: http://127.0.0.1:3000 - LOCKS_INTERNAL_HTTP_RELAY: http://localhost:15412 - LOCKS_INTERNAL_PKARR_RELAY: http://localhost:15411 + LOCKS_INTERNAL_HTTP_RELAY: http://127.0.0.1:15412 + LOCKS_INTERNAL_PKARR_RELAY: http://127.0.0.1:15411 LOCKS_INTERNAL_DHT_BOOTSTRAP: 127.0.0.1:6881 volumes: - .:/workspace diff --git a/docker/bitcoin-bootstrap.sh b/docker/bitcoin-bootstrap.sh new file mode 100755 index 0000000..22533fb --- /dev/null +++ b/docker/bitcoin-bootstrap.sh @@ -0,0 +1,42 @@ +#!/usr/bin/env bash +set -euo pipefail + +: "${BITCOIN_RPC_USER:?BITCOIN_RPC_USER is required}" +: "${BITCOIN_RPC_PASSWORD:?BITCOIN_RPC_PASSWORD is required}" + +umask 077 +rpc_config="$(mktemp)" +cleanup() { + rm -f -- "$rpc_config" +} +trap cleanup EXIT INT TERM +printf 'rpcuser=%s\nrpcpassword=%s\n' "$BITCOIN_RPC_USER" "$BITCOIN_RPC_PASSWORD" > "$rpc_config" + +bitcoin_cli() { + bitcoin-cli -conf="$rpc_config" -rpcconnect=127.0.0.1 -rpcport=18443 -regtest "$@" +} + +for _ in $(seq 1 120); do + if bitcoin_cli getblockchaininfo >/dev/null 2>&1; then + break + fi + sleep 1 +done +bitcoin_cli getblockchaininfo >/dev/null 2>&1 || { + printf '%s\n' 'bitcoin bootstrap failed: RPC unavailable' >&2 + exit 1 +} + +if ! bitcoin_cli listwalletdir | grep -q '"name": "miner"'; then + bitcoin_cli createwallet miner >/dev/null +elif ! bitcoin_cli listwallets | grep -q '"miner"'; then + bitcoin_cli loadwallet miner >/dev/null +fi + +height="$(bitcoin_cli getblockcount)" +if (( height < 101 )); then + address="$(bitcoin_cli -rpcwallet=miner getnewaddress)" + bitcoin_cli -rpcwallet=miner generatetoaddress "$((101 - height))" "$address" >/dev/null +fi + +printf '%s\n' 'bitcoin bootstrap ready' diff --git a/docker/js-demo.Dockerfile b/docker/js-demo.Dockerfile new file mode 100644 index 0000000..3eb9440 --- /dev/null +++ b/docker/js-demo.Dockerfile @@ -0,0 +1,28 @@ +# 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 \ + && apt-get install -y --no-install-recommends ca-certificates util-linux \ + && rm -rf /var/lib/apt/lists/* +COPY --chown=node:node examples/js-sdk/package.json examples/js-sdk/package-lock.json /workspace/examples/js-sdk/ +RUN npm --prefix examples/js-sdk ci --ignore-scripts \ + && npm cache clean --force +COPY --chown=node:node examples/js-sdk /workspace/examples/js-sdk +COPY --from=locks-sdk-wasm --chown=node:node /workspace/locks-sdk/bindings/js/pkg /workspace/locks-sdk/bindings/js/pkg +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/.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 ed28851..e114aec 100644 --- a/docker/locks-server-compose-entrypoint.sh +++ b/docker/locks-server-compose-entrypoint.sh @@ -6,6 +6,7 @@ generated_config="$service_home/config.toml" compose_config="${LOCKS_COMPOSE_CONFIG:-/var/lib/pubky-lock/config.compose.toml}" secret_path="$service_home/secret.sess" creator_authority_key_path="$service_home/creator-authority-encryption-key" +public_config="${LOCKS_PUBLIC_CONFIG:-/run/locks-public/config.toml}" mkdir -p "$service_home" @@ -46,6 +47,12 @@ if [ -z "$lock_server_public_key" ] || [ "$lock_server_public_key" = " "$public_config_tmp" +chmod 0644 "$public_config_tmp" +mv "$public_config_tmp" "$public_config" + cat > "$compose_config" <`. - Derives creator from the Locks-local frontend session. - Returns secret-free missing/present creator authority status. +- Paykit setup readiness route: `GET /creator/paykit/setup-status` + - Requires `Authorization: Bearer `. + - Derives Creator identity from the Locks frontend session and sends no browser-supplied Creator. + - Returns only `ready`, `setup_required`, or `unavailable`. - Hosted legacy creator connect/session routes: `GET /connect`, `POST /connect/{flow_id}/complete`, `POST /frontend-sessions`, `DELETE /frontend-sessions/current` - Mount when explicit `[creator_authority_acquisition].enabled = true`. - `DELETE /frontend-sessions/current` requires `Authorization: Bearer ` and revokes the current frontend session. @@ -54,6 +58,7 @@ Gated-off routes are plain Axum `404 Not Found` responses because the route is i | `DELETE /frontend-sessions/current` | `204` empty response | Requires `Authorization: Bearer `. Mounted with creator authority acquisition. | Token is request-only and is deleted from the frontend session store. | `401 frontend_session_unavailable`, `401 frontend_session_expired`, `404` when route gated off | | `GET /.well-known/locks-server` | `200` JSON service identity | Public. Always mounted. CORS-enabled. | No secrets. Used by browser SDK to verify service, API version, and Lock Server Pubky identity. | n/a | | `GET /creator/authority-status` | `200` JSON secret-free authority status | Requires `Authorization: Bearer `. Creator is derived from the frontend session. | Response contains only creator, boolean status, auth kind, scopes, and optional expiry; no tokens, codes, authorization URLs, secrets, or DB/config values. | `401 frontend_session_unavailable`, `401 frontend_session_expired`, `404` only if route absent in older deployments | +| `GET /creator/paykit/setup-status` | `200` JSON coarse Paykit setup status | Requires `Authorization: Bearer `. Creator is derived from the session; query/body Creator input is rejected. | Response contains only `status`; Paykit URL, HTTP status, authority details, credentials, and internal failures are never exposed. | `401 frontend_session_unavailable`, `401 frontend_session_expired`; authenticated Paykit failures return `200 {"status":"unavailable"}` | | `POST /proof-bundles` | `200` JSON lifecycle | Public viewer route. A new `paykit-payment` lifecycle identity requires `[paykit]` runtime config; an exact persisted replay does not. | No bearer secrets, invoice data, or raw proof material in response. | `400 invalid_request`, `409 task_state_conflict`, `422 unsupported_verifier_type`, `422 paykit_not_configured`, `422 reader_pubky_unresolvable`, `429 rate_limited`, `502 paykit_invoice_creation_failed` | | `POST /verification-task-lookups` | `200` JSON lifecycle | Public viewer route. | No bearer secrets in response. | `400 invalid_request`, `404 verification_task_not_found` | | `POST /verification-task-completions` | `200` JSON lifecycle | Dev-only completion gate. | No bearer secrets in response. | `400 invalid_request`, `404 verification_task_not_found`, `409 task_state_conflict`, `404` when route gated off | @@ -253,6 +258,43 @@ Error cases: - Missing/malformed frontend session bearer: `401 frontend_session_unavailable`. - Expired frontend session: `401 frontend_session_expired`. +## Paykit setup readiness route + +### `GET /creator/paykit/setup-status` + +Returns whether the authenticated Creator already has usable authority configured on Paykit +Server. Creator identity comes only from the Locks frontend session. + +```http +GET /creator/paykit/setup-status +Authorization: Bearer +``` + +The request has no query parameters and no body. Success is always a closed one-field object: + +```json +{"status":"ready"} +``` + +```json +{"status":"setup_required"} +``` + +```json +{"status":"unavailable"} +``` + +- `ready`: skip Paykit authorization. +- `setup_required`: launch the Paykit setup iframe. +- `unavailable`: show a retry/degraded state. **Never** treat this as `setup_required` and never + launch authorization from it. + +For authenticated requests, missing Paykit configuration, timeout/network failure, non-success +Paykit responses, and malformed/unsupported Paykit success bodies all project to +`{"status":"unavailable"}`. Frontend-session failures retain the existing `401` error envelope. +The browser response never exposes Paykit failure details, configured URLs, Creator authority +details, or credentials. + ## Creator publishing routes Creator publishing routes always use Pubky homeserver-backed repositories. Callers must pass `Authorization: Bearer `. Handlers derive creator from the Locks-local frontend session and reject request-body `creator`; frontend session tokens must not be supplied in query strings, request bodies, or cookies. diff --git a/docs/LOCAL_OPERATOR_DEMO.md b/docs/LOCAL_OPERATOR_DEMO.md index e0b214c..fb12e9f 100644 --- a/docs/LOCAL_OPERATOR_DEMO.md +++ b/docs/LOCAL_OPERATOR_DEMO.md @@ -11,6 +11,60 @@ Creator publishing is authenticated. The removed unauthenticated local/dev creat - [`docs/RUNTIME.md`](RUNTIME.md): startup/config/worker/health-readiness behavior. - [`docs/API.md`](API.md): route contract and fixture-backed request/response shapes. - [`docs/LOCAL_DEMO.md`](LOCAL_DEMO.md): E2E/test-support client demo using Axum `Router::oneshot`. +- [`examples/js-sdk/README.md`](../examples/js-sdk/README.md): browser-facing Paykit local demo setup and operator commands. + +## Paykit Compose local demonstration + +The repository's browser-facing Paykit demonstration is a separate operator path from the manual single-server walkthrough below. Its local-only definition is `compose.paykit-local-demo.yaml`. It composes PostgreSQL, Bitcoin regtest, Fulcrum, Pubky testnet, Locks, Paykit Server, and the creator and reader browser demos. External source builds use anonymous public Git contexts pinned to immutable commits; no sibling repository checkout is required. + +The startup dependency flow is: + +```mermaid +flowchart TD + composeBootstrap[compose-bootstrap] --> postgres + composeBootstrap --> paykitPostgres[paykit-postgres] + composeBootstrap --> bitcoin + + postgres --> pubkyTestnet[pubky-testnet] + postgres --> locksServer[locks-server] + pubkyTestnet --> locksServer[locks-server] + + bitcoin --> bitcoinBootstrap[bitcoin-bootstrap] + bitcoinBootstrap --> fulcrum + fulcrum --> electrumReadiness[electrum-readiness] + + locksServer --> paykitConfig[paykit-config] + locksServer --> demoConfig[demo-config] + + paykitPostgres --> paykitServer[paykit-server] + pubkyTestnet --> paykitServer + paykitConfig --> paykitServer + electrumReadiness --> paykitServer + + locksServer --> creatorDemo[creator-demo] + paykitServer --> creatorDemo + demoConfig --> creatorDemo + pubkyTestnet --> creatorDemo + + locksServer --> readerDemo[reader-demo] + paykitServer --> readerDemo + demoConfig --> readerDemo + pubkyTestnet --> readerDemo +``` + +`creator-demo` and `reader-demo` wait for healthy `locks-server` and `paykit-server` services plus successful `demo-config` completion. + +The bootstrap and configuration services are one-shot startup jobs. Exiting successfully is their healthy terminal state; they do not remain as long-running processes: + +| Service | Responsibility | Downstream gate | +| --- | --- | --- | +| `compose-bootstrap` | Creates or validates ignored local credentials, service environment files, Pubky homeserver configuration, state directories, ownership, and permissions. | Both PostgreSQL services and Bitcoin start only after successful completion. | +| `bitcoin-bootstrap` | Waits for regtest RPC, creates or loads the `miner` wallet, and mines to height 101 so coinbase funds are mature and spendable. | Fulcrum starts only after successful completion. | +| `electrum-readiness` | Sends an Electrum `server.version` request and validates the response; a started container or open TCP port alone is insufficient. | Paykit Server starts only after protocol readiness succeeds. | +| `paykit-config` | Waits for Locks Server to publish its runtime public key, then generates Paykit Server configuration that trusts that exact identity. | Paykit Server starts only after successful generation. | +| `demo-config` | Generates the shared browser configuration from the runtime Locks Server identity and local testnet endpoints. | Creator and reader demos start only after successful generation. | + +The complete startup and reset commands, browser URLs, local state boundaries, and manual payment workflow are maintained in [`examples/js-sdk/README.md`](../examples/js-sdk/README.md). ## Dev legacy-connect testnet automation diff --git a/docs/RUNTIME.md b/docs/RUNTIME.md index 5acb082..ee4d783 100644 --- a/docs/RUNTIME.md +++ b/docs/RUNTIME.md @@ -115,7 +115,7 @@ pkarr_relays = [] key_republisher_interval_seconds = 3600 ``` -`public_pubky_tls_port` and `public_icann_http_port` advertise externally reachable ports. `icann_domain` is browser/ICANN fallback target. Local testnet operators should set `pkarr_relays = ["http://localhost:15411"]`. +`public_pubky_tls_port` and `public_icann_http_port` advertise externally reachable ports. `icann_domain` is browser/ICANN fallback target. Local testnet operators should set `pkarr_relays = ["http://127.0.0.1:15411"]`. PKARR publishing starts when environment is `staging`/`production` or creator-authority acquisition is enabled, and republishes every `key_republisher_interval_seconds` seconds. diff --git a/docs/SDK.md b/docs/SDK.md index dbadc74..a1a16ef 100644 --- a/docs/SDK.md +++ b/docs/SDK.md @@ -47,7 +47,7 @@ python3 -m http.server 8080 --directory locks-sdk/bindings/js Open: ```text -http://localhost:8080/demo/ +http://127.0.0.1:8080/demo/ ``` The demo imports `../pkg/locks_sdk_wasm.js`, so it only exercises locally generated wasm-pack output. It still requires a real configured Lock Server Pubky with a browser-usable PKARR domain endpoint and working `legacy-connect` creator acquisition. @@ -130,7 +130,7 @@ For local Pubky testnet browser development, configure the local PKARR relay exp import { Locks, LocksOptions } from "locks-sdk-wasm"; const options = new LocksOptions(); -options.addPkarrRelay("http://localhost:15411"); +options.addPkarrRelay("http://127.0.0.1:15411"); const locks = Locks.forServerWithOptions("pubky...", options); ``` @@ -138,9 +138,9 @@ const locks = Locks.forServerWithOptions("pubky...", options); Local `pubky-core/pubky-testnet` defaults are: ```text -PKARR relay = http://localhost:15411 -HTTP/auth relay = http://localhost:15412 -DHT bootstrap = localhost:6881 +PKARR relay = http://127.0.0.1:15411 +HTTP/auth relay = http://127.0.0.1:15412 +DHT bootstrap = 127.0.0.1:6881 ``` This remains the stable browser path. @@ -332,6 +332,29 @@ Request body: } ``` +### Check Paykit setup readiness + +```ts +const result = await session.creator.paykitSetupStatus(); + +switch (result.status) { + case "ready": + // Skip Paykit authorization. + break; + case "setup_required": + // Launch the Paykit setup iframe. + break; + case "unavailable": + // Show retry/degraded state. Do not launch authorization. + break; +} +``` + +`paykitSetupStatus()` takes no Creator argument. It uses the current Locks frontend-session bearer +and calls `GET /creator/paykit/setup-status` with no body. The Lock Server derives the Creator from +that session. The returned object contains only `status: "ready" | "setup_required" | +"unavailable"`; malformed responses reject instead of being interpreted as setup readiness. + ## Viewer/access APIs Viewer calls do not require a Pubky identity in v0. Callers choose and durably store a `BundleId`; treat it as bearer-like recovery state. diff --git a/docs/SDK_LIVE_SMOKE.md b/docs/SDK_LIVE_SMOKE.md index a00271b..2b1c586 100644 --- a/docs/SDK_LIVE_SMOKE.md +++ b/docs/SDK_LIVE_SMOKE.md @@ -17,7 +17,7 @@ The command is informational and exits 0. It reports whether the following envir | Variable | Meaning | | --- | --- | | `LOCKS_LIVE_LOCK_SERVER` | Lock Server Pubky with a browser-usable PKARR endpoint | -| `LOCKS_LIVE_PKARR_RELAY` | PKARR relay URL. Local `pubky-testnet` uses `http://localhost:15411` | +| `LOCKS_LIVE_PKARR_RELAY` | PKARR relay URL. Local `pubky-testnet` uses `http://127.0.0.1:15411` | | `LOCKS_LIVE_CREATOR` | Creator Pubky publishing `/pub/locks.app/config.json` | | `LOCKS_LIVE_CONTENT_LOCK_RESOURCE` | Canonical `pubky.../pub/locks.app/.json` resource | | `LOCKS_LIVE_DEMO_ORIGIN` | Browser origin allowed by `creator_authority_acquisition.legacy_connect.allowed_return_origins` | @@ -34,7 +34,7 @@ python3 -m http.server 8080 --directory locks-sdk/bindings/js Open: ```text -http://localhost:8080/demo/ +http://127.0.0.1:8080/demo/ ``` Then verify: @@ -43,7 +43,7 @@ Then verify: ```ts const options = new LocksOptions(); - options.addPkarrRelay(process.env.LOCKS_LIVE_PKARR_RELAY ?? "http://localhost:15411"); + options.addPkarrRelay(process.env.LOCKS_LIVE_PKARR_RELAY ?? "http://127.0.0.1:15411"); ``` 2. `Locks.forServerWithOptions(lockServer, options)` displays the configured Lock Server Pubky. diff --git a/examples/js-sdk/README.md b/examples/js-sdk/README.md index 99d36ec..6c7a6ae 100644 --- a/examples/js-sdk/README.md +++ b/examples/js-sdk/README.md @@ -1,6 +1,6 @@ # JS SDK local testnet creator/reader demos -These examples are a script-driven local Pubky testnet workflow plus browser UIs for the Locks JS/WASM SDK creator and unauthenticated reader paths. +These examples are a script-driven local Pubky testnet workflow plus browser UIs for the Locks JS/WASM SDK creator and reader paths. The reader browser remains unauthenticated; `paykit-payment` additionally uses a fixed `content-viewer` identity only inside the native Paykit reader helper. The creator demo publishes locked content and displays a **Viewer content lock resource**. It has separate controls for selecting the primary file and optional secondary files. The primary file becomes the default resource readers usually open first; each secondary file is uploaded as an additional resource in the same content lock. Copy the viewer resource into the separate reader demo to exercise the unauthenticated reader flow. @@ -15,8 +15,14 @@ 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 +examples/js-sdk/scripts/receive-paykit-request.mjs +examples/js-sdk/scripts/register-paykit-reader.mjs +examples/js-sdk/scripts/lib/paykit-reader-worker.mjs +examples/js-sdk/scripts/test-paykit-reader-worker.mjs examples/js-sdk/scripts/start-demo-server.mjs examples/js-sdk/scripts/start-reader-demo-server.mjs ``` @@ -24,7 +30,7 @@ examples/js-sdk/scripts/start-reader-demo-server.mjs Generated local state lives under: ```text -./.local/js-sdk-demo/config.json +./.local/demo-config/config.json ./.local/js-sdk-demo/content-creator-session.json ./.local/lock-server/passphrase ./.local/lock-server/recovery_file @@ -32,6 +38,13 @@ Generated local state lives under: ./.local/content-creator/passphrase ./.local/content-creator/recovery_file ./.local/content-creator/profile.json +./.local/content-viewer/passphrase +./.local/content-viewer/recovery_file +./.local/content-viewer/profile.json +./.local/paykit-reader/state.v1 +./.local/paykit-reader/prepared.v1.json +./.local/paykit-reader/worker.v1.json +./.local/paykit-reader/owner.lock ``` ## Prerequisites @@ -43,13 +56,13 @@ Required tools/services: - Postgres reachable by the Lock Server. - A local Pubky testnet exposing: ```text - PKARR relay http://localhost:15411 - HTTP/auth relay http://localhost:15412 - Auth inbox http://localhost:15412/inbox/ - DHT bootstrap localhost:6881 + PKARR relay http://127.0.0.1:15411 + HTTP/auth relay http://127.0.0.1:15412 + Auth inbox http://127.0.0.1:15412/inbox/ + DHT bootstrap 127.0.0.1:6881 ``` -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 @@ -69,14 +82,13 @@ The examples package uses `@synonymdev/pubky` for Node-side Pubky testnet auth/k ## Local environment setup -The demos need four processes/services alive at the same time: +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. -1. local Pubky testnet -2. Postgres -3. Lock Server on `127.0.0.1:3000` -4. creator demo server on `localhost:8080` and/or reader demo server on `localhost:8081` +The Compose image builds the JS/WASM package itself. A fresh checkout does not need a host-generated `locks-sdk/bindings/js/pkg` directory. -### Docker Compose local stack +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 For a containerized local stack from the repository root: @@ -93,9 +105,9 @@ The compose stack starts: - Postgres on host port `55433` - Pubky testnet on `15411`, `15412`, `6881`, homeserver HTTP on `6286`, and homeserver admin on `6288` -- Lock Server on `http://localhost:3000` -- creator demo on `http://localhost:8080/examples/js-sdk/` -- reader demo on `http://localhost:8081/reader/` +- Lock Server on `http://127.0.0.1:3000` +- creator demo on `http://127.0.0.1:8080/examples/js-sdk/` +- reader demo on `http://127.0.0.1:8088/reader/` The Pubky testnet image is built from the public `pubky/pubky-core` repository at the revision pinned in `docker-compose.yml`; no sibling checkout is required. @@ -114,163 +126,127 @@ If writer authentication fails after a local testnet restart with a malformed or docker compose exec creator-demo npm --prefix examples/js-sdk run create-user -- --role content-creator --force ``` -The browser-facing demo config still uses `localhost`; container-internal health checks/auth use Docker service names through `LOCKS_INTERNAL_*` environment overrides. +The browser-facing demo config uses `127.0.0.1`; container-internal health checks/auth use explicit loopback or Docker service names through `LOCKS_INTERNAL_*` environment overrides. -### 1. Start local Pubky testnet +## Local Pubky testnet defaults -Start `pubky-core/pubky-testnet` using its local static development defaults. From the Locks examples' point of view, these endpoints must respond: +`pubky-core/pubky-testnet` local static development uses: -```bash -curl -i http://localhost:15411 -curl -i http://localhost:15412 +```text +PKARR relay = http://127.0.0.1:15411 +HTTP/auth relay = http://127.0.0.1:15412 +Pubky Auth inbox = http://127.0.0.1:15412/inbox/ +DHT bootstrap = 127.0.0.1:6881 +Paykit browser = http://127.0.0.1:3001 ``` -`404` from the relay root is fine. Connection refused means the testnet is not running or is using different ports. - -The examples assume the homeserver Pubky is: +These values are written to: ```text -pubky8pinxxgqs41n4aididenw5apqp1urfmzdztr8jt4abrkdn435ewo +./.local/demo-config/config.json ``` -If your local testnet homeserver differs, edit `./.local/js-sdk-demo/config.json` after `init-config` and change `testnet.homeserver`. - -### 2. Start Postgres +## Setup -Use whatever local Postgres you normally use. The Lock Server reads its URL from `PUBKY_LOCK_DATABASE_URL`; include the database name explicitly: +Initialize JS demo config: ```bash -export PUBKY_LOCK_DATABASE_URL='postgres://locks:locks@localhost:55433/locks_test' +npm --prefix examples/js-sdk run init-config ``` -A quick readiness check: +Create the content creator signing keypair: ```bash -psql "$PUBKY_LOCK_DATABASE_URL" -c 'select 1;' +npm --prefix examples/js-sdk run create-user -- --role content-creator ``` -If you use a different local database/user/port, keep the same environment variable name and update only the URL value. +The dev-static reader flow does not need a `content-viewer` keypair. The Paykit reader flow does: -### 3. Configure Lock Server for the JS demos - -The examples do **not** generate or mutate Lock Server TOML. They read the Lock Server Pubky from: - -```text -~/.pubky-lock/config.toml +```bash +npm --prefix examples/js-sdk run create-user -- --role content-viewer ``` -Generate a local creator-authority encryption key for the same shell that starts the Lock Server: +The embedded reader worker registers that identity with the configured local homeserver before invoking the native helper. Reader recovery material is loaded from the existing encrypted role files and sent only on helper stdin. + +Existing keypairs are reused. To regenerate one role: ```bash -export PUBKY_LOCK_CREATOR_AUTH_ENCRYPTION_KEY="$( - python3 - <<'PY' -import base64, os -print(base64.urlsafe_b64encode(os.urandom(32)).decode().rstrip('=')) -PY -)" +npm --prefix examples/js-sdk run create-user -- --role content-creator --force ``` -To generate the default config and Lock Server secret, start the server once after setting `PUBKY_LOCK_DATABASE_URL` and `PUBKY_LOCK_CREATOR_AUTH_ENCRYPTION_KEY`: +Deleting `.local` deletes the local recovery-file identities as well as disposable demo state. +Authentication never silently regenerates a missing identity because that would approve as a +different Pubky. Recreate the fallback identity explicitly before authenticating: ```bash -cargo run -p locks-server +npm --prefix examples/js-sdk run create-user -- --role content-creator ``` -Stop it after it writes `~/.pubky-lock/config.toml` and `~/.pubky-lock/secret.sess`. The generated config contains the real `credentials.lock_server_public_key`; the JS demo config initializer refuses placeholder values. - -For the browser creator/reader demos, edit `~/.pubky-lock/config.toml` and make sure it has these local-testnet values: - -```toml -bind_addr = "127.0.0.1:3000" - -[worker] -enabled = true - -[runtime] -environment = "development" - -[creator_authority_acquisition] -enabled = true -method = "legacy-connect" -frontend_session_ttl_seconds = 86400 -frontend_session_code_ttl_seconds = 120 +Replacing the content-creator identity clears any persisted demo-auth session for the old key before and after rotation. The demo server also validates persisted and newly approved sessions against the current role profile, so an approval that completes during rotation cannot restore the old identity. Authenticate the demo again before continuing. -[creator_authority_acquisition.legacy_connect] -allowed_return_origins = ["http://localhost:8080"] - -[pubky] -network = "testnet" +## Run the demo server -[pkdns] -icann_domain = "localhost" -public_icann_http_port = 3000 -pkarr_relays = ["http://localhost:15411"] -``` +### Complete local Compose stack: quickstart -Keep the generated or derived `credentials.lock_server_public_key` value in that file. If it is still: +1. From the repository root, build and start the complete stack in the background: -```toml -lock_server_public_key = "" +```bash +docker compose --file compose.paykit-local-demo.yaml up -d --build ``` -start the Lock Server once so it can initialize `~/.pubky-lock/secret.sess` and rewrite/derive the real public key before running `npm --prefix examples/js-sdk run init-config`. +2. Open the content-creator demo: -### 4. Start Lock Server - -```bash -cargo run -p locks-server +```text +http://127.0.0.1:8080/examples/js-sdk/ ``` -Wait for these checks to pass: +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 -curl -fsS http://127.0.0.1:3000/healthz -curl -fsS http://127.0.0.1:3000/readyz -curl -fsS http://127.0.0.1:3000/.well-known/locks-server +npm --prefix examples/js-sdk run authenticate -- --role content-creator +npm --prefix examples/js-sdk run authenticate-paykit -- --role content-creator ``` -The Lock Server also needs its PKARR record published to the local relay. With the config above, startup/republishing should publish through `http://localhost:15411`. The browser SDK depends on that record when resolving `_pubky.`. +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. -## Local Pubky testnet defaults +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. -`pubky-core/pubky-testnet` local static development uses: +`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. -```text -PKARR relay = http://localhost:15411 -HTTP/auth relay = http://localhost:15412 -Pubky Auth inbox = http://localhost:15412/inbox/ -DHT bootstrap = localhost:6881 -``` +This starts separate Locks and Paykit PostgreSQL services, Bitcoin Core regtest, a 101-block wallet bootstrap, Fulcrum readiness through `server.version`, Pubky testnet, a local Homegate-compatible signup bridge, Locks, Paykit Server, and both browser demos. All published ports bind to host loopback. Paykit is browser-visible at `http://127.0.0.1:3001`, the Homegate bridge at `http://127.0.0.1:6288`, and Fulcrum at `tcp://127.0.0.1:60001`. Locks reaches Paykit at `http://127.0.0.1:3001` inside the shared Pubky network namespace. The unprivileged creator and reader images contain the reviewed native helpers and a package built in the image; they receive only their explicit runtime directories, never the repository root or Lock Server identity volume. -These values are written to: +Open: ```text -./.local/js-sdk-demo/config.json +Creator: http://127.0.0.1:8080/examples/js-sdk/ +Reader: http://127.0.0.1:8088/reader/ +Paykit: http://127.0.0.1:3001/setup ``` -## Setup +The reader uses port `8088` for both direct npm and Compose; there is no host/container remap. -Initialize JS demo config: +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 -npm --prefix examples/js-sdk run init-config +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' ``` -Create the content creator signing keypair: +The Compose reader process listens on port `8088`. To remove the four explicit disposable database/Bitcoin/Fulcrum volumes, empty bootstrap scratch directory, and encrypted reader-helper state while preserving generated credentials/config, role identities, and Lock Server identity: ```bash -npm --prefix examples/js-sdk run create-user -- --role content-creator +npm --prefix examples/js-sdk run reset-paykit-demo ``` -The unauthenticated reader demo does not need a `content-viewer` keypair. - -Existing keypairs are reused. To regenerate one role: - -```bash -npm --prefix examples/js-sdk run create-user -- --role content-creator --force -``` +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. -## Run the demo server +### Direct npm server ```bash npm --prefix examples/js-sdk run start-server @@ -285,12 +261,13 @@ npm --prefix examples/js-sdk run start-server -- --allow-unhealthy Open: ```text -http://localhost:8080/examples/js-sdk/ +http://127.0.0.1:8080/examples/js-sdk/ ``` -## Run the reader demo server +## Run the reader demo server directly with npm -The reader demo is a separate static/debug server on port `8081` inside the same package: +Outside the Paykit Compose flow, the reader demo is a separate static/debug server on the +same port `8088` inside the same package: ```bash npm --prefix examples/js-sdk run start-reader-server @@ -305,7 +282,7 @@ npm --prefix examples/js-sdk run start-reader-server -- --allow-unhealthy Open: ```text -http://localhost:8081/reader/ +http://127.0.0.1:8088/reader/ ``` Reader-server responsibilities are intentionally narrow: @@ -327,7 +304,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 -- \ @@ -341,7 +320,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 @@ -351,15 +330,15 @@ It signs up/registers the `content-creator` with the configured homeserver, appr Click **Authenticate to Lock Server**. -The browser uses the Locks JS/WASM SDK to redirect to the Lock-Server-hosted `/connect` shell. The raw legacy-connect authorization URL stays on the Lock Server origin. +Both creator pages open the Lock Server `/connect` shell in an iframe modal. The raw legacy-connect authorization URL stays on the Lock Server origin. -The callback URL is: +The shell returns `{ state, code }` directly to the parent with `postMessage`. The parent accepts the result only from the exact Lock Server origin and iframe window, then validates the state before exchanging the one-time code. The configured callback URL supplies the parent target origin; the browser does not navigate to it: ```text -http://localhost:8080/auth/lock-server/callback +http://127.0.0.1:8080/auth/lock-server/callback ``` -Approve the Lock Server auth string with the same 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 -- \ @@ -367,7 +346,7 @@ npm --prefix examples/js-sdk run authenticate -- \ --auth "pubkyauth://..." ``` -After callback, the browser stores the Locks frontend session in `localStorage`. +The demo homeserver flow and Lock Server flow must both be approved by that same content-creator identity. The browser verifies the creator returned by the Lock Server against the live demo-auth creator. If the demo creator later changes or signs out, the browser revokes and clears the old Locks frontend session, closes any pending Locks auth flow, clears creator-scoped pointer state, and requires matching reauthentication before publishing. After a successful code exchange, the Locks frontend session is kept in memory only and is cleared on reload. ### 3. Configure pointer and create locked content @@ -384,10 +363,29 @@ Rules: ``` - only the filename segment is editable - `/` in filename is rejected -- verifier dropdown has one option: - ```text - dev-static - ``` +- lock type defaults to `dev-static`; `paykit-payment` is the alternate mode +- `paykit-payment` amount is a positive decimal integer string in sats +- payment asset is fixed to `BTC` +- payment recipient is the authenticated content creator; it is not user-editable +- payment is the content lock's sole criterion and the lock logic references exactly that criterion +- payment publishing is rejected until Paykit setup succeeds for the current authenticated creator +- selecting `paykit-payment` opens `GET http://127.0.0.1:3001/setup` in a Paykit-origin iframe +- the parent accepts completion only from that exact iframe window and origin with the pending state +- the success callback is only `{ type: "paykit-setup-callback", state }`; failures add only `error: "setup-failed"`, and account data stays inside Paykit + +The Paykit iframe displays the auth URL and both approved local commands. First create or load the dedicated Bitcoin Core descriptor wallet and print its external BIP84 account `tpub` and account index: + +```bash +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. 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 +npm --prefix examples/js-sdk run authenticate-paykit -- --role content-creator +``` + +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: @@ -405,11 +403,11 @@ After success, the page displays the **Viewer content lock resource**: ## Reader browser flow -The reader demo is unauthenticated for now. It does not create or use a Pubky reader identity. +The browser remains unauthenticated. A `paykit-payment` proof carries the public key prepared by the native helper; the browser never receives the reader secret or encrypted Paykit state. 1. Copy the creator demo's **Viewer content lock resource** and paste it into the reader demo. 2. Click **Load lock**. The browser SDK validates the content lock and resolves the Lock Server. -3. Choose `dev-static` proof control: +3. The loaded lock selects its verifier mode. For `dev-static`, choose: ```text satisfied = true | false ``` @@ -418,7 +416,21 @@ The reader demo is unauthenticated for now. It does not create or use a Pubky re 6. Click **Issue access credential**. 7. Click **Read guarded content**. -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. +For `paykit-payment`: + +1. The in-process Paykit reader worker starts with `reader-demo`, creates or restores the durable encrypted reader state, publishes and reads back its Receiver Marker, and waits for private Paykit messages. The page polls its closed status automatically; proof submission remains disabled until the worker is prepared and its Reader Pubky matches the current `content-viewer` identity. +2. Click **Submit proof bundle**. The browser submits one `paykit-payment` proof with the confirmed top-level `reader_public_key` and an empty `{}` criterion payload. It never calls the dev completion route. +3. The worker advances the Paykit/Noise link and receives the real Payment Request without a foreground command. The page displays only its validated request ID, regtest address, amount in sats, canonical manual `bitcoin-cli` payment command, and optional mining command. +4. Run the displayed payment command in a terminal. Mining is optional because local Locks uses `minimum_confirmations = 0`. +5. The page polls `pending` and `in_progress` lifecycle states. On `completed`, it issues an access credential and reads the primary guarded resource. `failed`, `expired`, and unknown states fail closed. Use **Resume payment verification polling** after a reload. + +The worker is the sole mutable owner of `./.local/paykit-reader/state.v1`. A direct child holds a kernel advisory lock on the owner-only `./.local/paykit-reader/owner.lock` file for the worker lifetime; the child exits when the parent's stdin closes, so the kernel releases ownership after normal exit or a crash without stale-lock takeover. The legacy `prepare-paykit-reader` and `receive-paykit-request` wrappers reject execution while the embedded worker is enabled and acquire the same lock when run standalone. + +`GET /api/health` reports HTTP-process liveness only. `GET /api/paykit-reader/status` reports the separate worker readiness/projection contract; Compose uses that second endpoint for health so a serving but unprepared or failed reader is not considered ready. + +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. 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 @@ -432,8 +444,8 @@ This does not run live browser flows. It verifies that the examples keep the agr ## Boundaries -- Authenticated reader UI is deferred. -- The reader demo uses manual paste only; it does not auto-read creator demo state. -- Lock Server TOML generation is out of scope. +- Browser-side authenticated reader sessions are not used; the Paykit reader identity stays in the one-shot native helper workflow. +- The reader demo manually pastes only the creator's content-lock resource; the Paykit Reader Pubky comes exclusively from the local prepared-status handshake. +- Compose generates closed Locks and Paykit TOML from actual local identities; trusted-key placeholders are never runnable configuration. - The second auth flow must use Lock Server `/connect`, not a demo-origin rendering of the raw `authorization_url`. - The examples do not use a gateway/base URL fallback. SDK calls resolve through browser PKARR/domain paths using the configured local PKARR relay. diff --git a/examples/js-sdk/app-iframe.js b/examples/js-sdk/app-iframe.js index 1510874..cb8d576 100644 --- a/examples/js-sdk/app-iframe.js +++ b/examples/js-sdk/app-iframe.js @@ -2,11 +2,15 @@ import { configureLockServicePointer, exchangeCreatorConnectCode, publishLockedContent, + signOutCreator, startCreatorConnect, } from './creator-complete-flow.js'; +import { invalidateIdentityScopedCreatorState } from './creator-identity.js'; +import { buildCreatorLockPolicy } from './creator-lock-policy.js'; +import { acceptPaykitSetupEvent, buildPaykitSetupRequest } from './paykit-setup.js'; import init, { Locks } from '../../locks-sdk/bindings/js/pkg/locks_sdk_wasm.js'; -// iframe flow variant of app.js — direct postMessage delivery (ADR 0019). +// Shared creator-page iframe flow — direct postMessage delivery (ADR 0019). // Step 2 (Authenticate to Lock Server) opens the Lock Server /connect page in an IFRAME MODAL with // ?delivery=postmessage. The /connect shell itself posts { type: 'locks-auth-callback', state, code } // straight to this parent window — NO full-page redirect, NO same-origin callback page on this app. @@ -17,21 +21,29 @@ import init, { Locks } from '../../locks-sdk/bindings/js/pkg/locks_sdk_wasm.js'; // Documented endpoint names for the smoke checker and readers: // POST /api/demo-auth/start // GET /api/demo-auth/status -// Verifier dropdown initial option: dev-static +// Lock type defaults to dev-static; paykit-payment is explicitly selectable. // Message type published by the Lock Server /connect shell (embedder contract). const LOCKS_AUTH_CALLBACK_TYPE = 'locks-auth-callback'; -const POINTER_CONFIGURED_KEY = 'pubky-locks-demo.pointerConfigured'; +const LOCKS_AUTH_ERRORS = new Set(['invalid-response', 'connect-failed']); +const LEGACY_POINTER_CONFIGURED_KEY = 'pubky-locks-demo.pointerConfigured'; +const POINTER_CONFIGURED_KEY_PREFIX = `${LEGACY_POINTER_CONFIGURED_KEY}.`; const state = { config: null, demoAuthenticated: false, lockAuthenticated: false, + creatorPubky: null, + paykitSetupComplete: false, feLockSessionToken: null, // Lock Server frontend session token — in-memory only (cleared on reload) - lastReceivedCode: null, // one-time code received from the iframe callback (for display) pendingConnectState: null, // opaque state persisted for the in-flight connect (in-memory) lockServerOrigin: null, // origin of the connect iframe; the only accepted postMessage sender lockAuthFrame: null, // the connect iframe element; only its window may post the callback + pendingPaykitSetupState: null, + paykitSetupOrigin: null, + paykitSetupFrame: null, + paykitSetupCreator: null, + demoAuthStatusRequestId: 0, }; const el = { @@ -48,7 +60,12 @@ const el = { resourceFilename: document.querySelector('#resource-filename'), selectedResources: document.querySelector('#selected-resources'), selectedResourceList: document.querySelector('#selected-resource-list'), - verifierType: document.querySelector('#verifier-type'), + lockType: document.querySelector('#lock-type'), + devStaticFields: document.querySelector('#dev-static-fields'), + paykitPaymentFields: document.querySelector('#paykit-payment-fields'), + paykitAmountSats: document.querySelector('#paykit-amount-sats'), + paykitSetupStatus: document.querySelector('#paykit-setup-status'), + retryPaykitSetup: document.querySelector('#retry-paykit-setup'), criterionId: document.querySelector('#criterion-id'), criterionSatisfied: document.querySelector('#criterion-satisfied'), accessTtl: document.querySelector('#access-ttl'), @@ -67,20 +84,30 @@ window.addEventListener('message', async (event) => { if (event.source !== state.lockAuthFrame?.contentWindow) return; // Size the iframe to the shell's reported content height (QR panel vs shorter mobile button), // so the modal hugs the content instead of leaving a fixed-height gap. - if (event.data?.type === 'locks-auth-resize') { - if (typeof event.data.height === 'number' && state.lockAuthFrame) { - state.lockAuthFrame.style.height = `${Math.max(0, event.data.height)}px`; + if (hasExactKeys(event.data, ['type', 'height']) && event.data.type === 'locks-auth-resize') { + if (Number.isFinite(event.data.height) && event.data.height >= 0 && event.data.height <= 4096 && state.lockAuthFrame) { + state.lockAuthFrame.style.height = `${event.data.height}px`; } return; } if (event.data?.type !== LOCKS_AUTH_CALLBACK_TYPE) return; // The shell reports a definitive failure (expired/rejected flow) instead of hanging. - if (event.data.error) { + if ( + hasExactKeys(event.data, ['type', 'state', 'error']) + && event.data.state === state.pendingConnectState + && LOCKS_AUTH_ERRORS.has(event.data.error) + ) { closeLockAuthIframe(); - await postClientLog('error', 'lock-auth-iframe-shell-error', { error: event.data.error }); - showError(el.lockAuthStatus, new Error(`Lock Server connect failed: ${event.data.error}`)); + await postClientLog('error', 'lock-auth-iframe-shell-error'); + showError(el.lockAuthStatus, new Error('Lock Server connect failed')); return; } + if ( + !hasExactKeys(event.data, ['type', 'state', 'code']) + || event.data.state !== state.pendingConnectState + || typeof event.data.code !== 'string' + || event.data.code.length === 0 + ) return; try { const { code, state: receivedState } = event.data; const { sessionSecret } = await exchangeCreatorConnectCode({ @@ -88,14 +115,17 @@ window.addEventListener('message', async (event) => { code, state: receivedState, expectedState: state.pendingConnectState, + expectedCreatorPubky: state.creatorPubky, pkarrRelays: [state.config.testnet.pkarrRelay], }); state.feLockSessionToken = sessionSecret; // in-memory only - state.lastReceivedCode = code; state.lockAuthenticated = true; refreshLockAuthStatus(); - await postClientLog('info', 'lock-auth-iframe-complete', { code }); showLockAuthComplete(); + state.pendingConnectState = null; + state.lockServerOrigin = null; + state.lockAuthFrame = null; + await postClientLog('info', 'lock-auth-iframe-complete'); } catch (error) { closeLockAuthIframe(); // otherwise the full-screen overlay hides the error message await postClientLog('error', 'lock-auth-iframe-exchange-failed', serializeError(error)); @@ -103,6 +133,39 @@ window.addEventListener('message', async (event) => { } }); +function hasExactKeys(value, expected) { + if (value === null || typeof value !== 'object' || Array.isArray(value)) return false; + const keys = Object.keys(value).sort(); + const expectedKeys = [...expected].sort(); + return keys.length === expectedKeys.length && keys.every((key, index) => key === expectedKeys[index]); +} + +window.addEventListener('message', (event) => { + const result = acceptPaykitSetupEvent({ + event, + expectedOrigin: state.paykitSetupOrigin, + expectedSource: state.paykitSetupFrame?.contentWindow, + expectedState: state.pendingPaykitSetupState, + setupCreator: state.paykitSetupCreator, + currentCreator: state.creatorPubky, + }); + if (!result) return; + + if (result.status === 'error') { + state.paykitSetupComplete = false; + closePaykitSetupIframe(); + el.retryPaykitSetup.hidden = false; + showError(el.paykitSetupStatus, new Error('Paykit setup failed')); + return; + } + + state.paykitSetupComplete = true; + closePaykitSetupIframe(); + el.retryPaykitSetup.hidden = true; + el.paykitSetupStatus.textContent = 'Paykit setup complete for this creator.'; + el.paykitSetupStatus.className = 'ok'; +}); + // Open the Lock Server /connect page inside an iframe overlay. The demo draws the modal CARD // (title, description, close) — mirroring what pubky.app provides in the real integration — and the // iframe renders only the secret-bearing QR on the Lock Server origin (parent cannot read it). @@ -162,6 +225,67 @@ function closeLockAuthIframe() { state.lockAuthFrame = null; // drop the ref so a late message from a closed frame is ignored } +function openPaykitSetupIframe(setupUrl) { + const overlay = document.createElement('div'); + overlay.id = 'paykit-setup-iframe-overlay'; + overlay.style.cssText = + 'position:fixed;inset:0;background:rgba(5,5,10,0.6);display:flex;z-index:9999;' + + 'align-items:center;justify-content:center;'; + overlay.addEventListener('click', (event) => { + if (event.target === overlay) cancelPaykitSetupIframe(); + }); + + const card = document.createElement('div'); + card.style.cssText = + 'box-sizing:border-box;position:relative;width:min(640px,92vw);display:flex;flex-direction:column;' + + 'gap:16px;padding:24px;background:#fff;border-radius:12px;box-shadow:0 20px 60px rgba(0,0,0,.35);'; + + const closeBtn = document.createElement('button'); + closeBtn.setAttribute('aria-label', 'Close Paykit setup'); + closeBtn.textContent = '✕'; + closeBtn.style.cssText = 'position:absolute;top:12px;right:12px;padding:6px 10px;'; + closeBtn.addEventListener('click', cancelPaykitSetupIframe); + + const title = document.createElement('h2'); + title.textContent = 'Set up Paykit payments'; + title.style.cssText = 'margin:0;padding-right:40px;'; + + const description = document.createElement('p'); + description.textContent = 'Complete the Paykit instructions for the current creator. From the repository root, use this explicit Compose command:'; + description.style.cssText = 'margin:0;'; + + const companionCommand = document.createElement('code'); + 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'); + frame.id = 'paykit-setup-iframe'; + frame.title = 'Paykit creator setup'; + frame.src = setupUrl; + frame.referrerPolicy = 'no-referrer'; + frame.style.cssText = 'width:100%;height:min(520px,70vh);border:0;display:block;'; + + card.append(closeBtn, title, description, companionCommand, frame); + overlay.append(card); + document.body.append(overlay); + state.paykitSetupFrame = frame; +} + +function cancelPaykitSetupIframe() { + closePaykitSetupIframe(); + el.retryPaykitSetup.hidden = false; + el.paykitSetupStatus.textContent = 'Paykit setup canceled.'; + el.paykitSetupStatus.className = 'muted'; +} + +function closePaykitSetupIframe() { + document.getElementById('paykit-setup-iframe-overlay')?.remove(); + state.pendingPaykitSetupState = null; + state.paykitSetupOrigin = null; + state.paykitSetupFrame = null; + state.paykitSetupCreator = null; +} + // After the token is delivered to the parent, hide the iframe and show a completion panel + Close. // This is only called after the token has been stored, so Close being visible means delivery is done. function showLockAuthComplete() { @@ -188,11 +312,12 @@ 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(); refreshLockAuthStatus(); + refreshLockTypeFields(); refreshPublishingState(); setInterval(refreshDemoAuthStatus, 2000); } @@ -204,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); } @@ -216,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, @@ -249,7 +374,7 @@ el.configurePointer.addEventListener('click', async () => { sessionSecret, pkarrRelays: [state.config.testnet.pkarrRelay], }); - localStorage.setItem(POINTER_CONFIGURED_KEY, 'true'); + localStorage.setItem(pointerConfiguredKey(state.creatorPubky), 'true'); el.publishingStatus.textContent = 'Lock Service Pointer configured. Upload a file to create locked content.'; el.publishingStatus.className = 'ok'; refreshPublishingState(); @@ -268,6 +393,10 @@ el.primaryContentFile.addEventListener('change', () => { el.secondaryContentFiles.addEventListener('change', renderSelectedResources); el.resourceFilename.addEventListener('input', renderSelectedResources); +el.lockType.addEventListener('change', refreshLockTypeFields); +el.retryPaykitSetup.addEventListener('click', () => { + if (el.lockType.value === 'paykit-payment' && state.creatorPubky) startPaykitSetup(); +}); el.lockedContentForm.addEventListener('submit', async (event) => { event.preventDefault(); @@ -279,16 +408,20 @@ el.lockedContentForm.addEventListener('submit', async (event) => { const secondaryFiles = Array.from(el.secondaryContentFiles.files ?? []); const resources = await buildResourcesFromFiles(primaryFile, secondaryFiles, filename); - const criteria = [{ - criterion_id: el.criterionId.value.trim(), - verifier_type: el.verifierType.value, - params: { satisfied: el.criterionSatisfied.value === 'true' }, - }]; + const { criteria, lockLogic } = buildCreatorLockPolicy({ + lockType: el.lockType.value, + criterionId: el.criterionId.value, + devStaticSatisfied: el.criterionSatisfied.value === 'true', + amountSats: el.paykitAmountSats.value, + recipientPubky: state.creatorPubky, + paykitSetupComplete: state.paykitSetupComplete, + }); const result = await publishLockedContent({ lockServer: state.config.lockServer.pubky, sessionSecret: state.feLockSessionToken, resources, criteria, + lockLogic, accessTtlSeconds: Number(el.accessTtl.value), pkarrRelays: [state.config.testnet.pkarrRelay], }); @@ -300,8 +433,34 @@ el.lockedContentForm.addEventListener('submit', async (event) => { }); async function refreshDemoAuthStatus() { + const requestId = ++state.demoAuthStatusRequestId; try { const status = await fetchJson('/api/demo-auth/status'); + if (requestId !== state.demoAuthStatusRequestId) return; + const creatorPubky = status.authenticated ? status.pubky : null; + const creatorChanged = state.creatorPubky !== creatorPubky; + if (creatorChanged) { + const previousCreatorPubky = state.creatorPubky; + const hadLockSession = Boolean(state.feLockSessionToken); + closeLockAuthIframe(); + if (previousCreatorPubky) localStorage.removeItem(pointerConfiguredKey(previousCreatorPubky)); + localStorage.removeItem(LEGACY_POINTER_CONFIGURED_KEY); + const invalidation = await invalidateIdentityScopedCreatorState({ + state, + revokeSession: (sessionSecret) => signOutCreator({ + lockServer: state.config.lockServer.pubky, + sessionSecret, + pkarrRelays: [state.config.testnet.pkarrRelay], + }), + }); + if (requestId !== state.demoAuthStatusRequestId) return; + if (hadLockSession && !invalidation.revoked) { + await postClientLog('warn', 'lock-session-revocation-failed-after-creator-change'); + } + state.paykitSetupComplete = false; + closePaykitSetupIframe(); + } + state.creatorPubky = creatorPubky; state.demoAuthenticated = status.authenticated; if (status.authenticated) { el.demoAuthStatus.textContent = `Authenticated as ${status.pubky} on ${status.homeserver}`; @@ -313,20 +472,75 @@ async function refreshDemoAuthStatus() { el.demoAuthStatus.className = 'muted'; } refreshLockAuthStatus(); + if (creatorChanged) refreshLockTypeFields(); } catch (error) { + if (requestId !== state.demoAuthStatusRequestId) return; showError(el.demoAuthStatus, error); } } +function refreshLockTypeFields() { + const paymentSelected = el.lockType.value === 'paykit-payment'; + el.devStaticFields.hidden = paymentSelected; + el.paykitPaymentFields.hidden = !paymentSelected; + el.paykitAmountSats.required = paymentSelected; + + if (!paymentSelected) { + closePaykitSetupIframe(); + return; + } + if (state.paykitSetupComplete) { + el.paykitSetupStatus.textContent = 'Paykit setup complete for this creator.'; + el.paykitSetupStatus.className = 'ok'; + return; + } + if (!state.creatorPubky) { + el.paykitSetupStatus.textContent = 'Authenticate the content creator before starting Paykit setup.'; + el.paykitSetupStatus.className = 'muted'; + return; + } + if (state.paykitSetupFrame) return; + startPaykitSetup(); +} + +function startPaykitSetup() { + if ( + el.lockType.value !== 'paykit-payment' + || !state.creatorPubky + || state.paykitSetupComplete + || state.paykitSetupFrame + ) return; + + closePaykitSetupIframe(); + el.retryPaykitSetup.hidden = true; + el.paykitSetupStatus.className = 'muted'; + try { + const pendingState = crypto.randomUUID(); + const request = buildPaykitSetupRequest({ + paykitUrl: state.config.paykit.url, + returnTo: window.location.origin, + state: pendingState, + }); + state.pendingPaykitSetupState = pendingState; + state.paykitSetupOrigin = request.origin; + state.paykitSetupCreator = state.creatorPubky; + openPaykitSetupIframe(request.url); + el.paykitSetupStatus.textContent = 'Paykit setup is in progress.'; + el.paykitSetupStatus.className = 'muted'; + } catch (error) { + closePaykitSetupIframe(); + el.retryPaykitSetup.hidden = false; + showError(el.paykitSetupStatus, error); + } +} + function refreshLockAuthStatus() { state.lockAuthenticated = Boolean(state.feLockSessionToken); el.startLockAuth.disabled = !state.demoAuthenticated; if (!state.demoAuthenticated) { el.lockAuthStatus.textContent = 'Waiting for demo auth.'; } else if (state.lockAuthenticated) { - el.lockAuthStatus.textContent = state.lastReceivedCode - ? `Authenticated to Lock Server.\ncode: ${state.lastReceivedCode}\nfeLockSessionToken: ${state.feLockSessionToken}` - : 'Authenticated to Lock Server.'; + el.lockAuthStatus.textContent = 'Authenticated to Lock Server.'; el.lockAuthStatus.className = 'ok'; } else { el.lockAuthStatus.textContent = 'Ready to authenticate to Lock Server.'; @@ -337,7 +551,9 @@ function refreshLockAuthStatus() { function refreshPublishingState() { const hasSession = Boolean(state.feLockSessionToken); - const pointerConfigured = localStorage.getItem(POINTER_CONFIGURED_KEY) === 'true'; + const pointerConfigured = state.creatorPubky + ? localStorage.getItem(pointerConfiguredKey(state.creatorPubky)) === 'true' + : false; el.configurePointer.disabled = !hasSession; el.lockedContentForm.hidden = !hasSession || !pointerConfigured; if (!hasSession) { @@ -349,6 +565,10 @@ function refreshPublishingState() { } } +function pointerConfiguredKey(creatorPubky) { + return `${POINTER_CONFIGURED_KEY_PREFIX}${creatorPubky}`; +} + async function fetchJson(url, options) { const response = await fetch(url, options); if (!response.ok) throw new Error(`${url} failed with HTTP ${response.status}`); @@ -360,21 +580,15 @@ function showError(target, error) { target.className = 'error'; } -async function postClientLog(level, event, details = {}) { +async function postClientLog(level, _event, _details) { try { await fetch('/api/client-log', { method: 'POST', headers: { 'content-type': 'application/json' }, - body: JSON.stringify({ - level, - event, - details, - location: window.location.href, - at: new Date().toISOString(), - }), + body: JSON.stringify({ level }), }); - } catch (error) { - console.warn('failed to post demo client log', error); + } catch { + console.warn('failed to post demo client log'); } } diff --git a/examples/js-sdk/app.js b/examples/js-sdk/app.js index b668133..dd84811 100644 --- a/examples/js-sdk/app.js +++ b/examples/js-sdk/app.js @@ -1,327 +1,2 @@ -import { - completeCreatorConnect, - configureLockServicePointer, - publishLockedContent, - startCreatorConnect, -} from './creator-complete-flow.js'; -import init, { Locks } from '../../locks-sdk/bindings/js/pkg/locks_sdk_wasm.js'; - -// Documented endpoint names for the smoke checker and readers: -// POST /api/demo-auth/start -// GET /api/demo-auth/status -// Verifier dropdown initial option: dev-static - -const SESSION_SECRET_KEY = 'pubky-locks-demo.frontendSessionSecret'; -const CONNECT_STATE_KEY = 'pubky-locks-demo.connectState'; -const POINTER_CONFIGURED_KEY = 'pubky-locks-demo.pointerConfigured'; - -const state = { - config: null, - demoAuthenticated: false, - lockAuthenticated: false, -}; - -const el = { - demoAuthStatus: document.querySelector('#demo-auth-status'), - startDemoAuth: document.querySelector('#start-demo-auth'), - demoAuthCommand: document.querySelector('#demo-auth-command'), - lockAuthStatus: document.querySelector('#lock-auth-status'), - startLockAuth: document.querySelector('#start-lock-auth'), - publishingStatus: document.querySelector('#publishing-status'), - configurePointer: document.querySelector('#configure-pointer'), - lockedContentForm: document.querySelector('#locked-content-form'), - primaryContentFile: document.querySelector('#primary-content-file'), - secondaryContentFiles: document.querySelector('#secondary-content-files'), - resourceFilename: document.querySelector('#resource-filename'), - selectedResources: document.querySelector('#selected-resources'), - selectedResourceList: document.querySelector('#selected-resource-list'), - verifierType: document.querySelector('#verifier-type'), - criterionId: document.querySelector('#criterion-id'), - criterionSatisfied: document.querySelector('#criterion-satisfied'), - accessTtl: document.querySelector('#access-ttl'), - creatorResult: document.querySelector('#creator-result'), - viewerResource: document.querySelector('#viewer-resource'), -}; - -await init(); -await bootstrap(); - -async function bootstrap() { - state.config = await fetchJson('/config.json'); - await postClientLog('info', 'bootstrap-config-loaded', { - lockServerPubky: state.config.lockServer.pubky, - lockServerUrl: state.config.lockServer.url, - pkarrRelay: state.config.testnet.pkarrRelay, - httpRelay: state.config.testnet.httpRelay, - callback: state.config.paths.lockServerCallback, - hasLocalLockSession: Boolean(localStorage.getItem(SESSION_SECRET_KEY)), - }); - try { - await maybeCompleteLockServerCallback(); - } catch (error) { - await postClientLog('error', 'lock-auth-callback-failed', serializeError(error)); - showError(el.lockAuthStatus, error); - } - await refreshDemoAuthStatus(); - refreshLockAuthStatus(); - refreshPublishingState(); - setInterval(refreshDemoAuthStatus, 2000); -} - -el.startDemoAuth.addEventListener('click', async () => { - try { - const result = await fetchJson('/api/demo-auth/start', { method: 'POST' }); - if (result.authenticated) { - await refreshDemoAuthStatus(); - return; - } - el.demoAuthCommand.textContent = `${result.authorizationUrl}\n\n${result.command}`; - } catch (error) { - showError(el.demoAuthStatus, error); - } -}); - -el.startLockAuth.addEventListener('click', async () => { - try { - const connectState = crypto.randomUUID(); - sessionStorage.setItem(CONNECT_STATE_KEY, connectState); - await postClientLog('info', 'lock-auth-start-clicked', { - lockServerPubky: state.config.lockServer.pubky, - returnTo: state.config.paths.lockServerCallback, - state: connectState, - pkarrRelays: [state.config.testnet.pkarrRelay], - }); - const { connectUrl } = await startCreatorConnect({ - lockServer: state.config.lockServer.pubky, - returnTo: state.config.paths.lockServerCallback, - state: connectState, - pkarrRelays: [state.config.testnet.pkarrRelay], - }); - await postClientLog('info', 'lock-auth-connect-url-built', { connectUrl }); - window.location.assign(connectUrl); - } catch (error) { - await postClientLog('error', 'lock-auth-start-failed', serializeError(error)); - showError(el.lockAuthStatus, error); - } -}); - -el.configurePointer.addEventListener('click', async () => { - try { - const sessionSecret = localStorage.getItem(SESSION_SECRET_KEY); - await configureLockServicePointer({ - lockServer: state.config.lockServer.pubky, - sessionSecret, - pkarrRelays: [state.config.testnet.pkarrRelay], - }); - localStorage.setItem(POINTER_CONFIGURED_KEY, 'true'); - el.publishingStatus.textContent = 'Lock Service Pointer configured. Upload a file to create locked content.'; - el.publishingStatus.className = 'ok'; - refreshPublishingState(); - } catch (error) { - showError(el.publishingStatus, error); - } -}); - -el.primaryContentFile.addEventListener('change', () => { - const primaryFile = el.primaryContentFile.files?.[0]; - if (primaryFile && !el.resourceFilename.value) { - el.resourceFilename.value = sanitizeFilename(primaryFile.name); - } - renderSelectedResources(); -}); - -el.secondaryContentFiles.addEventListener('change', renderSelectedResources); -el.resourceFilename.addEventListener('input', renderSelectedResources); - -el.lockedContentForm.addEventListener('submit', async (event) => { - event.preventDefault(); - try { - const primaryFile = el.primaryContentFile.files?.[0]; - if (!primaryFile) throw new Error('select a primary file first'); - const filename = el.resourceFilename.value.trim(); - if (!filename || filename.includes('/')) throw new Error('primary filename is required and must not contain /'); - - const secondaryFiles = Array.from(el.secondaryContentFiles.files ?? []); - const resources = await buildResourcesFromFiles(primaryFile, secondaryFiles, filename); - const criteria = [{ - criterion_id: el.criterionId.value.trim(), - verifier_type: el.verifierType.value, - params: { satisfied: el.criterionSatisfied.value === 'true' }, - }]; - const result = await publishLockedContent({ - lockServer: state.config.lockServer.pubky, - sessionSecret: localStorage.getItem(SESSION_SECRET_KEY), - resources, - criteria, - accessTtlSeconds: Number(el.accessTtl.value), - pkarrRelays: [state.config.testnet.pkarrRelay], - }); - el.creatorResult.textContent = JSON.stringify(result, null, 2); - el.viewerResource.textContent = result.contentLockResource; - } catch (error) { - showError(el.publishingStatus, error); - } -}); - -async function maybeCompleteLockServerCallback() { - if (!location.pathname.endsWith('/auth/lock-server/callback')) return; - const expectedState = sessionStorage.getItem(CONNECT_STATE_KEY); - await postClientLog('info', 'lock-auth-callback-received', { - callbackUrl: window.location.href, - expectedState, - }); - const { sessionSecret } = await completeCreatorConnect({ - lockServer: state.config.lockServer.pubky, - callbackUrl: window.location.href, - expectedState, - pkarrRelays: [state.config.testnet.pkarrRelay], - }); - localStorage.setItem(SESSION_SECRET_KEY, sessionSecret); - state.lockAuthenticated = true; - await postClientLog('info', 'lock-auth-callback-completed', { - storedSessionSecret: true, - }); - history.replaceState({}, '', '/examples/js-sdk/'); -} - -async function refreshDemoAuthStatus() { - try { - const status = await fetchJson('/api/demo-auth/status'); - state.demoAuthenticated = status.authenticated; - if (status.authenticated) { - el.demoAuthStatus.textContent = `Authenticated as ${status.pubky} on ${status.homeserver}`; - el.demoAuthStatus.className = 'ok'; - el.startDemoAuth.disabled = true; - el.demoAuthCommand.textContent = ''; - } else { - el.demoAuthStatus.textContent = status.pending ? 'Waiting for auth approval...' : 'Not authenticated to homeserver.'; - el.demoAuthStatus.className = 'muted'; - } - refreshLockAuthStatus(); - } catch (error) { - showError(el.demoAuthStatus, error); - } -} - -function refreshLockAuthStatus() { - const secret = localStorage.getItem(SESSION_SECRET_KEY); - state.lockAuthenticated = Boolean(secret); - el.startLockAuth.disabled = !state.demoAuthenticated; - if (!state.demoAuthenticated) { - el.lockAuthStatus.textContent = 'Waiting for demo auth.'; - } else if (state.lockAuthenticated) { - el.lockAuthStatus.textContent = 'Authenticated to Lock Server.'; - el.lockAuthStatus.className = 'ok'; - } else { - el.lockAuthStatus.textContent = 'Ready to authenticate to Lock Server.'; - el.lockAuthStatus.className = 'muted'; - } - refreshPublishingState(); -} - -function refreshPublishingState() { - const hasSession = Boolean(localStorage.getItem(SESSION_SECRET_KEY)); - const pointerConfigured = localStorage.getItem(POINTER_CONFIGURED_KEY) === 'true'; - el.configurePointer.disabled = !hasSession; - el.lockedContentForm.hidden = !hasSession || !pointerConfigured; - if (!hasSession) { - el.publishingStatus.textContent = 'Waiting for Lock Server session.'; - el.publishingStatus.className = 'muted'; - } else if (!pointerConfigured) { - el.publishingStatus.textContent = 'Configure Lock Service Pointer before uploading content.'; - el.publishingStatus.className = 'muted'; - } -} - -async function fetchJson(url, options) { - const response = await fetch(url, options); - if (!response.ok) throw new Error(`${url} failed with HTTP ${response.status}`); - return response.json(); -} - -function showError(target, error) { - target.textContent = error.message; - target.className = 'error'; -} - -async function postClientLog(level, event, details = {}) { - try { - await fetch('/api/client-log', { - method: 'POST', - headers: { 'content-type': 'application/json' }, - body: JSON.stringify({ - level, - event, - details, - location: window.location.href, - at: new Date().toISOString(), - }), - }); - } catch (error) { - console.warn('failed to post demo client log', error); - } -} - -function serializeError(error) { - return { - name: error?.name, - message: error?.message ?? String(error), - stack: error?.stack, - }; -} - -function renderSelectedResources() { - const primaryFile = el.primaryContentFile.files?.[0]; - const secondaryFiles = Array.from(el.secondaryContentFiles.files ?? []); - el.selectedResourceList.replaceChildren(); - if (!primaryFile && secondaryFiles.length === 0) { - el.selectedResources.hidden = true; - return; - } - - if (primaryFile) { - const primaryPath = el.resourceFilename.value.trim() || sanitizeFilename(primaryFile.name); - appendSelectedResource('Primary', primaryPath, primaryFile); - } - for (const secondaryFile of secondaryFiles) { - appendSelectedResource('Secondary', sanitizeFilename(secondaryFile.name), secondaryFile); - } - el.selectedResources.hidden = false; -} - -function appendSelectedResource(kind, path, file) { - const item = document.createElement('li'); - item.textContent = `${kind}: /priv/locks.app/content/${path} (${file.name}, ${file.size} bytes)`; - el.selectedResourceList.append(item); -} - -async function buildResourcesFromFiles(primaryFile, secondaryFiles, primaryPath) { - const usedPaths = new Set(); - const files = [ - { kind: 'primary', file: primaryFile, path: primaryPath }, - ...secondaryFiles.map((file) => ({ kind: 'secondary', file, path: sanitizeFilename(file.name) })), - ]; - const resources = []; - for (const [index, { kind, file, path }] of files.entries()) { - if (!path || path.includes('/')) throw new Error(`${kind} file path is invalid`); - if (usedPaths.has(path)) throw new Error(`duplicate guarded resource path: ${path}`); - usedPaths.add(path); - resources.push({ - path, - contentType: file.type || 'application/octet-stream', - bytes: new Uint8Array(await file.arrayBuffer()), - }); - } - return resources; -} - -function sanitizeFilename(name) { - return name.split(/[\\/]/).pop() || 'uploaded.bin'; -} - -// Keep explicit SDK symbols in this file for readers/smoke checks. -void Locks.forServerWithOptions; -void 'Configure Lock Service Pointer'; -void 'Create locked content'; -void 'Viewer content lock resource'; -void 'dev-static'; +// Both creator pages share the same origin/window/state-validated iframe auth flow. +import './app-iframe.js'; \ No newline at end of file diff --git a/examples/js-sdk/creator-complete-flow.js b/examples/js-sdk/creator-complete-flow.js index ce4b057..f5cd2cf 100644 --- a/examples/js-sdk/creator-complete-flow.js +++ b/examples/js-sdk/creator-complete-flow.js @@ -7,6 +7,7 @@ import init, { RegisterGuardedResourceOptions, SetLockServicePointerOptions, } from '../../locks-sdk/bindings/js/pkg/locks_sdk_wasm.js'; +import { enforceCreatorIdentityMatch } from './creator-identity.js'; export function buildLocksOptions({ pkarrRelays = [] } = {}) { const options = new LocksOptions(); @@ -41,7 +42,13 @@ export async function startCreatorConnect({ lockServer, returnTo, state, pkarrRe * The returned session secret is bearer-equivalent; store it according to the * host application's security model. */ -export async function completeCreatorConnect({ lockServer, callbackUrl, expectedState, pkarrRelays = [] }) { +export async function completeCreatorConnect({ + lockServer, + callbackUrl, + expectedState, + expectedCreatorPubky, + pkarrRelays = [], +}) { await init(); const callback = Locks.parseConnectCallback(callbackUrl); @@ -50,6 +57,7 @@ export async function completeCreatorConnect({ lockServer, callbackUrl, expected code: callback.code, state: callback.state, expectedState, + expectedCreatorPubky, pkarrRelays, }); } @@ -62,7 +70,14 @@ export async function completeCreatorConnect({ lockServer, callbackUrl, expected * flow — the CSRF binding is always enforced (fail closed). The returned session secret is * bearer-equivalent. */ -export async function exchangeCreatorConnectCode({ lockServer, code, state, expectedState, pkarrRelays = [] }) { +export async function exchangeCreatorConnectCode({ + lockServer, + code, + state, + expectedState, + expectedCreatorPubky, + pkarrRelays = [], +}) { await init(); if (state !== expectedState) { @@ -73,7 +88,7 @@ export async function exchangeCreatorConnectCode({ lockServer, code, state, expe const session = await locks.exchangeFrontendSessionCode( new ExchangeFrontendSessionCodeOptions(code, state), ); - + await enforceCreatorIdentityMatch({ session, expectedCreatorPubky }); return { session, sessionSecret: session.exportSecret(), @@ -113,6 +128,7 @@ export async function publishLockedContent({ contentType, bytes, criteria, + lockLogic, accessTtlSeconds = 3600, pkarrRelays = [], }) { @@ -134,7 +150,7 @@ export async function publishLockedContent({ let builder = new CreateContentLockRequestBuilder() .primaryResource(primaryResource) .criteria(criteria) - .lockLogic({ type: 'all', criteria: criteria.map((criterion) => criterion.criterion_id) }) + .lockLogic(lockLogic) .accessPolicy({ requested_credential_ttl_seconds: accessTtlSeconds }) .lockServer({ override: lockServer }); diff --git a/examples/js-sdk/creator-identity.js b/examples/js-sdk/creator-identity.js new file mode 100644 index 0000000..0f77750 --- /dev/null +++ b/examples/js-sdk/creator-identity.js @@ -0,0 +1,24 @@ +export async function enforceCreatorIdentityMatch({ session, expectedCreatorPubky }) { + const authenticatedCreatorPubky = session.creatorPubky(); + if (authenticatedCreatorPubky === expectedCreatorPubky) return; + + await session.signout(); + throw new Error('Lock Server creator does not match the demo creator; authenticate both flows with the same identity'); +} + +export async function invalidateIdentityScopedCreatorState({ state, revokeSession }) { + const sessionSecret = state.feLockSessionToken; + state.feLockSessionToken = null; + state.lockAuthenticated = false; + state.pendingConnectState = null; + state.lockServerOrigin = null; + state.lockAuthFrame = null; + + if (!sessionSecret) return { revoked: false }; + try { + await revokeSession(sessionSecret); + return { revoked: true }; + } catch { + return { revoked: false }; + } +} diff --git a/examples/js-sdk/creator-lock-policy.js b/examples/js-sdk/creator-lock-policy.js new file mode 100644 index 0000000..8cd1c1a --- /dev/null +++ b/examples/js-sdk/creator-lock-policy.js @@ -0,0 +1,57 @@ +const DEV_STATIC = 'dev-static'; +const PAYKIT_PAYMENT = 'paykit-payment'; + +export function buildCreatorLockPolicy({ + lockType = DEV_STATIC, + criterionId, + devStaticSatisfied = true, + amountSats, + recipientPubky, + paykitSetupComplete = false, +} = {}) { + const normalizedCriterionId = criterionId?.trim(); + if (!normalizedCriterionId) throw new Error('criterion ID is required'); + + let criterion; + if (lockType === DEV_STATIC) { + if (typeof devStaticSatisfied !== 'boolean') { + throw new Error('dev-static satisfied must be a boolean'); + } + criterion = { + criterion_id: normalizedCriterionId, + verifier_type: DEV_STATIC, + params: { satisfied: devStaticSatisfied }, + }; + } else if (lockType === PAYKIT_PAYMENT) { + if (!paykitSetupComplete) { + throw new Error('complete Paykit setup for the authenticated creator before publishing'); + } + if (typeof recipientPubky !== 'string' || !recipientPubky) { + throw new Error('paykit-payment requires the authenticated creator recipient'); + } + if ( + typeof amountSats !== 'string' + || !amountSats + || !/^\d+$/.test(amountSats) + || !/[1-9]/.test(amountSats) + ) { + throw new Error('paykit-payment amount must be a positive decimal integer string'); + } + criterion = { + criterion_id: normalizedCriterionId, + verifier_type: PAYKIT_PAYMENT, + params: { + recipient_pubky: recipientPubky, + amount: amountSats, + asset: 'BTC', + }, + }; + } else { + throw new Error(`unsupported lock type: ${lockType}`); + } + + return { + criteria: [criterion], + lockLogic: { type: 'all', criteria: [normalizedCriterionId] }, + }; +} diff --git a/examples/js-sdk/flows.html b/examples/js-sdk/flows.html index a671c32..cb3adc0 100644 --- a/examples/js-sdk/flows.html +++ b/examples/js-sdk/flows.html @@ -17,25 +17,24 @@

Pubky Locks JS SDK — creator demo

- Both demos run the same creator flow (authenticate → grant the Lock Server access → publish locked content). - They differ only in how the creator authorizes the Lock Server. Pick one: + Both creator pages use iframe auth and run the same flow + (authenticate → grant the Lock Server access → publish locked content). Pick one:

-

Redirect flow

-

Authorizing the Lock Server navigates the whole page to the Lock Server and back - (classic OAuth-style full-page redirect). The session token is stored in - localStorage.

- Open redirect flow → +

Primary creator page

+

Authorizing the Lock Server happens inside an iframe modal. The session token is kept + in memory only.

+ Open primary creator page →
-

Iframe flow

+

Alternate creator page

Authorizing the Lock Server happens inside an iframe modal — the page never leaves the app origin. The Lock Server passes the result back to the parent via postMessage, - and the session token is kept in memory only (not localStorage). + and the session token is kept in memory only. Works inside an installed PWA (standalone).

- Open iframe flow → + Open alternate creator page →

See examples/js-sdk/README.md for local testnet setup.

diff --git a/examples/js-sdk/iframe.html b/examples/js-sdk/iframe.html index 429484d..fd90d50 100644 --- a/examples/js-sdk/iframe.html +++ b/examples/js-sdk/iframe.html @@ -24,8 +24,8 @@

Pubky Locks JS SDK creator demo — iframe flow

-

Authorizing the Lock Server uses an iframe modal (no full-page redirect).  → - Switch to redirect flow  ·  +

Authorizing the Lock Server uses an iframe modal without leaving this page.  → + Open primary creator page  ·  All flows

This local testnet demo exercises the browser JS/WASM SDK against a configured Lock Server.

@@ -71,22 +71,34 @@

3. Creator publishing

Secondary guarded resource paths are derived from each secondary file name. Use distinct file names. - +
+ +
+ - +
+ +
+ +

Waiting for loaded lock.

Proof bundle / lifecycle

@@ -88,7 +102,7 @@

Secondary files

Waiting for access credential.

Content

-

+      
diff --git a/examples/js-sdk/scripts/authenticate-paykit.mjs b/examples/js-sdk/scripts/authenticate-paykit.mjs new file mode 100644 index 0000000..9d45ebf --- /dev/null +++ b/examples/js-sdk/scripts/authenticate-paykit.mjs @@ -0,0 +1,341 @@ +#!/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'; +import { resolve } from 'node:path'; + +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; +const MAX_CAPTURE_BYTES = 4 * 1024; + +function parseAccountIndex(value) { + const text = String(value).trim(); + if (!/^(0|[1-9][0-9]*)$/.test(text)) { + throw new Error('account index must be an unsigned decimal integer'); + } + const accountIndex = Number(text); + if (!Number.isSafeInteger(accountIndex) || accountIndex > 0xffff_ffff) { + throw new Error('account index is outside the JSON u32 range'); + } + return accountIndex; +} + +function normalizeInput(authUrl, accountXpub, accountIndex) { + const normalizedAuthUrl = String(authUrl).trim(); + const normalizedAccountXpub = String(accountXpub).trim(); + if (!normalizedAuthUrl || !normalizedAccountXpub) { + throw new Error('Paykit input requires three ordered lines'); + } + return { + authUrl: normalizedAuthUrl, + accountXpub: normalizedAccountXpub, + accountIndex: parseAccountIndex(accountIndex), + }; +} + +export function parsePaykitInputLines(value) { + if (typeof value !== 'string' || Buffer.byteLength(value, 'utf8') > MAX_INPUT_BYTES) { + throw new Error('Paykit input requires three ordered lines'); + } + const lines = value.split(/\r?\n/); + if (lines.at(-1) === '') lines.pop(); + if (lines.length !== 3) { + throw new Error('Paykit input requires three ordered lines'); + } + return normalizeInput(lines[0], lines[1], lines[2]); +} + +async function readAll(stream) { + const chunks = []; + let size = 0; + for await (const chunk of stream) { + size += chunk.length; + if (size > MAX_INPUT_BYTES) { + chunk.fill(0); + for (const buffered of chunks) buffered.fill(0); + throw new Error('Paykit input requires three ordered lines'); + } + chunks.push(chunk); + } + const bytes = Buffer.concat(chunks); + try { + return bytes.toString('utf8'); + } finally { + bytes.fill(0); + for (const chunk of chunks) chunk.fill(0); + } +} + +export async function collectPaykitInputs({ + isTTY = stdin.isTTY, + question, + readInput = () => readAll(stdin), +} = {}) { + if (!isTTY) return parsePaykitInputLines(await readInput()); + if (typeof question !== 'function') throw new Error('interactive input is unavailable'); + return normalizeInput( + await question('Paste Paykit auth URL: '), + await question('Paste account xpub/tpub: '), + await question('Account index: '), + ); +} + +export function buildCompanionHelperInput({ authUrl, accountXpub, accountIndex, creatorSecret }) { + if (!(creatorSecret instanceof Uint8Array) || creatorSecret.length !== 32) { + throw new Error('creator recovery file must contain a 32-byte secret'); + } + const secretView = Buffer.from( + creatorSecret.buffer, + creatorSecret.byteOffset, + creatorSecret.byteLength, + ); + return { + version: 1, + auth_url: authUrl, + creator_secret: secretView.toString('base64url'), + account_xpub: accountXpub, + account_index: accountIndex, + }; +} + +export function requirePaykitCreatorRole(role) { + if (role !== 'content-creator') { + throw new Error('Paykit companion authentication requires --role content-creator'); + } + return role; +} + +export function companionResultCategory(result) { + if (result?.status === 'approved') { + return { exitCode: 0, stream: 'stdout', message: 'Paykit companion authentication approved.' }; + } + if (result?.status === 'timeout') { + return { exitCode: 1, stream: 'stderr', message: 'Paykit companion authentication timed out.' }; + } + 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 = [], + input, + classifyClose, + timeoutMs, + killGraceMs = DEFAULT_KILL_GRACE_MS, + signal, + spawnProcess = spawn, + spawnEnvironment, +}) { + if (typeof helperPath !== 'string' || typeof classifyClose !== 'function') { + throw new Error('invalid helper process contract'); + } + if (!Number.isFinite(timeoutMs) || timeoutMs <= 0 || !Number.isFinite(killGraceMs) || killGraceMs <= 0) { + throw new Error('invalid helper deadline'); + } + if (!Array.isArray(helperArgs) || helperArgs.length > 8 || helperArgs.some((value) => typeof value !== 'string' || value.includes('\0'))) { + throw new Error('invalid helper arguments'); + } + if (signal?.aborted) return { status: 'failed' }; + + const payload = Buffer.from(`${JSON.stringify(input)}\n`, 'utf8'); + if (payload.length > MAX_INPUT_BYTES) { + payload.fill(0); + throw new Error('helper input is too large'); + } + + return new Promise((resolveResult) => { + let child; + try { + const spawnOptions = { + stdio: ['pipe', 'pipe', 'pipe'], + shell: false, + }; + if (spawnEnvironment) spawnOptions.env = spawnEnvironment; + child = spawnProcess(helperPath, helperArgs, spawnOptions); + } catch { + payload.fill(0); + resolveResult({ status: 'failed' }); + return; + } + const stdoutChunks = []; + const stderrChunks = []; + let capturedBytes = 0; + let timedOut = false; + let aborted = false; + let overflowed = false; + let settled = false; + let spawned = false; + let terminating = false; + let processErrored = false; + let killTimer; + let forceSettleTimer; + + function terminate() { + if (terminating) return; + terminating = true; + try { + child.kill('SIGTERM'); + } catch {} + if (!killTimer) { + killTimer = setTimeout(() => { + try { + child.kill('SIGKILL'); + } catch {} + forceSettleTimer = setTimeout(() => { + child.stdin.destroy(); + child.stdout.destroy(); + child.stderr.destroy(); + child.unref(); + settle(timedOut ? { status: 'timeout' } : { status: 'failed' }); + }, killGraceMs); + }, killGraceMs); + } + } + + const deadline = setTimeout(() => { + timedOut = true; + terminate(); + }, timeoutMs); + + const abort = () => { + aborted = true; + terminate(); + }; + if (signal?.aborted) abort(); + else signal?.addEventListener('abort', abort, { once: true }); + + function settle(result) { + if (settled) return; + settled = true; + clearTimeout(deadline); + clearTimeout(killTimer); + clearTimeout(forceSettleTimer); + signal?.removeEventListener('abort', abort); + payload.fill(0); + for (const chunk of [...stdoutChunks, ...stderrChunks]) chunk.fill(0); + resolveResult(result); + } + + function capture(target, chunk) { + const bytes = Buffer.from(chunk); + capturedBytes += bytes.length; + if (capturedBytes > MAX_CAPTURE_BYTES) { + bytes.fill(0); + overflowed = true; + terminate(); + return; + } + target.push(bytes); + } + + child.stdout.on('data', (chunk) => capture(stdoutChunks, chunk)); + child.stderr.on('data', (chunk) => capture(stderrChunks, chunk)); + child.stdin.on('error', () => {}); + child.once('spawn', () => { spawned = true; }); + child.on('error', () => { + if (!spawned) settle({ status: 'failed' }); + else { + processErrored = true; + terminate(); + } + }); + child.on('close', (code, signal) => { + const stdoutBytes = Buffer.concat(stdoutChunks); + const stderrBytes = Buffer.concat(stderrChunks); + let result = { status: 'failed' }; + if (!overflowed && !processErrored) { + try { + result = classifyClose({ code, signal, stdout: stdoutBytes, stderr: stderrBytes }); + } catch {} + } + stdoutBytes.fill(0); + stderrBytes.fill(0); + if (timedOut) settle({ status: 'timeout' }); + else if (aborted) settle({ status: 'failed' }); + else settle(result); + }); + child.stdin.end(payload, () => payload.fill(0)); + }); +} + +export async function runCompanionHelper({ + helperPath = resolveCompanionHelperPath(), + input, + timeoutMs = DEFAULT_TIMEOUT_MS, + killGraceMs = DEFAULT_KILL_GRACE_MS, + spawnProcess = spawn, +}) { + return runBoundedHelper({ + helperPath, + input, + timeoutMs, + killGraceMs, + spawnProcess, + classifyClose: ({ code, signal, stdout, stderr }) => ( + code === 0 + && signal === null + && stderr.length === 0 + && stdout.equals(Buffer.from('{"version":1,"status":"approved"}\n')) + ? { status: 'approved' } + : { status: 'failed' } + ), + }); +} + +async function main() { + const args = parseArgs(); + const role = requirePaykitCreatorRole(requiredRole(args)); + + let readline; + let creatorSecret; + let helperInput; + try { + if (stdin.isTTY) readline = createInterface({ input: stdin, output: stdout }); + const values = await collectPaykitInputs({ + isTTY: stdin.isTTY, + question: readline ? (prompt) => readline.question(prompt) : undefined, + }); + creatorSecret = await loadRoleSecret(role); + helperInput = buildCompanionHelperInput({ ...values, creatorSecret }); + const result = await runCompanionHelper({ input: helperInput }); + const category = companionResultCategory(result); + if (category.stream === 'stdout') console.log(category.message); + else console.error(category.message); + return category.exitCode; + } finally { + readline?.close(); + creatorSecret?.fill(0); + if (helperInput) helperInput.creator_secret = ''; + } +} + +const isMain = process.argv[1] + && resolve(process.argv[1]) === resolve(fileURLToPath(import.meta.url)); +if (isMain) { + main() + .then((code) => { process.exitCode = code; }) + .catch(() => { + console.error('Paykit companion authentication could not start.'); + process.exitCode = 2; + }); +} diff --git a/examples/js-sdk/scripts/create-user.mjs b/examples/js-sdk/scripts/create-user.mjs index 8d03849..5a39fd5 100644 --- a/examples/js-sdk/scripts/create-user.mjs +++ b/examples/js-sdk/scripts/create-user.mjs @@ -13,6 +13,8 @@ import { writeSecret, } from './lib/paths.mjs'; import { readDemoConfig } from './lib/config.mjs'; +import { clearCreatorDemoSession } from './lib/creator-session-state.mjs'; +import { clearPreparedReaderStatus } from './lib/paykit-reader-status.mjs'; import { Keypair, publicKeyString, randomPassphrase } from './lib/pubky.mjs'; // Creates ./.local//passphrase, ./.local//recovery_file, and ./.local//profile.json. @@ -40,6 +42,9 @@ try { process.exit(0); } + if (role === 'content-creator') await clearCreatorDemoSession(); + if (role === 'content-viewer') await clearPreparedReaderStatus(); + const config = await readDemoConfig().catch(() => undefined); await ensureDir(roleDir(role)); @@ -57,6 +62,7 @@ try { await writeSecret(passphraseFile, `${passphrase}\n`); await writeSecret(recoveryFile, Buffer.from(recoveryBytes)); await writeJson(profileFile, profile); + if (role === 'content-creator') await clearCreatorDemoSession(); console.log(JSON.stringify({ ok: true, reused: false, forced: force, role, pubky, profile: profileFile }, null, 2)); } catch (error) { diff --git a/examples/js-sdk/scripts/electrum-readiness.mjs b/examples/js-sdk/scripts/electrum-readiness.mjs new file mode 100644 index 0000000..6aa5d5e --- /dev/null +++ b/examples/js-sdk/scripts/electrum-readiness.mjs @@ -0,0 +1,61 @@ +#!/usr/bin/env node +import net from 'node:net'; + +const host = process.env.ELECTRUM_HOST ?? 'fulcrum'; +const port = Number.parseInt(process.env.ELECTRUM_PORT ?? '50001', 10); +const attempts = Number.parseInt(process.env.ELECTRUM_READY_ATTEMPTS ?? '180', 10); +if (!/^[A-Za-z0-9.-]+$/.test(host) || !Number.isSafeInteger(port) || port < 1 || port > 65535) { + throw new Error('invalid Electrum readiness configuration'); +} + +function probe() { + return new Promise((resolve, reject) => { + const socket = net.createConnection({ host, port }); + let buffer = ''; + const timeout = setTimeout(() => socket.destroy(new Error('timeout')), 2_000); + socket.setEncoding('utf8'); + socket.on('connect', () => { + socket.write('{"jsonrpc":"2.0","id":1,"method":"server.version","params":["pubky-locks-compose","1.4"]}\n'); + }); + socket.on('data', (chunk) => { + buffer += chunk; + if (buffer.length > 16_384) socket.destroy(new Error('oversized response')); + const newline = buffer.indexOf('\n'); + if (newline === -1) return; + try { + const response = JSON.parse(buffer.slice(0, newline)); + if ( + response?.id !== 1 + || response.error != null + || !Array.isArray(response.result) + || response.result.length !== 2 + || !response.result.every((value) => typeof value === 'string' && value.length > 0) + ) { + throw new Error('invalid response'); + } + clearTimeout(timeout); + socket.end(); + resolve(); + } catch (error) { + socket.destroy(error); + } + }); + socket.on('error', (error) => { + clearTimeout(timeout); + reject(error); + }); + }); +} + +let ready = false; +for (let attempt = 0; attempt < attempts; attempt += 1) { + try { + await probe(); + ready = true; + break; + } catch { + await new Promise((resolve) => setTimeout(resolve, 1_000)); + } +} +if (!ready) throw new Error('Electrum readiness timed out'); +console.log('Electrum protocol ready'); diff --git a/examples/js-sdk/scripts/generate-paykit-account-tpub.mjs b/examples/js-sdk/scripts/generate-paykit-account-tpub.mjs new file mode 100644 index 0000000..3369a41 --- /dev/null +++ b/examples/js-sdk/scripts/generate-paykit-account-tpub.mjs @@ -0,0 +1,102 @@ +#!/usr/bin/env node +import { spawnSync } from 'node:child_process'; +import { fileURLToPath } from 'node:url'; + +import { repoRoot } from './lib/paths.mjs'; + +const WALLET_NAME = 'paykit-creator'; +const COMPOSE_FILE = 'compose.paykit-local-demo.yaml'; +const MAX_OUTPUT_BYTES = 1024 * 1024; +const COMMAND_TIMEOUT_MS = 30_000; +const TESTNET_ACCOUNT_XPUB = /^(tpub[1-9A-HJ-NP-Za-km-z]{107})$/; +const EXTERNAL_BIP84_TESTNET_DESCRIPTOR = /^wpkh\(\[[0-9a-fA-F]{8}\/84h\/1h\/(0|[1-9][0-9]*)h\](tpub[1-9A-HJ-NP-Za-km-z]{107})\/0\/\*\)#[0-9a-z]{8}$/; + +export function extractBip84AccountXpub(value) { + if (!value || typeof value !== 'object' || Array.isArray(value) || !Array.isArray(value.descriptors)) { + throw new Error('Bitcoin Core returned an invalid descriptor response'); + } + + const matches = value.descriptors.flatMap((entry) => { + if ( + !entry + || typeof entry !== 'object' + || entry.active !== true + || entry.internal !== false + || typeof entry.desc !== 'string' + ) return []; + const match = EXTERNAL_BIP84_TESTNET_DESCRIPTOR.exec(entry.desc); + if (!match || !TESTNET_ACCOUNT_XPUB.test(match[2])) return []; + const accountIndex = Number(match[1]); + if (!Number.isSafeInteger(accountIndex) || accountIndex > 0xffff_ffff) return []; + return [{ accountXpub: match[2], accountIndex }]; + }); + + if (matches.length !== 1) { + throw new Error('Bitcoin wallet must expose exactly one active external BIP84 testnet account descriptor'); + } + return matches[0]; +} + +export function generatePaykitAccountXpub({ + run = runBitcoinDescriptorCommand, +} = {}) { + const result = run(); + if (!result || result.status !== 0 || result.signal) { + throw new Error('could not create or inspect the local Paykit Bitcoin wallet'); + } + if (Buffer.byteLength(result.stdout ?? '', 'utf8') > MAX_OUTPUT_BYTES) { + throw new Error('Bitcoin Core descriptor response exceeded the output limit'); + } + + let response; + try { + response = JSON.parse(result.stdout); + } catch { + throw new Error('Bitcoin Core returned an invalid descriptor response'); + } + return extractBip84AccountXpub(response); +} + +function runBitcoinDescriptorCommand() { + const script = ` +set -euo pipefail +wallet=${WALLET_NAME} +cli=(bitcoin-cli -conf="$BITCOIN_DATA/bitcoin.conf" -regtest) +if ! "\${cli[@]}" -rpcwallet="$wallet" getwalletinfo >/dev/null 2>&1; then + "\${cli[@]}" loadwallet "$wallet" >/dev/null 2>&1 || \ + "\${cli[@]}" -named createwallet wallet_name="$wallet" descriptors=true load_on_startup=true >/dev/null +fi +"\${cli[@]}" -rpcwallet="$wallet" listdescriptors false +`; + return spawnSync( + 'docker', + ['compose', '-f', COMPOSE_FILE, 'exec', '-T', 'bitcoin', '/bin/bash', '-euc', script], + { + cwd: repoRoot, + encoding: 'utf8', + shell: false, + timeout: COMMAND_TIMEOUT_MS, + maxBuffer: MAX_OUTPUT_BYTES, + env: { + PATH: process.env.PATH, + DOCKER_HOST: process.env.DOCKER_HOST, + DOCKER_CONTEXT: process.env.DOCKER_CONTEXT, + DOCKER_CONFIG: process.env.DOCKER_CONFIG, + }, + }, + ); +} + +async function main() { + try { + const { accountXpub, accountIndex } = generatePaykitAccountXpub(); + process.stdout.write(`Paykit account tpub: ${accountXpub}\nPaykit account index: ${accountIndex}\n`); + } catch (error) { + process.stderr.write(`${error instanceof Error ? error.message : 'could not generate Paykit account tpub'}\n`); + process.exitCode = 1; + } +} + +if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) { + await main(); +} 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 2c2655a..dc709b7 100644 --- a/examples/js-sdk/scripts/init-config.mjs +++ b/examples/js-sdk/scripts/init-config.mjs @@ -2,14 +2,15 @@ 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/js-sdk-demo/config.json. -// Local testnet defaults: http://localhost:15411, http://localhost:15412, localhost:6881. +// Defaults: read lock_server_public_key from ~/.pubky-lock/config.toml and write ./.local/demo-config/config.json. +// 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; try { - const config = await buildDefaultDemoConfig(); + const lockConfigPath = typeof args['lock-config'] === 'string' ? args['lock-config'] : undefined; + const config = await buildDefaultDemoConfig(lockConfigPath); await writeJson(output, config); console.log(JSON.stringify({ ok: true, config: output, lockServer: config.lockServer }, null, 2)); } catch (error) { diff --git a/examples/js-sdk/scripts/init-paykit-compose.mjs b/examples/js-sdk/scripts/init-paykit-compose.mjs new file mode 100644 index 0000000..615923e --- /dev/null +++ b/examples/js-sdk/scripts/init-paykit-compose.mjs @@ -0,0 +1,181 @@ +#!/usr/bin/env node +import { randomBytes } from 'node:crypto'; +import { chmod, mkdir } from 'node:fs/promises'; +import { join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { + buildPaykitServerConfig, + buildPubkyHomeserverComposeConfig, + readLockServerPublicKey, + validatePaykitComposeEnvironment, +} from './lib/config.mjs'; +import { localPath, parseArgs, readPrivateText, writeAtomicFile } from './lib/paths.mjs'; + +const SECRET_KEYS = [ + 'version', + 'locksPostgresPassword', + 'paykitPostgresPassword', + 'paykitMasterKey', + 'bitcoinRpcUser', + 'bitcoinRpcPassword', + 'locksCreatorAuthKey', + 'pubkyHomeserverAdminPassword', +]; +const TOKEN = /^[A-Za-z0-9_-]{16,128}$/; +const MASTER_KEY = /^[A-Za-z0-9_-]{43}$/; + +function randomToken(bytes = 24) { + return randomBytes(bytes).toString('base64url'); +} + +export function createComposeSecrets() { + return Object.freeze({ + version: 1, + locksPostgresPassword: randomToken(), + paykitPostgresPassword: randomToken(), + paykitMasterKey: randomToken(32), + bitcoinRpcUser: `bitcoin_${randomToken(12)}`, + bitcoinRpcPassword: randomToken(32), + locksCreatorAuthKey: randomToken(32), + pubkyHomeserverAdminPassword: randomToken(24), + }); +} + +export function validateComposeSecrets(value) { + if ( + value === null + || typeof value !== 'object' + || Array.isArray(value) + || Object.keys(value).length !== SECRET_KEYS.length + || !SECRET_KEYS.every((key) => Object.hasOwn(value, key)) + || value.version !== 1 + ) { + throw new Error('invalid persisted Compose secrets'); + } + for (const key of [ + 'locksPostgresPassword', + 'paykitPostgresPassword', + 'bitcoinRpcUser', + 'bitcoinRpcPassword', + 'pubkyHomeserverAdminPassword', + ]) { + if (typeof value[key] !== 'string' || !TOKEN.test(value[key])) { + throw new Error('invalid persisted Compose secrets'); + } + } + for (const key of ['paykitMasterKey', 'locksCreatorAuthKey']) { + if (typeof value[key] !== 'string' || !MASTER_KEY.test(value[key])) { + throw new Error('invalid persisted Compose secrets'); + } + } + return Object.freeze({ ...value }); +} + +async function writeSecure(path, content, mode = 0o600) { + await writeAtomicFile(path, content, mode); +} + +function generatedPaths(root) { + return { + secrets: join(root, 'compose-secrets.json'), + locksPostgres: join(root, 'locks-postgres', 'locks-postgres.env'), + locksServer: join(root, 'locks-server', 'compose.env'), + paykitPostgres: join(root, 'paykit-postgres', 'postgres.env'), + paykitServer: join(root, 'paykit-server', 'paykit.env'), + 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'), + }; +} + +export async function initializePaykitCompose({ + root = localPath, + lockConfigPath, + configOnly = false, +} = {}) { + const paths = generatedPaths(root); + if (configOnly) { + if (!lockConfigPath) throw new Error('--lock-config is required with --config-only'); + const lockServerPubky = await readLockServerPublicKey(lockConfigPath); + await writeSecure(paths.paykitConfig, buildPaykitServerConfig({ lockServerPubky }), 0o644); + return Object.freeze({ root: resolve(root), configGenerated: true }); + } + + await Promise.all([ + 'js-sdk-demo', + 'demo-config', + 'creator-public', + 'bitcoin-bootstrap', + 'content-creator', + 'content-viewer', + 'paykit-reader', + 'locks-postgres', + 'paykit-postgres', + 'bitcoin-rpc', + 'pubky-homeserver', + 'paykit-server', + 'paykit-config', + 'homegate-bridge', + ].map(async (directory) => { + const path = join(root, directory); + await mkdir(path, { recursive: true, mode: 0o700 }); + await chmod(path, 0o700); + })); + + let secrets; + try { + secrets = validateComposeSecrets(JSON.parse(await readPrivateText(paths.secrets))); + } catch (error) { + if (error?.code !== 'ENOENT') throw new Error('persisted Compose secrets are invalid'); + secrets = createComposeSecrets(); + await writeSecure(paths.secrets, `${JSON.stringify(secrets, null, 2)}\n`); + } + + const paykitDatabaseUrl = `postgres://paykit:${secrets.paykitPostgresPassword}@paykit-postgres:5432/paykit`; + validatePaykitComposeEnvironment({ + PAYKIT_DATABASE_URL: paykitDatabaseUrl, + PAYKIT_MASTER_KEY: secrets.paykitMasterKey, + BITCOIN_RPC_USER: secrets.bitcoinRpcUser, + BITCOIN_RPC_PASSWORD: secrets.bitcoinRpcPassword, + }); + + await Promise.all([ + writeSecure(paths.locksPostgres, `POSTGRES_DB=locks_test\nPOSTGRES_USER=locks\nPOSTGRES_PASSWORD=${secrets.locksPostgresPassword}\n`), + writeSecure(paths.locksServer, `PUBKY_LOCK_DATABASE_URL=postgres://locks:${secrets.locksPostgresPassword}@postgres:5432/locks_test\nPUBKY_LOCK_CREATOR_AUTH_ENCRYPTION_KEY=${secrets.locksCreatorAuthKey}\n`), + writeSecure(paths.paykitPostgres, `POSTGRES_DB=paykit\nPOSTGRES_USER=paykit\nPOSTGRES_PASSWORD=${secrets.paykitPostgresPassword}\n`), + writeSecure(paths.paykitServer, `PAYKIT_DATABASE_URL=${paykitDatabaseUrl}\nPAYKIT_MASTER_KEY=${secrets.paykitMasterKey}\n`), + writeSecure(paths.bitcoinRpc, `BITCOIN_RPC_USER=${secrets.bitcoinRpcUser}\nBITCOIN_RPC_PASSWORD=${secrets.bitcoinRpcPassword}\nRPCUSER=${secrets.bitcoinRpcUser}\nRPCPASSWORD=${secrets.bitcoinRpcPassword}\n`), + writeSecure(paths.pubkyHomeserver, buildPubkyHomeserverComposeConfig({ + databasePassword: secrets.locksPostgresPassword, + adminPassword: secrets.pubkyHomeserverAdminPassword, + })), + writeSecure(paths.homegateBridge, `PUBKY_HOMESERVER_ADMIN_PASSWORD=${secrets.pubkyHomeserverAdminPassword}\n`), + ]); + + if (lockConfigPath) { + const lockServerPubky = await readLockServerPublicKey(lockConfigPath); + await writeSecure(paths.paykitConfig, buildPaykitServerConfig({ lockServerPubky }), 0o644); + } + + return Object.freeze({ root: resolve(root), configGenerated: Boolean(lockConfigPath) }); +} + +export async function main(argv = process.argv.slice(2)) { + const args = parseArgs(argv); + const result = await initializePaykitCompose({ + root: typeof args['local-dir'] === 'string' ? resolve(args['local-dir']) : localPath, + lockConfigPath: typeof args['lock-config'] === 'string' ? args['lock-config'] : undefined, + configOnly: args['config-only'] === true, + }); + console.log(JSON.stringify({ ok: true, localDir: result.root, configGenerated: result.configGenerated })); +} + +const isMain = process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url); +if (isMain) { + main().catch((error) => { + console.error(`init-paykit-compose failed: ${error.message}`); + process.exitCode = 1; + }); +} diff --git a/examples/js-sdk/scripts/lib/config.mjs b/examples/js-sdk/scripts/lib/config.mjs index e5c247c..e3f1eeb 100644 --- a/examples/js-sdk/scripts/lib/config.mjs +++ b/examples/js-sdk/scripts/lib/config.mjs @@ -9,18 +9,21 @@ 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', pubky: '', configPath: defaultLockServerConfigPath, }, + paykit: { + 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', }, }; @@ -65,6 +68,7 @@ export function validateDemoConfig(config) { ['lockServer', 'url'], ['lockServer', 'pubky'], ['lockServer', 'configPath'], + ['paykit', 'url'], ['testnet', 'homeserver'], ['testnet', 'httpRelay'], ['testnet', 'pkarrRelay'], @@ -78,9 +82,19 @@ export function validateDemoConfig(config) { new URL(config.demoServer.url); new URL(config.lockServer.url); + const paykitUrl = new URL(config.paykit.url); new URL(config.testnet.httpRelay); new URL(config.testnet.pkarrRelay); + if ( + !['http:', 'https:'].includes(paykitUrl.protocol) + || paykitUrl.username + || paykitUrl.password + || config.paykit.url !== paykitUrl.origin + ) { + throw new Error('invalid demo config: paykit.url must be an exact HTTP(S) origin without credentials'); + } + if (!config.lockServer.pubky.startsWith('pubky')) { throw new Error('invalid demo config: lockServer.pubky must start with pubky'); } @@ -123,8 +137,105 @@ export function withInternalServiceUrls(config, env = process.env) { return validateDemoConfig(internal); } -export async function buildDefaultDemoConfig() { +export async function buildDefaultDemoConfig(lockServerConfigPath = defaultLockServerConfigPath) { const config = structuredClone(defaultDemoConfig); - config.lockServer.pubky = await readLockServerPublicKey(config.lockServer.configPath); + config.lockServer.configPath = lockServerConfigPath; + config.lockServer.pubky = await readLockServerPublicKey(lockServerConfigPath); return validateDemoConfig(config); } + +export function buildPaykitServerConfig({ lockServerPubky }) { + if (!/^pubky[ybndrfg8ejkmcpqxot1uwisza345h769]{52}$/.test(lockServerPubky ?? '')) { + throw new Error('invalid Lock Server Pubky'); + } + return `[http] +listen_addr = "0.0.0.0:3001" + +[locks] +trusted_public_key = "${lockServerPubky}" + +[setup] +allowed_origins = ["http://127.0.0.1:8080", "http://localhost:8080"] + +[paykit] +receiver_path = "bitkit/server" +receiver_path_priority = ["bitkit"] +network = "testnet" + +[bitcoin] +network = "regtest" + +[electrum] +endpoint = "tcp://fulcrum:50001" +poll_interval = "1s" +request_timeout = "10s" +connect_retries = 1 + +[outbox] +poll_interval = "500ms" +batch_size = 16 +lease_duration = "30s" +retry_initial = "1s" +retry_max = "5m" +`; +} + +export function validatePaykitComposeEnvironment(environment) { + const databaseUrl = environment.PAYKIT_DATABASE_URL; + const masterKey = environment.PAYKIT_MASTER_KEY; + const bitcoinRpcUser = environment.BITCOIN_RPC_USER; + const bitcoinRpcPassword = environment.BITCOIN_RPC_PASSWORD; + if (typeof databaseUrl !== 'string' || !/^postgres:\/\/[^\s]+$/.test(databaseUrl)) { + throw new Error('PAYKIT_DATABASE_URL is invalid'); + } + if (typeof masterKey !== 'string' || !/^[A-Za-z0-9_-]{43}$/.test(masterKey)) { + throw new Error('PAYKIT_MASTER_KEY is invalid'); + } + if (typeof bitcoinRpcUser !== 'string' || !/^[A-Za-z0-9_-]{8,128}$/.test(bitcoinRpcUser)) { + throw new Error('BITCOIN_RPC_USER is invalid'); + } + if (typeof bitcoinRpcPassword !== 'string' || !/^[A-Za-z0-9_-]{16,128}$/.test(bitcoinRpcPassword)) { + throw new Error('BITCOIN_RPC_PASSWORD is invalid'); + } + return { databaseUrl, masterKey, bitcoinRpcUser, bitcoinRpcPassword }; +} + +export function buildPubkyHomeserverComposeConfig({ databasePassword, adminPassword }) { + for (const [name, value] of Object.entries({ databasePassword, adminPassword })) { + if (typeof value !== 'string' || !/^[A-Za-z0-9_-]{16,128}$/.test(value)) { + throw new Error(`invalid Pubky homeserver ${name}`); + } + } + return `[general] +database_url = "postgres://locks:${databasePassword}@postgres:5432/pubky_homeserver" +signup_mode = "open" + +[drive] +pubky_listen_socket = "0.0.0.0:6287" +icann_listen_socket = "0.0.0.0:6286" + +[storage] +type = "file_system" + +[admin] +enabled = true +listen_socket = "0.0.0.0:6288" +admin_password = "${adminPassword}" + +[metrics] +enabled = false +listen_socket = "0.0.0.0:6289" + +[pkdns] +public_ip = "127.0.0.1" +public_pubky_tls_port = 6287 +public_icann_http_port = 6286 +icann_domain = "localhost" +user_keys_republisher_interval = 14400 +dht_request_timeout_ms = 2000 + +[logging] +level = "info" +module_levels = ["pubky_homeserver=debug", "tower_http=debug"] +`; +} diff --git a/examples/js-sdk/scripts/lib/creator-session-state.mjs b/examples/js-sdk/scripts/lib/creator-session-state.mjs new file mode 100644 index 0000000..d982fc5 --- /dev/null +++ b/examples/js-sdk/scripts/lib/creator-session-state.mjs @@ -0,0 +1,70 @@ +import { chmod, rm } from 'node:fs/promises'; + +import { + contentCreatorSessionPath, + readJson, + roleProfilePath, + writeJson, +} from './paths.mjs'; + +const defaultProfilePath = roleProfilePath('content-creator'); + +export async function clearCreatorDemoSession(path = contentCreatorSessionPath) { + await rm(path, { force: true }); +} + +export async function readCreatorDemoSessionForCurrentRole({ + sessionPath = contentCreatorSessionPath, + profilePath = defaultProfilePath, +} = {}) { + let session; + try { + session = await readJson(sessionPath); + } catch (error) { + if (error.code === 'ENOENT') return null; + throw error; + } + + const profile = profilePath ? await readJson(profilePath) : null; + if (!validCreatorSession(session) || (profile && !creatorIdentitiesMatch(session, profile))) { + await clearCreatorDemoSession(sessionPath); + return null; + } + + await chmod(sessionPath, 0o600); + return session; +} + +export async function writeCreatorDemoSessionForCurrentRole( + session, + { + sessionPath = contentCreatorSessionPath, + profilePath = defaultProfilePath, + } = {}, +) { + 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'); + } + + await writeJson(sessionPath, session, { mode: 0o600 }); + await chmod(sessionPath, 0o600); + + 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 validCreatorSession(session) + && profile?.role === 'content-creator' + && 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/creator-static-path.mjs b/examples/js-sdk/scripts/lib/creator-static-path.mjs new file mode 100644 index 0000000..e47fb27 --- /dev/null +++ b/examples/js-sdk/scripts/lib/creator-static-path.mjs @@ -0,0 +1,36 @@ +import { realpathSync } from 'node:fs'; +import { normalize, resolve, sep } from 'node:path'; + +export function resolveCreatorStaticPath(pathname, { repoRoot, examplesRoot }) { + let relative; + if (pathname === '/') { + relative = 'index.html'; + } else if (pathname.startsWith('/examples/js-sdk/')) { + relative = pathname.slice('/examples/js-sdk/'.length); + if (relative.length === 0) relative = 'index.html'; + } else if (pathname.startsWith('/pkg/')) { + const packagePath = pathname.slice('/pkg/'.length); + return resolveExistingPathWithin(resolve(repoRoot, 'locks-sdk/bindings/js/pkg'), packagePath); + } else if (pathname.startsWith('/locks-sdk/bindings/js/pkg/')) { + const packagePath = pathname.slice('/locks-sdk/bindings/js/pkg/'.length); + return resolveExistingPathWithin(resolve(repoRoot, 'locks-sdk/bindings/js/pkg'), packagePath); + } else { + return null; + } + return resolveExistingPathWithin(examplesRoot, relative); +} + +export function resolveExistingPathWithin(root, relative) { + const normalized = normalize(relative).replace(/^[/\\]+/, ''); + const absoluteRoot = resolve(root); + const candidate = resolve(absoluteRoot, normalized); + if (candidate !== absoluteRoot && !candidate.startsWith(`${absoluteRoot}${sep}`)) return null; + try { + const canonicalRoot = realpathSync(absoluteRoot); + const canonicalCandidate = realpathSync(candidate); + if (canonicalCandidate !== canonicalRoot && !canonicalCandidate.startsWith(`${canonicalRoot}${sep}`)) return null; + return canonicalCandidate; + } catch { + return null; + } +} diff --git a/examples/js-sdk/scripts/lib/paths.mjs b/examples/js-sdk/scripts/lib/paths.mjs index 5292072..3efdd32 100644 --- a/examples/js-sdk/scripts/lib/paths.mjs +++ b/examples/js-sdk/scripts/lib/paths.mjs @@ -1,14 +1,33 @@ +import { randomBytes } from 'node:crypto'; import { existsSync } from 'node:fs'; -import { mkdir, readFile, writeFile } from 'node:fs/promises'; -import { dirname, join, resolve } from 'node:path'; +import { chmod, lstat, mkdir, open, readFile, rename, rm } from 'node:fs/promises'; +import { basename, dirname, join, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; export const repoRoot = resolve(fileURLToPath(new URL('../../../../', import.meta.url))); export const examplesRoot = join(repoRoot, 'examples', 'js-sdk'); export const localPath = join(repoRoot, '.local'); export const demoStateDir = join(localPath, 'js-sdk-demo'); -export const demoConfigPath = join(demoStateDir, 'config.json'); +export const demoPublicConfigDir = join(localPath, 'demo-config'); +export const demoConfigPath = join(demoPublicConfigDir, 'config.json'); export const contentCreatorSessionPath = join(demoStateDir, 'content-creator-session.json'); +export const creatorPublicDir = join(localPath, 'creator-public'); +export const creatorPublicProfilePath = join(creatorPublicDir, 'profile.json'); +export const paykitReaderDir = join(localPath, 'paykit-reader'); +export const bitcoinBootstrapDir = join(localPath, 'bitcoin-bootstrap'); +export const paykitReaderPreparedPath = join(paykitReaderDir, 'prepared.v1.json'); +export const paykitReaderWorkerStatusPath = join(paykitReaderDir, 'worker.v1.json'); +export const paykitReaderOwnershipPath = join(paykitReaderDir, 'owner.lock'); +export const composeSecretsPath = join(localPath, 'compose-secrets.json'); +export const locksPostgresEnvPath = join(localPath, 'locks-postgres', 'locks-postgres.env'); +export const locksServerComposeEnvPath = join(localPath, 'locks-server', 'compose.env'); +export const paykitServerDir = join(localPath, 'paykit-server'); +export const paykitPostgresEnvPath = join(localPath, 'paykit-postgres', 'postgres.env'); +export const paykitServerEnvPath = join(paykitServerDir, 'paykit.env'); +export const paykitPublicConfigDir = join(localPath, 'paykit-config'); +export const paykitServerConfigPath = join(paykitPublicConfigDir, 'config.toml'); +export const bitcoinRpcEnvPath = join(localPath, 'bitcoin-rpc', 'bitcoin-rpc.env'); +export const pubkyHomeserverConfigPath = join(localPath, 'pubky-homeserver', 'config.toml'); export const validRoles = ['lock-server', 'content-creator', 'content-viewer']; @@ -75,13 +94,43 @@ export async function readJson(path) { } export async function writeJson(path, value, { mode = 0o644 } = {}) { - await mkdir(dirname(path), { recursive: true }); - await writeFile(path, `${JSON.stringify(value, null, 2)}\n`, { mode }); + await writeAtomicFile(path, `${JSON.stringify(value, null, 2)}\n`, mode); } export async function writeSecret(path, bytesOrText) { - await mkdir(dirname(path), { recursive: true, mode: 0o700 }); - await writeFile(path, bytesOrText, { mode: 0o600 }); + await writeAtomicFile(path, bytesOrText, 0o600); +} + +export async function writeAtomicFile(path, content, mode) { + const directory = dirname(path); + await mkdir(directory, { recursive: true, mode: 0o700 }); + const directoryStat = await lstat(directory); + if (!directoryStat.isDirectory() || directoryStat.isSymbolicLink()) { + throw new Error('generated state parent must be a directory'); + } + await chmod(directory, 0o700); + const temporary = join(directory, `.${basename(path)}.${process.pid}.${randomBytes(8).toString('hex')}.tmp`); + let handle; + try { + handle = await open(temporary, 'wx', mode); + await handle.writeFile(content); + await handle.chmod(mode); + await handle.sync(); + await handle.close(); + handle = undefined; + await rename(temporary, path); + } finally { + await handle?.close().catch(() => {}); + await rm(temporary, { force: true }).catch(() => {}); + } +} + +export async function readPrivateText(path) { + const info = await lstat(path); + if (!info.isFile() || info.isSymbolicLink() || (info.mode & 0o077) !== 0) { + throw new Error('persisted secret must be a regular owner-only file'); + } + return readFile(path, 'utf8'); } export async function readMaybeText(path) { diff --git a/examples/js-sdk/scripts/lib/paykit-reader-helper.mjs b/examples/js-sdk/scripts/lib/paykit-reader-helper.mjs new file mode 100644 index 0000000..4d56cfe --- /dev/null +++ b/examples/js-sdk/scripts/lib/paykit-reader-helper.mjs @@ -0,0 +1,388 @@ +import { spawn } from 'node:child_process'; +import { fileURLToPath } from 'node:url'; + +import { runBoundedHelper } from '../authenticate-paykit.mjs'; +import { readJson } from './paths.mjs'; +import { readDemoConfig, withInternalServiceUrls } from './config.mjs'; +import { + homeserverPublicKey, + loadRoleKeypair, + loadRoleProfile, + loadRoleSecret, + pubkyForConfig, + signupBestEffort, +} from './pubky.mjs'; + +const DEFAULT_HELPER_PATH = '/usr/local/bin/paykit-reader-demo'; +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_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', + 'PAYKIT_READER_RECEIVER_PATH', + 'PAYKIT_READER_SERVER_PUBKY', + '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', + 'invalid_state', + 'protocol_failed', + 'receive_timeout', + 'output_failed', +]); + +function exactKeys(value, expected) { + return value + && typeof value === 'object' + && !Array.isArray(value) + && Object.keys(value).length === expected.length + && expected.every((key) => Object.hasOwn(value, key)); +} + +function parseOneJsonLine(stdout) { + if (typeof stdout !== 'string' || !stdout.endsWith('\n')) { + throw new Error('invalid reader helper output'); + } + const body = stdout.slice(0, -1); + if (!body || body.includes('\n') || body.includes('\r')) { + throw new Error('invalid reader helper output'); + } + try { + return JSON.parse(body); + } catch { + throw new Error('invalid reader helper output'); + } +} + +function isCanonicalPubky(value) { + return typeof value === 'string' + && /^pubky[ybndrfg8ejkmcpqxot1uwisza345h769]{52}$/.test(value); +} + +function isReceiverPath(value) { + return typeof value === 'string' + && value.length <= 255 + && /^[A-Za-z0-9._-]+(?:\/[A-Za-z0-9._-]+)*$/.test(value); +} + +function btcToSats(value) { + if (!/^(0|[1-9][0-9]*)(?:\.[0-9]{1,8})?$/.test(value)) { + throw new Error('invalid reader helper output'); + } + const [whole, fraction = ''] = value.split('.'); + return (BigInt(whole) * 100_000_000n) + BigInt(fraction.padEnd(8, '0')); +} + +export function buildReaderHelperInput({ operation, readerSecret }) { + if (!['prepare', 'receive'].includes(operation)) { + throw new Error('invalid reader helper operation'); + } + if (!(readerSecret instanceof Uint8Array) || readerSecret.length !== 32) { + throw new Error('reader recovery file must contain a 32-byte secret'); + } + const secretView = Buffer.from( + readerSecret.buffer, + readerSecret.byteOffset, + readerSecret.byteLength, + ); + return { + version: 1, + operation, + reader_secret: secretView.toString('base64url'), + }; +} + +export function parseReaderHelperSuccess({ operation, stdout }) { + const value = parseOneJsonLine(stdout); + if (operation === 'prepare') { + const keys = ['version', 'status', 'reader_pubky', 'receiver_path']; + if ( + !exactKeys(value, keys) + || value.version !== 1 + || value.status !== 'prepared' + || !isCanonicalPubky(value.reader_pubky) + || !isReceiverPath(value.receiver_path) + ) { + throw new Error('invalid reader helper output'); + } + return value; + } + if (operation !== 'receive') throw new Error('invalid reader helper output'); + + const keys = [ + 'version', + 'status', + 'payment_request_id', + 'address', + 'asset', + 'amount_sats', + 'payment_command', + 'optional_mining_command', + ]; + if ( + !exactKeys(value, keys) + || value.version !== 1 + || 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' + || !/^[1-9][0-9]*$/.test(value.amount_sats) + || value.optional_mining_command !== MINING_COMMAND + ) { + throw new Error('invalid reader helper output'); + } + const paymentMatch = /^docker compose 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); + if ( + !paymentMatch + || paymentMatch[1] !== value.address + || btcToSats(paymentMatch[2]) !== BigInt(value.amount_sats) + ) { + throw new Error('invalid reader helper output'); + } + return { + ...value, + 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 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 + || !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'); + } + return value; +} + +function parseReaderFailure(stderr) { + const value = parseOneJsonLine(stderr); + if (!exactKeys(value, ['version', 'error']) || value.version !== 1 || !FAILURE_CODES.has(value.error)) { + throw new Error('invalid reader helper output'); + } + return value.error; +} + +export function requireReaderEnvironment(env = process.env) { + if (REQUIRED_ENV.some((name) => typeof env[name] !== 'string' || env[name].length === 0)) { + throw new Error('Paykit reader helper environment is incomplete'); + } + if (!env.PAYKIT_READER_STATE_PATH.replaceAll('\\', '/').endsWith('/.local/paykit-reader/state.v1')) { + throw new Error('Paykit reader state path must end in .local/paykit-reader/state.v1'); + } +} + +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 = 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'); + } + resolved.PAYKIT_READER_SERVER_PUBKY = profile.pubky; + } + requireReaderEnvironment(resolved); + return resolved; +} + +function readerHelperEnvironment(env) { + const restricted = Object.fromEntries(REQUIRED_ENV.map((name) => [name, env[name]])); + if (env.PATH) restricted.PATH = env.PATH; + return restricted; +} + +export async function signupReaderBestEffort({ + readConfig = readDemoConfig, + normalizeConfig = withInternalServiceUrls, + loadKeypair = loadRoleKeypair, + pubkyFactory = pubkyForConfig, + signup = signupBestEffort, + getHomeserverPublicKey = homeserverPublicKey, +} = {}) { + const config = normalizeConfig(await readConfig()); + const keypair = await loadKeypair('content-viewer'); + let signer; + let session; + try { + signer = pubkyFactory(config).signer(keypair); + session = await signup(signer, getHomeserverPublicKey(config)); + } finally { + session?.free(); + signer?.free(); + keypair.free(); + } +} + +export async function runReaderRegistration({ + signal, + timeoutMs = REGISTRATION_TIMEOUT_MS, + spawnProcess = spawn, + env = process.env, +} = {}) { + return runBoundedHelper({ + helperPath: process.execPath, + helperArgs: [REGISTRATION_SCRIPT], + input: { version: 1, operation: 'register' }, + timeoutMs, + signal, + spawnProcess, + spawnEnvironment: env.PATH ? { PATH: env.PATH } : {}, + classifyClose: ({ code, signal: closeSignal, stdout, stderr }) => { + if ( + code === 0 + && closeSignal === null + && stderr.length === 0 + && stdout.toString('utf8') === '{"version":1,"status":"registered"}\n' + ) { + return { status: 'success' }; + } + return { status: 'failed', error: 'protocol_failed' }; + }, + }); +} + +export function runRegistrationStep({ + ensureRegistered = runReaderRegistration, + signal, + timeoutMs = REGISTRATION_TIMEOUT_MS, +} = {}) { + if (!signal || signal.aborted) return Promise.resolve({ status: 'failed' }); + return (async () => { + const operationController = new AbortController(); + const operation = Promise.resolve() + .then(() => ensureRegistered({ signal: operationController.signal, timeoutMs })) + .then((result) => result ?? { status: 'success' }) + .catch(() => ({ status: 'failed', error: 'protocol_failed' })); + let timer; + let abortParent; + const interrupted = new Promise((resolveInterrupted) => { + abortParent = () => { + operationController.abort(); + resolveInterrupted({ status: 'failed' }); + }; + signal.addEventListener('abort', abortParent, { once: true }); + timer = setTimeout(() => { + operationController.abort(); + resolveInterrupted({ status: 'timeout' }); + }, timeoutMs); + }); + const outcome = await Promise.race([ + operation.then((result) => ({ kind: 'operation', result })), + interrupted.then((result) => ({ kind: 'interrupted', result })), + ]); + clearTimeout(timer); + signal.removeEventListener('abort', abortParent); + if (outcome.kind === 'operation') return outcome.result; + if (!operationController.signal.aborted) { + operationController.abort(); + } + await operation; + return outcome.result; + })(); +} + +export async function runReaderOperation({ + operation, + helperPath = process.env.PAYKIT_READER_DEMO_BIN || DEFAULT_HELPER_PATH, + env = process.env, + readerSecret, + ensureRegistered = runReaderRegistration, + spawnProcess = spawn, + signal = new AbortController().signal, + timeoutMs = operation === 'receive' ? RECEIVE_TIMEOUT_MS : PREPARE_TIMEOUT_MS, + registrationTimeoutMs = REGISTRATION_TIMEOUT_MS, +} = {}) { + if (!['prepare', 'receive'].includes(operation)) throw new Error('invalid reader helper operation'); + const resolvedEnvironment = await resolveReaderEnvironment({ env }); + if (operation === 'prepare') { + const registration = await runRegistrationStep({ + ensureRegistered, + signal, + timeoutMs: registrationTimeoutMs, + }); + if (registration.status !== 'success') return registration; + } + + let secret = readerSecret; + let input; + try { + secret ??= await loadRoleSecret('content-viewer'); + input = buildReaderHelperInput({ operation, readerSecret: secret }); + return await runBoundedHelper({ + helperPath, + input, + timeoutMs, + signal, + spawnProcess, + spawnEnvironment: readerHelperEnvironment(resolvedEnvironment), + classifyClose: ({ code, signal, stdout, stderr }) => { + if (code === 0 && signal === null && stderr.length === 0) { + return { + status: 'success', + value: parseReaderHelperSuccess({ operation, stdout: stdout.toString('utf8') }), + }; + } + if (code !== 0 && signal === null && stdout.length === 0) { + try { + return { status: 'failed', error: parseReaderFailure(stderr.toString('utf8')) }; + } catch {} + } + return { status: 'failed' }; + }, + }); + } finally { + secret?.fill(0); + if (input) input.reader_secret = ''; + } +} + +export function readerResultCategory(operation, result) { + if (result?.status === 'success') return { exitCode: 0, stream: 'stdout', value: result.value }; + if (result?.status === 'timeout') { + return { exitCode: 1, stream: 'stderr', message: `Paykit reader ${operation} timed out.` }; + } + const suffix = result?.error ? ` (${result.error})` : ''; + return { exitCode: 1, stream: 'stderr', message: `Paykit reader ${operation} failed${suffix}.` }; +} + +export function printReaderSuccess(operation, value, output = console.log) { + if (operation === 'prepare') { + output('Paykit reader prepared.'); + output(`Reader Pubky: ${value.reader_pubky}`); + output(`Receiver path: ${value.receiver_path}`); + return; + } + output('Paykit Payment Request received.'); + output(`Address: ${value.address}`); + output(`Amount: ${value.amount_sats} sats`); + output(`Payment request: ${value.payment_request_id}`); + output(`Pay: ${value.payment_command}`); + output(`Optional mining: ${value.optional_mining_command}`); +} diff --git a/examples/js-sdk/scripts/lib/paykit-reader-status.mjs b/examples/js-sdk/scripts/lib/paykit-reader-status.mjs new file mode 100644 index 0000000..33d99f0 --- /dev/null +++ b/examples/js-sdk/scripts/lib/paykit-reader-status.mjs @@ -0,0 +1,186 @@ +import { chmod, lstat, mkdir, open, readFile, rename, rm } from 'node:fs/promises'; +import { dirname } from 'node:path'; +import { randomUUID } from 'node:crypto'; + +import { validateReaderOperatorResult } from './paykit-reader-helper.mjs'; +import { paykitReaderPreparedPath, paykitReaderWorkerStatusPath } from './paths.mjs'; + +const READER_PUBKY = /^pubky[ybndrfg8ejkmcpqxot1uwisza345h769]{52}$/; +const RECEIVER_PATH = /^[A-Za-z0-9][A-Za-z0-9._/-]{0,254}$/; +const RETRYABLE_ERRORS = new Set(['receive_timeout', 'protocol_failed']); +const FAILURE_ERRORS = new Set([ + 'invalid_input', + 'invalid_config', + 'invalid_state', + 'output_failed', + 'prepare_timeout', + 'worker_failed', +]); + +function exactKeys(value, expected) { + return value !== null + && typeof value === 'object' + && !Array.isArray(value) + && Object.keys(value).length === expected.length + && expected.every((key) => Object.hasOwn(value, key)); +} + +export function validatePreparedReaderStatus(value) { + if ( + !exactKeys(value, ['version', 'status', 'reader_pubky', 'receiver_path']) + || value.version !== 1 + || value.status !== 'prepared' + || typeof value.reader_pubky !== 'string' + || !READER_PUBKY.test(value.reader_pubky) + || typeof value.receiver_path !== 'string' + || !RECEIVER_PATH.test(value.receiver_path) + ) { + throw new Error('invalid prepared Paykit reader status'); + } + return Object.freeze({ ...value }); +} + +export async function writePreparedReaderStatus(value, path = paykitReaderPreparedPath) { + const status = validatePreparedReaderStatus(value); + await writeAtomicStatus(status, path); +} + +async function writeAtomicStatus(status, path) { + const directory = dirname(path); + const temporary = `${path}.${process.pid}.${randomUUID()}.tmp`; + await mkdir(directory, { recursive: true, mode: 0o700 }); + let file; + try { + file = await open(temporary, 'wx', 0o600); + await file.writeFile(`${JSON.stringify(status)}\n`, 'utf8'); + await file.sync(); + await file.close(); + file = undefined; + await rename(temporary, path); + await chmod(path, 0o600); + } finally { + await file?.close().catch(() => {}); + await rm(temporary, { force: true }).catch(() => {}); + } +} + +export async function readPreparedReaderStatus(path = paykitReaderPreparedPath) { + try { + const metadata = await lstat(path); + if (!metadata.isFile() || metadata.isSymbolicLink() || (metadata.mode & 0o077) !== 0) return null; + return validatePreparedReaderStatus(JSON.parse(await readFile(path, 'utf8'))); + } catch { + return null; + } +} + +export async function clearPreparedReaderStatus(path = paykitReaderPreparedPath) { + await rm(path, { force: true }); +} + +export function buildPreparedReaderBrowserStatus(prepared, profile) { + if ( + !prepared + || profile?.role !== 'content-viewer' + || profile.pubky !== prepared.reader_pubky + ) { + return { version: 1, prepared: false }; + } + return { version: 1, prepared: true, reader_pubky: prepared.reader_pubky }; +} + +export function validatePaykitReaderWorkerStatus(value) { + if (!value || value.version !== 1 || typeof value.state !== 'string') { + throw new Error('invalid Paykit reader worker status'); + } + 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']) + && READER_PUBKY.test(value.reader_pubky) + ) { + return Object.freeze({ ...value }); + } + if ( + value.state === 'retrying' + && exactKeys(value, ['version', 'state', 'reader_pubky', 'error']) + && READER_PUBKY.test(value.reader_pubky) + && RETRYABLE_ERRORS.has(value.error) + ) { + return Object.freeze({ ...value }); + } + if ( + value.state === 'failed' + && exactKeys(value, ['version', 'state', 'error']) + && FAILURE_ERRORS.has(value.error) + ) { + return Object.freeze({ ...value }); + } + if ( + value.state === 'request_received' + && exactKeys(value, [ + 'version', + 'state', + 'reader_pubky', + 'payment_request_id', + 'address', + 'asset', + 'amount_sats', + 'payment_command', + 'optional_mining_command', + ]) + && READER_PUBKY.test(value.reader_pubky) + ) { + validateReaderOperatorResult({ + version: value.version, + status: 'received', + payment_request_id: value.payment_request_id, + address: value.address, + asset: value.asset, + amount_sats: value.amount_sats, + payment_command: value.payment_command, + optional_mining_command: value.optional_mining_command, + }); + return Object.freeze({ ...value }); + } + throw new Error('invalid Paykit reader worker status'); +} + +export async function writePaykitReaderWorkerStatus( + value, + path = paykitReaderWorkerStatusPath, +) { + await writeAtomicStatus(validatePaykitReaderWorkerStatus(value), path); +} + +export async function readPaykitReaderWorkerStatus(path = paykitReaderWorkerStatusPath) { + try { + const metadata = await lstat(path); + if (!metadata.isFile() || metadata.isSymbolicLink() || (metadata.mode & 0o077) !== 0) return null; + return validatePaykitReaderWorkerStatus(JSON.parse(await readFile(path, 'utf8'))); + } catch { + return null; + } +} + +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 ( + Object.hasOwn(worker, 'reader_pubky') + && (profile?.role !== 'content-viewer' || profile.pubky !== worker.reader_pubky) + ) { + return { version: 1, state: 'failed', error: 'identity_mismatch' }; + } + return { ...worker }; +} diff --git a/examples/js-sdk/scripts/lib/paykit-reader-worker.mjs b/examples/js-sdk/scripts/lib/paykit-reader-worker.mjs new file mode 100644 index 0000000..59e2002 --- /dev/null +++ b/examples/js-sdk/scripts/lib/paykit-reader-worker.mjs @@ -0,0 +1,381 @@ +import { spawn } from 'node:child_process'; +import { chmod, lstat, mkdir, open } from 'node:fs/promises'; +import { dirname } from 'node:path'; + +import { runReaderOperation } from './paykit-reader-helper.mjs'; +import { paykitReaderOwnershipPath } from './paths.mjs'; +import { writePreparedReaderStatus } from './paykit-reader-status.mjs'; + +const RETRYABLE_ERRORS = new Set(['receive_timeout', 'protocol_failed']); +const INITIAL_RETRY_MS = 1_000; +const MAX_RETRY_MS = 30_000; +const OWNERSHIP_ACQUIRE_TIMEOUT_MS = 2_000; +const OWNERSHIP_RELEASE_TIMEOUT_MS = 2_000; +const OWNERSHIP_KILL_GRACE_MS = 1_000; +const OWNERSHIP_READY = 'locked\n'; +const OWNERSHIP_HOLDER_PROGRAM = 'process.stdout.write("locked\\n");process.stdin.on("end",()=>process.exit(0));process.stdin.resume()'; +const TERMINAL_ERRORS = new Set([ + 'invalid_input', + 'invalid_config', + 'invalid_state', + 'output_failed', + 'prepare_timeout', + 'worker_failed', +]); + +export function assertStandaloneReaderOperationAllowed(env = process.env) { + if (env.PAYKIT_READER_WORKER_ENABLED === '1') { + throw new Error('Paykit reader state is owned by the embedded reader-demo worker'); + } +} + +export async function acquirePaykitReaderOwnership( + path = paykitReaderOwnershipPath, + { spawnProcess = spawn } = {}, +) { + const directory = dirname(path); + await mkdir(directory, { recursive: true, mode: 0o700 }); + + let existing; + try { + existing = await lstat(path); + } catch (error) { + if (error?.code !== 'ENOENT') throw error; + existing = null; + } + if (existing && (!existing.isFile() || existing.isSymbolicLink())) { + throw new Error('Paykit reader ownership lock must be a regular file'); + } + if (!existing) { + const created = await open(path, 'wx', 0o600); + await created.close(); + } + + await chmod(directory, 0o700); + const metadata = await lstat(path); + if (!metadata.isFile() || metadata.isSymbolicLink() || (metadata.mode & 0o077) !== 0) { + throw new Error('Paykit reader ownership lock permissions are unsafe'); + } + await chmod(path, 0o600); + + const holder = spawnProcess('/usr/bin/flock', [ + '--nonblock', + '--exclusive', + '--no-fork', + path, + process.execPath, + '-e', + OWNERSHIP_HOLDER_PROGRAM, + ], { + stdio: ['pipe', 'pipe', 'pipe'], + env: {}, + shell: false, + }); + holder.stdin.on('error', () => {}); + const { closed } = await waitForOwnershipReady(holder); + + let releasing = false; + let released = false; + let resolveLost; + const ownershipLost = new Promise((resolve) => { resolveLost = resolve; }); + void closed.then(() => { + if (!releasing) resolveLost(); + }); + return { + lost: ownershipLost, + async release() { + if (released) return; + released = true; + releasing = true; + holder.stdin.end(); + await stopOwnershipHolder(holder, closed); + }, + }; +} + +export async function supervisePaykitReaderWorker(task, { onTerminalFailure } = {}) { + let result; + try { + result = await task; + } catch { + result = { status: 'failed', error: 'worker_failed' }; + } + if (result?.status === 'failed' && typeof onTerminalFailure === 'function') { + try { + await onTerminalFailure(result.error ?? 'worker_failed'); + } catch {} + } + 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, + writePreparedStatus = writePreparedReaderStatus, + writeWorkerStatus, + wait = waitForDelay, + acquireOwnership = acquirePaykitReaderOwnership, + onOwnershipChange = () => {}, +} = {}) { + if (!signal || typeof writeWorkerStatus !== 'function') { + throw new Error('Paykit reader worker dependencies are incomplete'); + } + + if (signal.aborted) return { status: 'stopped' }; + const ownership = await acquireOwnership(); + if (signal.aborted) { + await ownership.release(); + return { status: 'stopped' }; + } + const lifecycleController = new AbortController(); + let ownershipLost = false; + let ownershipVisible = false; + const setOwnershipVisible = (visible) => { + if (ownershipVisible === visible) return; + ownershipVisible = visible; + onOwnershipChange(visible); + }; + const assertOwnershipHeld = () => { + if (ownershipLost) throw new Error('Paykit reader ownership was lost'); + }; + const abortLifecycle = () => lifecycleController.abort(); + signal.addEventListener('abort', abortLifecycle, { once: true }); + if (ownership.lost) { + void ownership.lost.then(() => { + ownershipLost = true; + lifecycleController.abort(); + setOwnershipVisible(false); + }); + } + const publishWorkerStatus = async (status) => { + assertOwnershipHeld(); + await writeWorkerStatus(status); + assertOwnershipHeld(); + }; + try { + await publishWorkerStatus({ version: 1, state: 'starting' }); + if (lifecycleController.signal.aborted) return { status: 'stopped' }; + setOwnershipVisible(true); + const result = await runOwnedWorker({ + signal: lifecycleController.signal, + runOperation, + writePreparedStatus, + writeWorkerStatus: publishWorkerStatus, + wait, + assertOwnershipHeld, + }); + assertOwnershipHeld(); + return result; + } finally { + signal.removeEventListener('abort', abortLifecycle); + setOwnershipVisible(false); + await ownership.release(); + } +} + +async function runOwnedWorker({ + signal, + runOperation, + writePreparedStatus, + writeWorkerStatus, + wait, + assertOwnershipHeld, +}) { + assertOwnershipHeld(); + if (signal.aborted) return { status: 'stopped' }; + let prepared; + try { + const result = await runOperation({ operation: 'prepare', signal }); + assertOwnershipHeld(); + if (signal.aborted) return { status: 'stopped' }; + if (result?.status !== 'success') { + const failed = failedStatus(result); + await writeWorkerStatus(failed); + return { status: 'failed', error: failed.error }; + } + prepared = result.value; + assertOwnershipHeld(); + await writePreparedStatus(prepared); + assertOwnershipHeld(); + if (signal.aborted) return { status: 'stopped' }; + await writeWorkerStatus({ + version: 1, + state: 'waiting', + reader_pubky: prepared.reader_pubky, + }); + } catch { + await writeWorkerStatus({ version: 1, state: 'failed', error: 'worker_failed' }); + return { status: 'failed', error: 'worker_failed' }; + } + + let retryMs = INITIAL_RETRY_MS; + let lastPaymentRequestId = null; + while (!signal.aborted) { + let result; + try { + result = await runOperation({ operation: 'receive', signal }); + } catch { + result = { status: 'failed', error: 'worker_failed' }; + } + assertOwnershipHeld(); + if (signal.aborted) return { status: 'stopped' }; + if (result?.status === 'success') { + if (result.value.payment_request_id === lastPaymentRequestId) { + await wait(retryMs, signal); + retryMs = Math.min(retryMs * 2, MAX_RETRY_MS); + continue; + } + await writeWorkerStatus(receivedStatus(prepared.reader_pubky, result.value)); + lastPaymentRequestId = result.value.payment_request_id; + retryMs = INITIAL_RETRY_MS; + continue; + } + + const error = result?.status === 'timeout' + ? 'receive_timeout' + : result?.error ?? 'worker_failed'; + if (!RETRYABLE_ERRORS.has(error)) { + const terminalError = TERMINAL_ERRORS.has(error) ? error : 'worker_failed'; + await writeWorkerStatus({ version: 1, state: 'failed', error: terminalError }); + return { status: 'failed', error: terminalError }; + } + if (lastPaymentRequestId === null) { + await writeWorkerStatus({ + version: 1, + state: 'retrying', + reader_pubky: prepared.reader_pubky, + error, + }); + } + await wait(retryMs, signal); + retryMs = Math.min(retryMs * 2, MAX_RETRY_MS); + } + return { status: 'stopped' }; +} + +function failedStatus(result) { + const candidate = result?.status === 'timeout' + ? 'prepare_timeout' + : result?.error ?? 'worker_failed'; + const error = TERMINAL_ERRORS.has(candidate) ? candidate : 'worker_failed'; + return { version: 1, state: 'failed', error }; +} + +function receivedStatus(readerPubky, value) { + return { + version: 1, + state: 'request_received', + reader_pubky: readerPubky, + payment_request_id: value.payment_request_id, + address: value.address, + asset: value.asset, + amount_sats: value.amount_sats, + payment_command: value.payment_command, + optional_mining_command: value.optional_mining_command, + }; +} + +async function waitForOwnershipReady(holder) { + let resolveClosed; + const closed = new Promise((resolve) => { resolveClosed = resolve; }); + holder.once('close', resolveClosed); + let stdout = ''; + let stderrBytes = 0; + let timeout; + const ready = new Promise((resolveReady, rejectReady) => { + const rejectSpawn = () => rejectReady(new Error('Paykit reader ownership holder failed')); + holder.once('error', rejectSpawn); + holder.stderr.on('data', (chunk) => { + stderrBytes += chunk.length; + if (stderrBytes > 1_024) rejectReady(new Error('Paykit reader ownership holder failed')); + }); + holder.stdout.on('data', (chunk) => { + stdout += chunk.toString('utf8'); + if (stdout === OWNERSHIP_READY) { + holder.removeListener('error', rejectSpawn); + resolveReady(); + } else if (stdout.length > OWNERSHIP_READY.length || !OWNERSHIP_READY.startsWith(stdout)) { + rejectReady(new Error('Paykit reader ownership holder failed')); + } + }); + timeout = setTimeout( + () => rejectReady(new Error('Paykit reader ownership acquisition timed out')), + OWNERSHIP_ACQUIRE_TIMEOUT_MS, + ); + }); + try { + await Promise.race([ + ready, + closed.then(() => { throw new Error('Paykit reader state is already owned by another process'); }), + ]); + clearTimeout(timeout); + return { closed }; + } catch (error) { + clearTimeout(timeout); + await stopOwnershipHolder(holder, closed); + throw error; + } +} + +async function stopOwnershipHolder(holder, closed) { + if (holder.exitCode !== null || holder.signalCode !== null) return; + holder.stdin.end(); + if (await closesWithin(closed, OWNERSHIP_RELEASE_TIMEOUT_MS)) return; + holder.kill('SIGTERM'); + if (await closesWithin(closed, OWNERSHIP_KILL_GRACE_MS)) return; + if (holder.exitCode !== null || holder.signalCode !== null) return; + holder.kill('SIGKILL'); + await closed; +} + +async function closesWithin(closed, milliseconds) { + let timer; + const result = await Promise.race([ + closed.then(() => true), + new Promise((resolve) => { timer = setTimeout(() => resolve(false), milliseconds); }), + ]); + clearTimeout(timer); + return result; +} + +function waitForDelay(milliseconds, signal) { + return new Promise((resolve) => { + if (signal.aborted) return resolve(); + const timer = setTimeout(resolve, milliseconds); + signal.addEventListener('abort', () => { + clearTimeout(timer); + resolve(); + }, { once: true }); + }); +} diff --git a/examples/js-sdk/scripts/lib/pubky.mjs b/examples/js-sdk/scripts/lib/pubky.mjs index 3ab6c57..79e7099 100644 --- a/examples/js-sdk/scripts/lib/pubky.mjs +++ b/examples/js-sdk/scripts/lib/pubky.mjs @@ -20,12 +20,44 @@ export function randomPassphrase() { return randomBytes(32).toString('base64url'); } -export async function loadRoleKeypair(role) { - const passphrase = (await readFile(rolePassphrasePath(role), 'utf8')).trim(); - const recoveryFile = await readFile(roleRecoveryFilePath(role)); +export async function loadRoleKeypair(role, { readFile: read = readFile } = {}) { + const passphrase = (await readRoleIdentityFile(rolePassphrasePath(role), role, read, 'utf8')).trim(); + const recoveryFile = await readRoleIdentityFile(roleRecoveryFilePath(role), role, read); return Keypair.fromRecoveryFile(new Uint8Array(recoveryFile), passphrase); } +export function secretFromRecoveryFile(recoveryFile, passphrase) { + const keypair = Keypair.fromRecoveryFile(new Uint8Array(recoveryFile), passphrase); + try { + const secret = keypair.secret(); + if (!(secret instanceof Uint8Array) || secret.length !== 32) { + throw new Error('recovery file did not contain a 32-byte Pubky secret'); + } + return secret; + } finally { + keypair.free(); + } +} + +export async function loadRoleSecret(role, { readFile: read = readFile } = {}) { + const passphrase = (await readRoleIdentityFile(rolePassphrasePath(role), role, read, 'utf8')).trim(); + const recoveryFile = await readRoleIdentityFile(roleRecoveryFilePath(role), role, read); + return secretFromRecoveryFile(recoveryFile, passphrase); +} + +async function readRoleIdentityFile(path, role, read, encoding) { + try { + return await read(path, encoding); + } catch (error) { + if (error?.code !== 'ENOENT') throw error; + const missing = new Error( + `missing local identity for ${role}; run \`npm --prefix examples/js-sdk run create-user -- --role ${role}\` before authentication`, + ); + missing.code = 'ROLE_IDENTITY_MISSING'; + throw missing; + } +} + export async function loadRoleProfile(role) { return readJson(new URL(`../../../../.local/${role}/profile.json`, import.meta.url).pathname); } @@ -47,7 +79,7 @@ export async function signupBestEffort(signer, homeserver) { try { return await signer.signup(homeserver, null); } catch (error) { - if (isAlreadyRegisteredError(error)) return await signer.signinBlocking(); + if (isAlreadyRegisteredError(error)) return await signer.signinCookieBlocking(); throw error; } } 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..bd3ce77 --- /dev/null +++ b/examples/js-sdk/scripts/paykit-companion-auth-compose.sh @@ -0,0 +1,18 @@ +#!/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) +PAYKIT_EXTERNAL_READER_PUBKY=${PAYKIT_EXTERNAL_READER_PUBKY:-} +export PAYKIT_EXTERNAL_READER_PUBKY + +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/prepare-paykit-reader.mjs b/examples/js-sdk/scripts/prepare-paykit-reader.mjs new file mode 100644 index 0000000..1f14485 --- /dev/null +++ b/examples/js-sdk/scripts/prepare-paykit-reader.mjs @@ -0,0 +1,53 @@ +#!/usr/bin/env node +import { resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { + printReaderSuccess, + readerResultCategory, + runReaderOperation, +} from './lib/paykit-reader-helper.mjs'; +import { clearPreparedReaderStatus, writePreparedReaderStatus } from './lib/paykit-reader-status.mjs'; +import { + acquirePaykitReaderOwnership, + assertStandaloneReaderOperationAllowed, +} from './lib/paykit-reader-worker.mjs'; + +const role = 'content-viewer'; + +export async function main({ + runOperation = runReaderOperation, + clearStatus = clearPreparedReaderStatus, + writeStatus = writePreparedReaderStatus, + printSuccess = printReaderSuccess, + printError = console.error, + acquireOwnership = acquirePaykitReaderOwnership, +} = {}) { + assertStandaloneReaderOperationAllowed(); + const ownership = await acquireOwnership(); + try { + await clearStatus(); + const result = await runOperation({ operation: 'prepare' }); + const category = readerResultCategory('prepare', result); + if (category.stream === 'stdout') { + await writeStatus(category.value); + printSuccess('prepare', category.value); + } else { + printError(category.message); + } + return category.exitCode; + } finally { + await ownership.release(); + } +} + +const isMain = process.argv[1] + && resolve(process.argv[1]) === resolve(fileURLToPath(import.meta.url)); +if (isMain) { + main() + .then((code) => { process.exitCode = code; }) + .catch(() => { + console.error(`Paykit reader prepare could not start for ${role}.`); + process.exitCode = 2; + }); +} diff --git a/examples/js-sdk/scripts/publish-creator-profile.mjs b/examples/js-sdk/scripts/publish-creator-profile.mjs new file mode 100644 index 0000000..d618161 --- /dev/null +++ b/examples/js-sdk/scripts/publish-creator-profile.mjs @@ -0,0 +1,38 @@ +#!/usr/bin/env node +import { resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { + creatorPublicProfilePath, + readJson, + roleProfilePath, + writeJson, +} from './lib/paths.mjs'; + +const CANONICAL_PUBKY = /^pubky[ybndrfg8ejkmcpqxot1uwisza345h769]{52}$/; + +export async function publishCreatorProfile({ + source = roleProfilePath('content-creator'), + destination = creatorPublicProfilePath, + profile, +} = {}) { + 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: creatorProfile.pubky }); + await writeJson(destination, publicProfile); + return publicProfile; +} + +async function main() { + try { + await publishCreatorProfile(); + process.stdout.write('Creator public profile published\n'); + } catch { + process.stderr.write('Creator public profile publication failed\n'); + process.exitCode = 1; + } +} + +if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) await main(); diff --git a/examples/js-sdk/scripts/receive-paykit-request.mjs b/examples/js-sdk/scripts/receive-paykit-request.mjs new file mode 100644 index 0000000..c9b7947 --- /dev/null +++ b/examples/js-sdk/scripts/receive-paykit-request.mjs @@ -0,0 +1,38 @@ +#!/usr/bin/env node +import { + printReaderSuccess, + readerResultCategory, + runReaderOperation, +} from './lib/paykit-reader-helper.mjs'; +import { + acquirePaykitReaderOwnership, + assertStandaloneReaderOperationAllowed, +} from './lib/paykit-reader-worker.mjs'; + +const role = 'content-viewer'; + +export async function main({ + runOperation = runReaderOperation, + printSuccess = printReaderSuccess, + printError = console.error, + acquireOwnership = acquirePaykitReaderOwnership, +} = {}) { + assertStandaloneReaderOperationAllowed(); + const ownership = await acquireOwnership(); + try { + const result = await runOperation({ operation: 'receive' }); + const category = readerResultCategory('receive', result); + if (category.stream === 'stdout') printSuccess('receive', category.value); + else printError(category.message); + return category.exitCode; + } finally { + await ownership.release(); + } +} + +main() + .then((code) => { process.exitCode = code; }) + .catch(() => { + console.error(`Paykit reader receive could not start for ${role}.`); + process.exitCode = 2; + }); diff --git a/examples/js-sdk/scripts/register-paykit-reader.mjs b/examples/js-sdk/scripts/register-paykit-reader.mjs new file mode 100644 index 0000000..314d71c --- /dev/null +++ b/examples/js-sdk/scripts/register-paykit-reader.mjs @@ -0,0 +1,44 @@ +#!/usr/bin/env node +import { resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { signupReaderBestEffort } from './lib/paykit-reader-helper.mjs'; + +const MAX_INPUT_BYTES = 1_024; + +export async function main({ input = process.stdin, signup = signupReaderBestEffort } = {}) { + const request = await readRequest(input); + if ( + request?.version !== 1 + || request.operation !== 'register' + || Object.keys(request).length !== 2 + ) { + throw new Error('invalid registration input'); + } + await signup(); + process.stdout.write('{"version":1,"status":"registered"}\n'); +} + +async function readRequest(input) { + const chunks = []; + let length = 0; + for await (const chunk of input) { + length += chunk.length; + if (length > MAX_INPUT_BYTES) throw new Error('registration input is too large'); + chunks.push(chunk); + } + const body = Buffer.concat(chunks).toString('utf8'); + if (!body.endsWith('\n') || body.slice(0, -1).includes('\n') || body.includes('\r')) { + throw new Error('invalid registration input'); + } + return JSON.parse(body.slice(0, -1)); +} + +const isMain = process.argv[1] + && resolve(process.argv[1]) === resolve(fileURLToPath(import.meta.url)); +if (isMain) { + main().catch(() => { + process.stderr.write('{"version":1,"error":"registration_failed"}\n'); + process.exitCode = 1; + }); +} diff --git a/examples/js-sdk/scripts/reset-paykit-demo.mjs b/examples/js-sdk/scripts/reset-paykit-demo.mjs new file mode 100644 index 0000000..90ed8c4 --- /dev/null +++ b/examples/js-sdk/scripts/reset-paykit-demo.mjs @@ -0,0 +1,49 @@ +#!/usr/bin/env node +import { rm } from 'node:fs/promises'; +import { spawnSync } from 'node:child_process'; + +import { bitcoinBootstrapDir, paykitReaderDir, repoRoot } from './lib/paths.mjs'; + +const COMPOSE_FILE = 'compose.paykit-local-demo.yaml'; +const dockerEnvironment = Object.fromEntries(Object.entries({ + HOME: process.env.HOME, + PATH: process.env.PATH, + DOCKER_HOST: process.env.DOCKER_HOST, + DOCKER_CONTEXT: process.env.DOCKER_CONTEXT, +}).filter(([, value]) => typeof value === 'string')); +const disposableVolumes = [ + 'pubky-locks-paykit-demo-locks-postgres', + 'pubky-locks-paykit-demo-paykit-postgres', + 'pubky-locks-paykit-demo-bitcoin', + 'pubky-locks-paykit-demo-fulcrum', +]; + +const result = spawnSync('docker', ['compose', '-f', COMPOSE_FILE, 'down', '--remove-orphans'], { + cwd: repoRoot, + shell: false, + stdio: 'inherit', + timeout: 120_000, + env: dockerEnvironment, +}); +if (result.error || result.status !== 0) { + console.error('reset-paykit-demo failed: docker compose down failed'); + process.exitCode = 1; +} else { + const removeVolumes = spawnSync('docker', ['volume', 'rm', '--force', ...disposableVolumes], { + cwd: repoRoot, + shell: false, + stdio: 'ignore', + timeout: 30_000, + env: dockerEnvironment, + }); + if (removeVolumes.error || removeVolumes.status !== 0) { + console.error('reset-paykit-demo failed: disposable volume removal failed'); + process.exitCode = 1; + } else { + await Promise.all([ + rm(bitcoinBootstrapDir, { recursive: true, force: true }), + rm(paykitReaderDir, { recursive: true, force: true }), + ]); + console.log('Paykit demo runtime reset; generated config and role identities preserved'); + } +} diff --git a/examples/js-sdk/scripts/smoke-paykit-compose.mjs b/examples/js-sdk/scripts/smoke-paykit-compose.mjs new file mode 100644 index 0000000..82d82c8 --- /dev/null +++ b/examples/js-sdk/scripts/smoke-paykit-compose.mjs @@ -0,0 +1,408 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import { chmod, lstat, mkdir, mkdtemp, readFile, rm, stat, symlink, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { + buildPaykitServerConfig, + validatePaykitComposeEnvironment, +} from './lib/config.mjs'; +import { repoRoot, writeSecret } from './lib/paths.mjs'; +import { initializePaykitCompose } from './init-paykit-compose.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'; + +const lockServerPubky = 'pubky7ir1ttte48bcp4zjychjyscicrwi1j34mtt91ptsafdbjmr8g9eo'; +const creatorPubky = 'pubkytkrq8zmwb8a3m9k15csu3q17qmfgqnp9dskbrg9uq1rydpyxp7qy'; +const composeSource = await readFile(join(repoRoot, 'compose.paykit-local-demo.yaml'), 'utf8'); +const defaultComposeSource = await readFile(join(repoRoot, 'docker-compose.yml'), 'utf8'); +const creatorAppSource = await readFile(join(repoRoot, 'examples/js-sdk/app-iframe.js'), 'utf8'); +const creatorServerSource = await readFile(join(repoRoot, 'examples/js-sdk/scripts/start-demo-server.mjs'), 'utf8'); +const lockAuthoritySource = await readFile(join(repoRoot, 'locks-server/src/api/creator_authority.rs'), 'utf8'); +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); +assert.match(composeSource, /PAYKIT_READER_RECEIVER_PATH: bitkit\/wallet/); +assert.doesNotMatch(composeSource, /PAYKIT_READER_RECEIVER_PATH: paykit\/reader/); +assert.equal( + resolveCreatorStaticPath('/.local/compose-secrets.json', { repoRoot, examplesRoot: join(repoRoot, 'examples/js-sdk') }), + null, +); +assert.equal( + resolveCreatorStaticPath('/Cargo.toml', { repoRoot, examplesRoot: join(repoRoot, 'examples/js-sdk') }), + null, +); +assert.equal( + resolveCreatorStaticPath('/examples/js-sdk/index.html', { repoRoot, examplesRoot: join(repoRoot, 'examples/js-sdk') }), + join(repoRoot, 'examples/js-sdk/index.html'), +); +assert.equal( + resolveCreatorStaticPath('/examples/js-sdk/', { repoRoot, examplesRoot: join(repoRoot, 'examples/js-sdk') }), + join(repoRoot, 'examples/js-sdk/index.html'), +); +const readerBaseEnvironment = { + PAYKIT_READER_STATE_PATH: '/workspace/.local/paykit-reader/state.v1', + PAYKIT_READER_PUBKY_TESTNET_HOST: 'pubky-testnet', + PAYKIT_READER_RECEIVER_PATH: 'bitkit/wallet', + PAYKIT_READER_SERVER_PATH: 'bitkit/server', +}; +const testAccountXpub = `tpub${'A'.repeat(107)}`; +assert.deepEqual( + extractBip84AccountXpub({ + walletName: 'paykit-creator', + descriptors: [ + { active: true, internal: true, desc: `wpkh([01020304/84h/1h/0h]${testAccountXpub}/1/*)#internal` }, + { active: true, internal: false, desc: `wpkh([01020304/84h/1h/0h]${testAccountXpub}/0/*)#external` }, + ], + }), + { accountXpub: testAccountXpub, accountIndex: 0 }, +); +assert.throws( + () => extractBip84AccountXpub({ + walletName: 'paykit-creator', + descriptors: [ + { active: true, internal: false, desc: `wpkh([01020304/84h/0h/0h]xpub${'A'.repeat(107)}/0/*)#mainnet` }, + ], + }), + /external BIP84 testnet account descriptor/, +); +assert.equal( + (await resolveReaderEnvironment({ + env: readerBaseEnvironment, + loadProfile: async (role) => ({ role, pubky: creatorPubky }), + })).PAYKIT_READER_SERVER_PUBKY, + creatorPubky, +); +await assert.rejects( + resolveReaderEnvironment({ + env: readerBaseEnvironment, + loadProfile: async () => ({ role: 'content-viewer', pubky: creatorPubky }), + }), + /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://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', + PAYKIT_MASTER_KEY: 'AQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQE', + BITCOIN_RPC_USER: 'bitcoin-user', + BITCOIN_RPC_PASSWORD: 'bitcoin-password', +}), { + databaseUrl: 'postgres://paykit:secret@paykit-postgres:5432/paykit', + masterKey: 'AQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQE', + bitcoinRpcUser: 'bitcoin-user', + bitcoinRpcPassword: 'bitcoin-password', +}); +for (const [name, value] of [ + ['PAYKIT_DATABASE_URL', ''], + ['PAYKIT_MASTER_KEY', 'short'], + ['BITCOIN_RPC_USER', 'bad user'], + ['BITCOIN_RPC_PASSWORD', ''], +]) { + const environment = { + PAYKIT_DATABASE_URL: 'postgres://paykit:secret@paykit-postgres:5432/paykit', + PAYKIT_MASTER_KEY: 'AQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQE', + BITCOIN_RPC_USER: 'bitcoin-user', + BITCOIN_RPC_PASSWORD: 'bitcoin-password', + [name]: value, + }; + assert.throws(() => validatePaykitComposeEnvironment(environment), new RegExp(name)); +} + +const generatedRoot = await mkdtemp(join(tmpdir(), 'locks-paykit-compose-')); +try { + const staticRoot = join(generatedRoot, 'static-root'); + const outsideStaticRoot = join(generatedRoot, 'outside-static-root'); + await mkdir(staticRoot); + await mkdir(outsideStaticRoot); + await writeFile(join(outsideStaticRoot, 'secret.txt'), 'not public'); + await symlink(join(outsideStaticRoot, 'secret.txt'), join(staticRoot, 'escape.txt')); + assert.equal( + resolveCreatorStaticPath('/examples/js-sdk/escape.txt', { repoRoot, examplesRoot: staticRoot }), + null, + 'creator static paths must not follow symlinks outside their allowlisted root', + ); + const outsideSecretPath = join(generatedRoot, 'outside-secret.txt'); + const secretLinkPath = join(generatedRoot, 'secret-link.txt'); + await writeFile(outsideSecretPath, 'outside remains unchanged', { mode: 0o600 }); + await symlink(outsideSecretPath, secretLinkPath); + await writeSecret(secretLinkPath, 'replacement'); + assert.equal(await readFile(outsideSecretPath, 'utf8'), 'outside remains unchanged'); + assert.equal((await lstat(secretLinkPath)).isSymbolicLink(), false, 'secret writes must replace symlinks'); + assert.equal((await stat(secretLinkPath)).mode & 0o777, 0o600); + const lockConfigPath = join(generatedRoot, 'lock-server.toml'); + await writeFile(lockConfigPath, `lock_server_public_key = "${lockServerPubky}"\n`, { mode: 0o600 }); + const first = await initializePaykitCompose({ root: generatedRoot }); + assert.equal(first.configGenerated, false); + const generatedFiles = [ + 'compose-secrets.json', + 'locks-postgres/locks-postgres.env', + 'locks-server/compose.env', + 'paykit-postgres/postgres.env', + '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`); + } + for (const directory of [ + 'js-sdk-demo', + 'demo-config', + 'creator-public', + 'bitcoin-bootstrap', + 'content-creator', + 'content-viewer', + 'paykit-reader', + 'locks-postgres', + 'paykit-postgres', + 'bitcoin-rpc', + 'pubky-homeserver', + 'paykit-server', + 'paykit-config', + 'homegate-bridge', + ]) { + assert.equal((await stat(join(generatedRoot, directory))).mode & 0o777, 0o700, `${directory} must be mode 0700`); + } + const paykitEnvironment = await readFile(join(generatedRoot, 'paykit-server/paykit.env'), 'utf8'); + assert.match(paykitEnvironment, /^PAYKIT_DATABASE_URL=postgres:\/\/paykit:[A-Za-z0-9_-]+@paykit-postgres:5432\/paykit$/m); + assert.match(paykitEnvironment, /^PAYKIT_MASTER_KEY=[A-Za-z0-9_-]{43}$/m); + const firstSecrets = await readFile(join(generatedRoot, 'compose-secrets.json'), 'utf8'); + await initializePaykitCompose({ root: generatedRoot }); + assert.equal(await readFile(join(generatedRoot, 'compose-secrets.json'), 'utf8'), firstSecrets); + await chmod(join(generatedRoot, 'compose-secrets.json'), 0o644); + await assert.rejects( + initializePaykitCompose({ root: generatedRoot }), + /persisted Compose secrets are invalid/, + ); + await chmod(join(generatedRoot, 'compose-secrets.json'), 0o600); + + const configured = await initializePaykitCompose({ + root: generatedRoot, + lockConfigPath, + configOnly: true, + }); + assert.equal(configured.configGenerated, true); + assert.equal( + await readFile(join(generatedRoot, 'paykit-config/config.toml'), 'utf8'), + buildPaykitServerConfig({ lockServerPubky }), + ); + assert.equal((await stat(join(generatedRoot, 'paykit-config/config.toml'))).mode & 0o777, 0o644); + + await writeFile( + join(generatedRoot, 'compose-secrets.json'), + `${JSON.stringify({ ...JSON.parse(firstSecrets), unexpected: true })}\n`, + ); + await assert.rejects( + initializePaykitCompose({ root: generatedRoot }), + /persisted Compose secrets are invalid/, + ); +} finally { + await rm(generatedRoot, { recursive: true, force: true }); +} + +const configOnlyRoot = await mkdtemp(join(tmpdir(), 'locks-paykit-public-config-')); +try { + const lockConfigPath = join(configOnlyRoot, 'lock-server.toml'); + await writeFile(lockConfigPath, `lock_server_public_key = "${lockServerPubky}"\n`, { mode: 0o644 }); + await initializePaykitCompose({ root: configOnlyRoot, lockConfigPath, configOnly: true }); + assert.equal( + await readFile(join(configOnlyRoot, 'paykit-config/config.toml'), 'utf8'), + buildPaykitServerConfig({ lockServerPubky }), + ); + await assert.rejects(readFile(join(configOnlyRoot, 'compose-secrets.json'), 'utf8'), { code: 'ENOENT' }); + + const privateProfilePath = join(configOnlyRoot, 'private-profile.json'); + const publicProfilePath = join(configOnlyRoot, 'creator-public/profile.json'); + await writeFile(privateProfilePath, `${JSON.stringify({ + role: 'content-creator', + pubky: creatorPubky, + homeserver: lockServerPubky, + created_at: '2026-01-01T00:00:00.000Z', + })}\n`, { mode: 0o600 }); + await publishCreatorProfile({ source: privateProfilePath, destination: publicProfilePath }); + assert.deepEqual(JSON.parse(await readFile(publicProfilePath, 'utf8')), { + 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, + PAYKIT_READER_CREATOR_PROFILE_PATH: publicProfilePath, + }, + 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 }); +} + +const compose = await readFile(join(repoRoot, 'compose.paykit-local-demo.yaml'), 'utf8'); +const jsDemoDockerfile = await readFile(join(repoRoot, 'docker/js-demo.Dockerfile'), 'utf8'); +const locksEntrypoint = await readFile(join(repoRoot, 'docker/locks-server-compose-entrypoint.sh'), 'utf8'); +const resetScript = await readFile(join(repoRoot, 'examples/js-sdk/scripts/reset-paykit-demo.mjs'), 'utf8'); +const validateScript = await readFile(join(repoRoot, 'examples/js-sdk/scripts/validate-paykit-compose.mjs'), 'utf8'); +const accountScript = await readFile(join(repoRoot, 'examples/js-sdk/scripts/generate-paykit-account-tpub.mjs'), 'utf8'); +const packageJson = JSON.parse(await readFile(join(repoRoot, 'examples/js-sdk/package.json'), 'utf8')); +const bootstrapMode = (await stat(join(repoRoot, 'docker/bitcoin-bootstrap.sh'))).mode; +assert.notEqual(bootstrapMode & 0o111, 0, 'Bitcoin bootstrap script must be executable'); +assert.match(locksEntrypoint, /level = "info,pubky::actors::session=warn"/); +assert.match(compose, /RUST_LOG: \$\{LOCKS_RUST_LOG:-info,pubky::actors::session=warn\}/); +for (const required of ['--no-install-recommends ca-certificates util-linux', 'rm -rf /var/lib/apt/lists/*']) { + assert.ok(jsDemoDockerfile.includes(required), `JS demo image missing ${required}`); +} +for (const service of [ + 'compose-bootstrap:', + 'paykit-postgres:', + 'bitcoin:', + 'bitcoin-bootstrap:', + 'fulcrum:', + 'electrum-readiness:', + 'homegate-bridge:', + 'paykit-config:', + 'demo-config:', + 'paykit-server:', +]) { + assert.ok(compose.includes(` ${service}`), `missing Compose service ${service}`); +} +for (const required of [ + 'postgres:17-bookworm@sha256:4f736ae292687621d4dbe0d499ffd024a36bd2ee7d8ca6f2ccd4c800f047b394', + 'bitcoin/bitcoin:29.1@sha256:de62c536feb629bed65395f63afd02e3a7a777a3ec82fbed773d50336a739319', + 'cculianu/fulcrum:v1.11.1@sha256:70f06b93ab5863997992d4b4508312fe81ce576017e16ecc7e69c7d38165bdf2', + 'node:22-bookworm-slim@sha256:813a7480f28fdadac1f7f5c824bcdad435b5bc1322a5968bbbdef8d058f9dff4', + 'additional_contexts:', + 'PUBKY_CORE_REV: 75eb1324f86e8caa16c41f18a2cd6b8e1909ee7b', + '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}:8088', + '127.0.0.1:${LOCKS_ELECTRUM_PORT:-60001}:50001', + '127.0.0.1:${LOCKS_HOMEGATE_PORT:-6288}:8082', + 'bitcoin-cli -conf=\\"$${BITCOIN_DATA}/bitcoin.conf\\" -regtest getblockchaininfo', + 'user: "1000:1000"', + '.local/bitcoin-bootstrap:/home/bitcoin/.bitcoin', + 'PAYKIT_CONFIG:', + '/run/compose-local/paykit-server/paykit.env', + '/run/compose-local/bitcoin-rpc.env', + './.local/locks-postgres:/run/compose-local:ro', + './.local/paykit-postgres:/run/compose-local/paykit-server:ro', + './.local/bitcoin-rpc:/run/compose-local:ro', + './.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:', + 'PAYKIT_COMPANION_AUTH_BIN:', + 'condition: service_healthy', + 'condition: service_completed_successfully', + 'locks-postgres-data:/var/lib/postgresql/data', + 'paykit-postgres-data:/var/lib/postgresql/data', + 'bitcoin-data:/home/bitcoin/.bitcoin', + 'fulcrum-data:/data', + '.local/paykit-config:/etc/paykit-server:ro', + 'check /health/live && check /health/ready', +]) { + assert.ok(compose.includes(required), `Compose missing ${required}`); +} +assert.ok(!compose.includes('env_file:'), 'Compose must bootstrap before loading generated environments'); +assert.ok(!compose.includes('chown -R 1000:1000 .local\n'), 'bootstrap must not rewrite ownership of the complete local state tree'); +assert.equal( + compose.split('./.local:/').length - 1, + 1, + 'only compose-bootstrap may mount the complete generated local state tree', +); +for (const siblingContext of ['../pubky-core', '../../Paykit/', '../paykit-rs', '../../Pubky/locks']) { + assert.ok(!compose.includes(siblingContext), `Compose must not require sibling context ${siblingContext}`); +} +for (const privateVolume of ['name: locks_lock-home', 'name: pubky-locks-demo-public']) { + assert.ok(!compose.includes(privateVolume), `Compose must not reuse private-era volume ${privateVolume}`); +} +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'); +assert.ok(readerService.includes('.local/creator-public'), 'reader requires only the closed creator public profile'); +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:8088/api/paykit-reader/status'), 'reader health must use the closed worker status endpoint'); +assert.ok(!readerService.includes('8081'), 'reader container must use the canonical port 8088 without remapping'); +assert.ok(readerService.includes('restart: unless-stopped'), 'reader worker must have an explicit restart policy'); +assert.ok(!compose.includes('POSTGRES_PASSWORD: locks'), 'database credentials must not be committed inline'); +assert.ok(!compose.includes('./locks-sdk/bindings/js/pkg:/workspace/locks-sdk/bindings/js/pkg'), 'demo images must provide their own WASM package'); +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}`); +} +assert.equal(packageJson.scripts['init-paykit-compose'], 'node scripts/init-paykit-compose.mjs'); +assert.equal(packageJson.scripts['reset-paykit-demo'], 'node scripts/reset-paykit-demo.mjs'); +for (const required of ['bitcoinBootstrapDir', 'rm(bitcoinBootstrapDir']) { + assert.ok(resetScript.includes(required), `reset script missing ${required}`); +} +for (const volume of [ + 'pubky-locks-paykit-demo-locks-postgres', + 'pubky-locks-paykit-demo-paykit-postgres', + 'pubky-locks-paykit-demo-bitcoin', + 'pubky-locks-paykit-demo-fulcrum', +]) { + assert.ok(compose.includes(`name: ${volume}`), `Compose missing isolated volume ${volume}`); + assert.ok(resetScript.includes(`'${volume}'`), `reset script missing isolated volume ${volume}`); +} +for (const [script, description] of [ + [resetScript, 'reset'], + [validateScript, 'validation'], + [accountScript, 'account generation'], +]) { + assert.ok(script.includes("const COMPOSE_FILE = 'compose.paykit-local-demo.yaml';"), `${description} script must select the local demo Compose file`); + assert.ok(script.includes("['compose', '-f', COMPOSE_FILE"), `${description} script must pass the local demo Compose file explicitly`); +} +assert.equal(packageJson.scripts['validate:paykit-compose'], 'node scripts/validate-paykit-compose.mjs'); +assert.equal( + packageJson.scripts['smoke:paykit-compose'], + 'npm run validate:paykit-compose && npm run test:paykit-reader-worker && node scripts/smoke-paykit-compose.mjs', +); + +console.log('Paykit Compose smoke check passed'); diff --git a/examples/js-sdk/scripts/start-demo-server.mjs b/examples/js-sdk/scripts/start-demo-server.mjs index 592bc78..ba01d4e 100644 --- a/examples/js-sdk/scripts/start-demo-server.mjs +++ b/examples/js-sdk/scripts/start-demo-server.mjs @@ -1,15 +1,21 @@ #!/usr/bin/env node import { createReadStream, existsSync, statSync } from 'node:fs'; -import { readFile, writeFile } from 'node:fs/promises'; import { createServer } from 'node:http'; -import { extname, join, normalize, resolve, sep } from 'node:path'; +import { extname, join } from 'node:path'; import { AuthFlowKind, pubkyForConfig } from './lib/pubky.mjs'; -import { contentCreatorSessionPath, examplesRoot, parseArgs, repoRoot, writeJson } from './lib/paths.mjs'; +import { contentCreatorSessionPath, examplesRoot, parseArgs, repoRoot } from './lib/paths.mjs'; import { readDemoConfig, validateDemoConfig, pubkyAuthRelayInboxUrl, withInternalServiceUrls } from './lib/config.mjs'; +import { + readCreatorDemoSessionForCurrentRole, + 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); @@ -35,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); @@ -49,8 +57,13 @@ const server = createServer(async (request, response) => { return sendJson(response, debugSnapshot(config, preflightStatus)); } if (request.method === 'POST' && url.pathname === '/api/client-log') { - const entry = await readJsonBody(request); - console.log(`[client:${entry.level ?? 'info'}] ${entry.event ?? 'event'} ${JSON.stringify(entry)}`); + let level; + try { + level = await readClientLogLevel(request); + } catch { + return sendJson(response, { error: 'invalid client log' }, 400); + } + console.log(`[creator-client:${level}] event`); return sendJson(response, { ok: true }); } if (request.method === 'POST' && url.pathname === '/api/demo-auth/start') { @@ -61,8 +74,8 @@ const server = createServer(async (request, response) => { return sendJson(response, await demoAuthStatus()); } if (request.method === 'GET' && url.pathname === '/auth/lock-server/callback') { - // Only the redirect flow (app.js) navigates here; it gets index.html. The iframe flow uses - // direct postMessage delivery and never hits this callback route. + // Legacy callback URLs still resolve to the creator page. Both current creator pages use + // direct postMessage delivery and never navigate to this route. return serveStatic(response, join(examplesRoot, 'index.html')); } if (request.method !== 'GET') { @@ -71,8 +84,8 @@ const server = createServer(async (request, response) => { } return servePath(url.pathname, response); } catch (error) { - console.error(error); - sendJson(response, { error: error.message }, 500); + console.error('demo request failed'); + sendJson(response, { error: 'request failed' }, 500); } }); @@ -90,39 +103,48 @@ async function startDemoAuth() { const pubky = pubkyForConfig(serviceConfig); const capabilities = '/pub/locks.app/:rw,/priv/locks.app/:rw'; const authRelay = pubkyAuthRelayInboxUrl(serviceConfig.testnet.httpRelay); - activeDemoAuthFlow = pubky.startAuthFlow(capabilities, AuthFlowKind.signin(), authRelay); + activeDemoAuthFlow = pubky.startCookieAuthFlow(capabilities, AuthFlowKind.signin(), authRelay); activeDemoAuthUrl = activeDemoAuthFlow.authorizationUrl; activeDemoAuthStartedAt = new Date().toISOString(); demoAuthPromise = activeDemoAuthFlow .awaitApproval() .then(async (session) => { - await writeJson(contentCreatorSessionPath, { + const creatorSession = { role: 'content-creator', pubky: session.info.publicKey.toString(), capabilities: session.info.capabilities, exported_session: session.export(), authenticated_at: new Date().toISOString(), - }); - activeDemoAuthFlow = null; + }; + await writeCreatorDemoSessionForCurrentRole(creatorSession, sessionStateOptions()); + if (externalWallet) await publishCreatorProfile({ profile: creatorSession }); return session; }) .catch((error) => { + console.error(`demo auth failed: ${error instanceof Error ? error.message : String(error)}`); + }) + .finally(() => { activeDemoAuthFlow = null; - console.error(`demo auth failed: ${error.message}`); + activeDemoAuthUrl = null; + activeDemoAuthStartedAt = null; + 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() { - if (existsSync(contentCreatorSessionPath)) { - const session = JSON.parse(await readFile(contentCreatorSessionPath, 'utf8')); + 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`); } @@ -144,7 +166,17 @@ async function demoAuthStatus() { } async function hasPersistedDemoSession() { - return existsSync(contentCreatorSessionPath); + 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) { @@ -152,6 +184,7 @@ function publicBrowserConfig(source) { return { demoServer: source.demoServer, lockServer: source.lockServer, + paykit: source.paykit, testnet: source.testnet, paths: { lockServerCallback: `${source.demoServer.url}/auth/lock-server/callback`, @@ -163,7 +196,7 @@ function logStartupDiagnostics(source, preflight) { if (!debugEnabled) return; const authRelay = pubkyAuthRelayInboxUrl(source.testnet.httpRelay); console.log('[demo] startup diagnostics'); - console.log('[demo] config path: ./.local/js-sdk-demo/config.json'); + console.log('[demo] config path: ./.local/demo-config/config.json'); console.log(`[demo] demoServer.url=${source.demoServer.url}`); console.log(`[demo] lockServer.url=${source.lockServer.url}`); console.log(`[demo] lockServer.pubky=${source.lockServer.pubky}`); @@ -200,7 +233,7 @@ function logRequest(request, url) { if (!debugEnabled) return; const interesting = url.pathname.startsWith('/api/') || url.pathname.startsWith('/auth/') || url.pathname === '/config.json'; if (!interesting) return; - console.log(`[demo] ${request.method} ${url.pathname}${url.search}`); + console.log(`[demo] ${request.method} ${url.pathname}`); } async function runPreflight(source) { @@ -214,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 @@ -243,27 +279,31 @@ async function checkHttp(url, name, checks, acceptsStatus) { } } -async function readJsonBody(request) { +async function readClientLogLevel(request) { const chunks = []; - for await (const chunk of request) chunks.push(chunk); - const text = Buffer.concat(chunks).toString('utf8'); - if (!text) return {}; - try { - return JSON.parse(text); - } catch (error) { - return { level: 'warn', event: 'invalid-client-log-json', raw: text, parseError: error.message }; + let size = 0; + for await (const chunk of request) { + size += chunk.length; + if (size > 128) throw new Error('client log too large'); + chunks.push(chunk); } + const text = Buffer.concat(chunks).toString('utf8'); + const value = JSON.parse(text); + if ( + value === null + || typeof value !== 'object' + || Array.isArray(value) + || Object.keys(value).length !== 1 + || !Object.hasOwn(value, 'level') + || !['info', 'warn', 'error'].includes(value.level) + ) throw new Error('invalid client log'); + return value.level; } function servePath(pathname, response) { - let relative = pathname === '/' ? '/examples/js-sdk/' : pathname; - if (relative === '/examples/js-sdk/') relative = '/examples/js-sdk/index.html'; - if (relative.startsWith('/pkg/')) relative = `/locks-sdk/bindings/js${relative}`; - - const normalized = normalize(relative).replace(/^\/+/, ''); - const filePath = resolve(repoRoot, normalized); - if (!filePath.startsWith(repoRoot + sep)) { - response.writeHead(403).end('forbidden'); + const filePath = resolveCreatorStaticPath(pathname, { repoRoot, examplesRoot }); + if (!filePath) { + response.writeHead(404).end('not found'); return; } if (!existsSync(filePath) || statSync(filePath).isDirectory()) { diff --git a/examples/js-sdk/scripts/start-reader-demo-server.mjs b/examples/js-sdk/scripts/start-reader-demo-server.mjs index 99e8c4b..db347ba 100644 --- a/examples/js-sdk/scripts/start-reader-demo-server.mjs +++ b/examples/js-sdk/scripts/start-reader-demo-server.mjs @@ -1,19 +1,39 @@ #!/usr/bin/env node import { createReadStream, existsSync, statSync } from 'node:fs'; import { createServer } from 'node:http'; -import { extname, join, normalize, resolve, sep } from 'node:path'; +import { extname, join, normalize } from 'node:path'; import { readDemoConfig, validateDemoConfig, pubkyAuthRelayInboxUrl, withInternalServiceUrls } from './lib/config.mjs'; -import { examplesRoot, parseArgs, repoRoot } from './lib/paths.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, + readPaykitReaderWorkerStatus, + writePaykitReaderWorkerStatus, +} from './lib/paykit-reader-status.mjs'; const args = parseArgs(process.argv.slice(2)); const allowUnhealthy = args['allow-unhealthy'] === true; const config = await readDemoConfig(); const serviceConfig = withInternalServiceUrls(config); const readerUrl = new URL(config.demoServer.url); -readerUrl.port = String(args.port ?? 8081); +readerUrl.port = String(args.port ?? 8088); const readerServerUrl = readerUrl.toString().replace(/\/$/, ''); const preflightStatus = await runPreflight(serviceConfig); +const externalReaderPubky = process.env.PAYKIT_EXTERNAL_READER_PUBKY?.trim() ?? ''; +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); @@ -34,6 +54,9 @@ const server = createServer(async (request, response) => { try { const url = new URL(request.url, readerServerUrl); logRequest(request, url); + if (request.method === 'GET' && url.pathname === '/api/health') { + return sendJson(response, { status: 'ok' }); + } if (request.method === 'GET' && url.pathname === '/config.json') { return sendJson(response, publicBrowserConfig(config)); } @@ -43,9 +66,21 @@ const server = createServer(async (request, response) => { if (request.method === 'GET' && url.pathname === '/api/debug/config') { return sendJson(response, debugSnapshot(config, preflightStatus)); } + 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); + } if (request.method === 'POST' && url.pathname === '/api/client-log') { - const entry = await readJsonBody(request); - console.log(`[reader-client:${entry.level ?? 'info'}] ${entry.event ?? 'event'} ${JSON.stringify(entry)}`); + let level; + try { + level = await readClientLogLevel(request); + } catch { + return sendJson(response, { error: 'invalid client log' }, 400); + } + console.log(`[reader-client:${level}] event`); return sendJson(response, { ok: true }); } if (request.method !== 'GET') { @@ -56,9 +91,9 @@ const server = createServer(async (request, response) => { return serveStatic(response, join(examplesRoot, 'reader.html')); } return servePath(url.pathname, response); - } catch (error) { - console.error(error); - sendJson(response, { error: error.message }, 500); + } catch { + console.error('reader demo request failed'); + sendJson(response, { error: 'request failed' }, 500); } }); @@ -67,6 +102,16 @@ server.listen(Number(readerUrl.port), () => { console.log(`Open ${readerServerUrl}/reader/`); }); +const workerTask = workerEnabled + ? supervisePaykitReaderWorker( + runWorkerAfterCreatorProfile(), + { onTerminalFailure: handleTerminalWorkerFailure }, + ) + : Promise.resolve({ status: 'stopped' }); +for (const signal of ['SIGINT', 'SIGTERM']) { + process.once(signal, () => { void shutdown(signal); }); +} + function publicBrowserConfig(source) { validateDemoConfig(source); return { @@ -77,6 +122,62 @@ 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, waitingForCreator }); +} + +async function handleTerminalWorkerFailure(error) { + const terminalError = [ + 'invalid_input', + 'invalid_config', + 'invalid_state', + 'output_failed', + 'prepare_timeout', + ].includes(error) ? error : 'worker_failed'; + console.error(`[reader-demo] Paykit reader worker stopped (${terminalError})`); + workerController.abort(); + process.exitCode = 1; + server.close(); +} + +let shutdownStarted = false; +async function shutdown(signal) { + if (shutdownStarted) return; + shutdownStarted = true; + console.log(`[reader-demo] shutting down after ${signal}`); + workerController.abort(); + await Promise.allSettled([ + workerTask, + new Promise((resolveClose) => server.close(resolveClose)), + ]); +} + function debugSnapshot(source, preflight) { return { checkedAt: new Date().toISOString(), @@ -97,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); @@ -115,11 +218,13 @@ async function checkHttp(url, name, checks, acceptsStatus) { function servePath(pathname, response) { const normalizedPath = normalize(pathname).replace(/^[/\\]+/, ''); - const resolved = normalizedPath.startsWith('locks-sdk/') - ? resolve(repoRoot, normalizedPath) - : resolve(examplesRoot, normalizedPath); - const allowedRoots = [examplesRoot, resolve(repoRoot, 'locks-sdk/bindings/js/pkg')]; - if (!allowedRoots.some((root) => resolved === root || resolved.startsWith(`${root}${sep}`))) { + const packagePrefix = 'locks-sdk/bindings/js/pkg/'; + const resolved = normalizedPath.startsWith(packagePrefix) + ? resolveExistingPathWithin(join(repoRoot, 'locks-sdk/bindings/js/pkg'), normalizedPath.slice(packagePrefix.length)) + : normalizedPath.startsWith('locks-sdk/') + ? null + : resolveExistingPathWithin(examplesRoot, normalizedPath); + if (!resolved) { response.writeHead(404).end('not found'); return; } @@ -152,15 +257,31 @@ function contentType(filePath) { } function sendJson(response, body, status = 200) { - response.writeHead(status, { 'content-type': 'application/json; charset=utf-8' }); + response.writeHead(status, { + 'cache-control': 'no-store', + 'content-type': 'application/json; charset=utf-8', + }); response.end(JSON.stringify(body)); } -async function readJsonBody(request) { +async function readClientLogLevel(request) { const chunks = []; - for await (const chunk of request) chunks.push(chunk); - if (chunks.length === 0) return {}; - return JSON.parse(Buffer.concat(chunks).toString('utf8')); + let size = 0; + for await (const chunk of request) { + size += chunk.length; + if (size > 128) throw new Error('client log too large'); + chunks.push(chunk); + } + const value = JSON.parse(Buffer.concat(chunks).toString('utf8')); + if ( + value === null + || typeof value !== 'object' + || Array.isArray(value) + || Object.keys(value).length !== 1 + || !Object.hasOwn(value, 'level') + || !['info', 'warn', 'error'].includes(value.level) + ) throw new Error('invalid client log'); + return value.level; } function logStartupDiagnostics(source, preflight) { diff --git a/examples/js-sdk/scripts/test-paykit-reader-worker.mjs b/examples/js-sdk/scripts/test-paykit-reader-worker.mjs new file mode 100644 index 0000000..a9fe8b0 --- /dev/null +++ b/examples/js-sdk/scripts/test-paykit-reader-worker.mjs @@ -0,0 +1,447 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import { EventEmitter } from 'node:events'; +import { mkdtemp, rm, stat, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { PassThrough } from 'node:stream'; + +import { runBoundedHelper } from './authenticate-paykit.mjs'; +import { parsePaykitReaderBrowserStatus } from '../reader-flow.js'; +import { runRegistrationStep } from './lib/paykit-reader-helper.mjs'; +import { + acquirePaykitReaderOwnership, + runPaykitReaderWorker, + supervisePaykitReaderWorker, + waitForCreatorProfile, +} from './lib/paykit-reader-worker.mjs'; +import { + buildPaykitReaderBrowserStatus, + readPaykitReaderWorkerStatus, + validatePaykitReaderWorkerStatus, + writePaykitReaderWorkerStatus, +} from './lib/paykit-reader-status.mjs'; + +const readerPubky = `pubky${'y'.repeat(52)}`; +const received = { + version: 1, + status: 'received', + payment_request_id: '12345678-1234-4123-8123-123456789abc', + 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=/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 = []; +const statuses = []; +let concurrent = 0; +let maxConcurrent = 0; +let ownershipAcquired = 0; +let ownershipReleased = 0; +const ownershipStates = []; +const lifecycleEvents = []; +let workerSignal; +const controller = new AbortController(); + +await runPaykitReaderWorker({ + signal: controller.signal, + runOperation: async ({ operation, signal }) => { + if (!workerSignal) workerSignal = signal; + assert.equal(signal, workerSignal); + assert.equal(signal.aborted, false); + concurrent += 1; + maxConcurrent = Math.max(maxConcurrent, concurrent); + operations.push(operation); + concurrent -= 1; + if (operation === 'prepare') { + return { status: 'success', value: { version: 1, status: 'prepared', reader_pubky: readerPubky, receiver_path: 'bitkit/wallet' } }; + } + if (operations.filter((value) => value === 'receive').length === 1) { + return { status: 'failed', error: 'receive_timeout' }; + } + return { status: 'success', value: received }; + }, + writePreparedStatus: async () => {}, + writeWorkerStatus: async (value) => { + statuses.push(value); + lifecycleEvents.push(`write:${value.state}`); + if (value.state === 'request_received') controller.abort(); + }, + wait: async () => {}, + acquireOwnership: async () => { + ownershipAcquired += 1; + return { release: async () => { ownershipReleased += 1; } }; + }, + onOwnershipChange: (owned) => { + ownershipStates.push(owned); + lifecycleEvents.push(`owned:${owned}`); + }, +}); + +assert.deepEqual(operations, ['prepare', 'receive', 'receive']); +assert.equal(maxConcurrent, 1); +assert.equal(ownershipAcquired, 1); +assert.equal(ownershipReleased, 1); +assert.deepEqual(ownershipStates, [true, false]); +assert.equal(workerSignal.aborted, true); +assert.ok(lifecycleEvents.indexOf('write:starting') < lifecycleEvents.indexOf('owned:true')); +assert.deepEqual(statuses.map(({ state }) => state), [ + 'starting', + 'waiting', + 'retrying', + 'request_received', +]); +assert.deepEqual(statuses.at(-1), { + version: 1, + state: 'request_received', + reader_pubky: readerPubky, + payment_request_id: received.payment_request_id, + address: received.address, + asset: 'BTC', + amount_sats: '50000', + payment_command: received.payment_command, + optional_mining_command: received.optional_mining_command, +}); +assert.deepEqual(parsePaykitReaderBrowserStatus(statuses.at(-1)), statuses.at(-1)); +assert.deepEqual(parsePaykitReaderBrowserStatus({ + version: 1, + state: 'retrying', + reader_pubky: readerPubky, + error: 'receive_timeout', +}), { + version: 1, + state: 'retrying', + 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, + payment_request_id: 'abcdef12-3456-4789-8123-123456789abc', +}; +const continuousOperations = []; +const continuousStatuses = []; +let continuousWaits = 0; +const continuousController = new AbortController(); +const continuousTimeout = setTimeout(() => continuousController.abort(), 100); +await runPaykitReaderWorker({ + signal: continuousController.signal, + runOperation: async ({ operation }) => { + continuousOperations.push(operation); + if (operation === 'prepare') { + return { status: 'success', value: { version: 1, status: 'prepared', reader_pubky: readerPubky, receiver_path: 'bitkit/wallet' } }; + } + const receiveCount = continuousOperations.filter((value) => value === 'receive').length; + if (receiveCount === 1) return { status: 'success', value: received }; + if (receiveCount === 2) return { status: 'success', value: received }; + return { status: 'success', value: newerReceived }; + }, + writePreparedStatus: async () => {}, + writeWorkerStatus: async (value) => { + continuousStatuses.push(value); + if (value.payment_request_id === newerReceived.payment_request_id) { + continuousController.abort(); + } + }, + wait: async () => { continuousWaits += 1; }, + acquireOwnership: async () => ({ release: async () => {} }), + onOwnershipChange: () => {}, +}); +clearTimeout(continuousTimeout); +assert.deepEqual(continuousOperations, ['prepare', 'receive', 'receive', 'receive']); +assert.deepEqual(continuousStatuses.map(({ state }) => state), [ + 'starting', + 'waiting', + 'request_received', + 'request_received', +]); +assert.equal(continuousWaits, 1); +assert.equal(continuousStatuses.at(-1).payment_request_id, newerReceived.payment_request_id); +assert.throws( + () => parsePaykitReaderBrowserStatus({ ...statuses.at(-1), raw_request: 'private' }), + /invalid Paykit reader status/, +); + +const helperSignals = []; +class AbortableHelper extends EventEmitter { + constructor() { + super(); + this.stdin = new PassThrough(); + this.stdout = new PassThrough(); + this.stderr = new PassThrough(); + queueMicrotask(() => this.emit('spawn')); + } + + kill(signal) { + helperSignals.push(signal); + queueMicrotask(() => this.emit('close', null, signal)); + return true; + } +} +const helperController = new AbortController(); +const helperResult = runBoundedHelper({ + helperPath: '/test/helper', + input: { version: 1 }, + classifyClose: () => ({ status: 'success' }), + timeoutMs: 1_000, + signal: helperController.signal, + spawnProcess: () => new AbortableHelper(), +}); +helperController.abort(); +assert.deepEqual(await helperResult, { status: 'failed' }); +assert.deepEqual(helperSignals, ['SIGTERM']); + +const preAborted = new AbortController(); +preAborted.abort(); +let preAbortedSpawned = false; +assert.deepEqual(await runBoundedHelper({ + helperPath: '/test/helper', + input: { version: 1 }, + classifyClose: () => ({ status: 'success' }), + timeoutMs: 1_000, + signal: preAborted.signal, + spawnProcess: () => { + preAbortedSpawned = true; + return new AbortableHelper(); + }, +}), { status: 'failed' }); +assert.equal(preAbortedSpawned, false); + +const statusRoot = await mkdtemp(join(tmpdir(), 'paykit-reader-worker-')); +try { + const ownershipPath = join(statusRoot, 'owner.lock'); + const firstOwner = await acquirePaykitReaderOwnership(ownershipPath); + assert.equal((await stat(ownershipPath)).mode & 0o777, 0o600); + await assert.rejects( + acquirePaykitReaderOwnership(ownershipPath), + /already owned/, + ); + await firstOwner.release(); + const replacementOwner = await acquirePaykitReaderOwnership(ownershipPath); + await replacementOwner.release(); + await writeFile(ownershipPath, 'stale', { mode: 0o600 }); + const recoveredOwner = await acquirePaykitReaderOwnership(ownershipPath); + await recoveredOwner.release(); + + const statusPath = join(statusRoot, 'worker.v1.json'); + await writePaykitReaderWorkerStatus(statuses.at(-1), statusPath); + assert.equal((await stat(statusPath)).mode & 0o777, 0o600); + assert.deepEqual(await readPaykitReaderWorkerStatus(statusPath), statuses.at(-1)); + assert.throws( + () => validatePaykitReaderWorkerStatus({ ...statuses.at(-1), raw_request: 'private' }), + /invalid Paykit reader worker status/, + ); + assert.deepEqual( + buildPaykitReaderBrowserStatus( + statuses.at(-1), + { role: 'content-viewer', pubky: readerPubky }, + { currentOwner: true }, + ), + statuses.at(-1), + ); + assert.deepEqual( + buildPaykitReaderBrowserStatus( + statuses.at(-1), + { role: 'content-viewer', pubky: `pubky${'b'.repeat(52)}` }, + { currentOwner: true }, + ), + { version: 1, state: 'failed', error: 'identity_mismatch' }, + ); + assert.deepEqual( + buildPaykitReaderBrowserStatus( + statuses.at(-1), + { role: 'content-viewer', pubky: readerPubky }, + { currentOwner: false }, + ), + { 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 }); +} + +const acquisitionAbortController = new AbortController(); +let finishAcquisition; +let acquisitionAbortReleased = 0; +let operationStartedAfterAcquisitionAbort = false; +const acquisitionAbortWorker = runPaykitReaderWorker({ + signal: acquisitionAbortController.signal, + acquireOwnership: () => new Promise((resolveOwnership) => { + finishAcquisition = () => resolveOwnership({ + release: async () => { acquisitionAbortReleased += 1; }, + }); + }), + runOperation: async () => { + operationStartedAfterAcquisitionAbort = true; + return { status: 'failed', error: 'worker_failed' }; + }, + writeWorkerStatus: async () => {}, +}); +acquisitionAbortController.abort(); +finishAcquisition(); +assert.deepEqual(await acquisitionAbortWorker, { status: 'stopped' }); +assert.equal(operationStartedAfterAcquisitionAbort, false); +assert.equal(acquisitionAbortReleased, 1); + +const startingAbortController = new AbortController(); +let operationStartedAfterStartingAbort = false; +assert.deepEqual(await runPaykitReaderWorker({ + signal: startingAbortController.signal, + acquireOwnership: async () => ({ release: async () => {} }), + runOperation: async () => { + operationStartedAfterStartingAbort = true; + return { status: 'failed', error: 'worker_failed' }; + }, + writeWorkerStatus: async ({ state }) => { + if (state === 'starting') startingAbortController.abort(); + }, +}), { status: 'stopped' }); +assert.equal(operationStartedAfterStartingAbort, false); + +const prepareAbortController = new AbortController(); +const prepareAbortWrites = []; +let preparedCheckpointAfterAbort = false; +assert.deepEqual(await runPaykitReaderWorker({ + signal: prepareAbortController.signal, + acquireOwnership: async () => ({ release: async () => {} }), + runOperation: async () => { + prepareAbortController.abort(); + return { + status: 'success', + value: { + version: 1, + status: 'prepared', + reader_pubky: readerPubky, + receiver_path: 'bitkit/wallet', + }, + }; + }, + writePreparedStatus: async () => { preparedCheckpointAfterAbort = true; }, + writeWorkerStatus: async ({ state }) => { prepareAbortWrites.push(state); }, +}), { status: 'stopped' }); +assert.equal(preparedCheckpointAfterAbort, false); +assert.deepEqual(prepareAbortWrites, ['starting']); + +let resolveOwnershipLoss; +let ownershipLossReleased = 0; +const ownershipLossWrites = []; +const ownershipLossStates = []; +await assert.rejects(runPaykitReaderWorker({ + signal: new AbortController().signal, + acquireOwnership: async () => ({ + lost: new Promise((resolveLoss) => { resolveOwnershipLoss = resolveLoss; }), + release: async () => { ownershipLossReleased += 1; }, + }), + runOperation: async () => ({ + status: 'success', + value: { + version: 1, + status: 'prepared', + reader_pubky: readerPubky, + receiver_path: 'bitkit/wallet', + }, + }), + writePreparedStatus: async () => { + resolveOwnershipLoss(); + await new Promise((resolve) => setImmediate(resolve)); + }, + writeWorkerStatus: async ({ state }) => { ownershipLossWrites.push(state); }, + onOwnershipChange: (owned) => ownershipLossStates.push(owned), +}), /ownership was lost/); +assert.deepEqual(ownershipLossWrites, ['starting']); +assert.deepEqual(ownershipLossStates, [true, false]); +assert.equal(ownershipLossReleased, 1); + +let rejectedWorkerReleased = 0; +const rejectedWorker = runPaykitReaderWorker({ + signal: new AbortController().signal, + writeWorkerStatus: async () => { throw new Error('status write failed'); }, + acquireOwnership: async () => ({ release: async () => { rejectedWorkerReleased += 1; } }), +}); +let supervisedError; +assert.deepEqual(await supervisePaykitReaderWorker(rejectedWorker, { + onTerminalFailure: async (error) => { supervisedError = error; }, +}), { status: 'failed', error: 'worker_failed' }); +assert.equal(supervisedError, 'worker_failed'); +assert.equal(rejectedWorkerReleased, 1); + +const registrationController = new AbortController(); +let registrationCancelled = false; +let registrationCleanupComplete = false; +const registration = runRegistrationStep({ + signal: registrationController.signal, + timeoutMs: 60_000, + ensureRegistered: ({ signal }) => new Promise((resolveRegistration) => { + const finishCleanup = () => { + registrationCancelled = true; + setTimeout(() => { + registrationCleanupComplete = true; + resolveRegistration({ status: 'failed' }); + }, 5); + }; + if (signal.aborted) finishCleanup(); + else signal.addEventListener('abort', finishCleanup, { once: true }); + }), +}); +registrationController.abort(); +assert.deepEqual(await registration, { status: 'failed' }); +assert.equal(registrationCancelled, true); +assert.equal(registrationCleanupComplete, true); + +let registrationTimedOutSignal = false; +let timedRegistrationCleanupComplete = false; +const timedRegistration = runRegistrationStep({ + signal: new AbortController().signal, + timeoutMs: 5, + ensureRegistered: ({ signal }) => new Promise((resolveRegistration) => { + signal.addEventListener('abort', () => { + registrationTimedOutSignal = true; + setTimeout(() => { + timedRegistrationCleanupComplete = true; + resolveRegistration({ status: 'failed' }); + }, 5); + }, { once: true }); + }), +}); +assert.deepEqual(await timedRegistration, { status: 'timeout' }); +assert.equal(registrationTimedOutSignal, true); +assert.equal(timedRegistrationCleanupComplete, true); + +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 new file mode 100644 index 0000000..bd4218b --- /dev/null +++ b/examples/js-sdk/scripts/validate-paykit-compose.mjs @@ -0,0 +1,150 @@ +#!/usr/bin/env node +import { spawnSync } from 'node:child_process'; +import { fileURLToPath } from 'node:url'; + +import { repoRoot } from './lib/paths.mjs'; + +const MAX_MODEL_BYTES = 2 * 1024 * 1024; +const COMPOSE_FILE = 'compose.paykit-local-demo.yaml'; +const REQUIRED_SERVICES = [ + 'postgres', + 'paykit-postgres', + 'bitcoin', + 'bitcoin-bootstrap', + 'fulcrum', + 'electrum-readiness', + 'pubky-testnet', + 'homegate-bridge', + 'locks-server', + 'paykit-config', + 'demo-config', + 'paykit-server', + 'creator-demo', + 'reader-demo', +]; + +export function validateSafeComposeModel(model) { + if (!model || typeof model !== 'object' || Array.isArray(model) || !model.services) { + throw new Error('Compose returned an invalid model'); + } + for (const service of REQUIRED_SERVICES) { + if (!model.services[service]) throw new Error(`Compose model is missing service ${service}`); + } + for (const [name, service] of Object.entries(model.services)) { + if (typeof service.image === 'string' && !service.build && !service.image.includes('@sha256:')) { + throw new Error(`external image for ${name} is not digest-pinned`); + } + } + for (const name of [ + 'bitcoin-bootstrap', + 'electrum-readiness', + 'paykit-config', + 'demo-config', + 'homegate-bridge', + 'creator-demo', + 'reader-demo', + ]) { + const service = model.services[name]; + if (String(service.user ?? '') !== '1000:1000') { + throw new Error(`${name} must run as an unprivileged user`); + } + } + for (const name of ['creator-demo', 'reader-demo']) { + const service = model.services[name]; + for (const mount of service.volumes ?? []) { + if ( + mount.source === '.' + || mount.source === 'lock-home' + || mount.source === 'pubky-locks-demo-identity' + || mount.source === 'locks_lock-home' + || mount.target === '/workspace' + || mount.target === '/workspace/.local' + || mount.target === '/root' + || mount.target === '/var/lib/pubky-lock' + ) { + throw new Error(`${name} crosses a private source or identity boundary`); + } + } + } + const readerTargets = new Set( + (model.services['reader-demo'].volumes ?? []).map((mount) => mount.target), + ); + for (const privateTarget of [ + '/workspace/.local/js-sdk-demo', + '/workspace/.local/content-creator', + ]) { + if (readerTargets.has(privateTarget)) { + throw new Error('reader-demo crosses a creator-private state boundary'); + } + } + const bootstrapHome = (model.services['bitcoin-bootstrap'].volumes ?? []).find( + (mount) => mount.target === '/home/bitcoin/.bitcoin', + ); + if ( + bootstrapHome?.type !== 'bind' + || !bootstrapHome.source.endsWith('/.local/bitcoin-bootstrap') + ) { + throw new Error('bitcoin-bootstrap must use reset-managed scratch state'); + } + 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; +} + +export function validatePaykitCompose({ run = runCompose } = {}) { + const quiet = run(['compose', '-f', COMPOSE_FILE, 'config', '--quiet'], { capture: false }); + if (quiet.error || quiet.status !== 0 || quiet.signal) { + throw new Error('Compose quiet validation failed'); + } + const rendered = run( + ['compose', '-f', COMPOSE_FILE, 'config', '--no-env-resolution', '--format', 'json'], + { capture: true }, + ); + if (rendered.error || rendered.status !== 0 || rendered.signal) { + throw new Error('Compose safe model rendering failed'); + } + if (Buffer.byteLength(rendered.stdout ?? '', 'utf8') > MAX_MODEL_BYTES) { + throw new Error('Compose model exceeded the output limit'); + } + let model; + try { + model = JSON.parse(rendered.stdout); + } catch { + throw new Error('Compose returned invalid JSON'); + } + validateSafeComposeModel(model); +} + +function runCompose(args, { capture }) { + const environment = Object.fromEntries(Object.entries({ + HOME: process.env.HOME, + PATH: process.env.PATH, + DOCKER_HOST: process.env.DOCKER_HOST, + DOCKER_CONTEXT: process.env.DOCKER_CONTEXT, + DOCKER_CONFIG: process.env.DOCKER_CONFIG, + }).filter(([, value]) => typeof value === 'string')); + return spawnSync('docker', args, { + cwd: repoRoot, + shell: false, + encoding: capture ? 'utf8' : undefined, + stdio: capture ? ['ignore', 'pipe', 'ignore'] : 'ignore', + timeout: 30_000, + maxBuffer: MAX_MODEL_BYTES, + env: environment, + }); +} + +function main() { + try { + validatePaykitCompose(); + process.stdout.write('Paykit Compose validation passed\n'); + } catch (error) { + process.stderr.write(`${error instanceof Error ? error.message : 'Compose validation failed'}\n`); + process.exitCode = 1; + } +} + +if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) main(); diff --git a/locks-e2e/tests/legacy_connect_shell_http.rs b/locks-e2e/tests/legacy_connect_shell_http.rs index 29c3bdd..dc71e2c 100644 --- a/locks-e2e/tests/legacy_connect_shell_http.rs +++ b/locks-e2e/tests/legacy_connect_shell_http.rs @@ -158,6 +158,10 @@ async fn connect_shell_postmessage_mode_returns_json_and_frames_allowed_parent() // the callback message type, and drops the manual approval button. assert!(shell_html.contains("locks-auth-callback")); assert!(shell_html.contains("TARGET_ORIGIN = \"https://pubky.app\"")); + assert!(shell_html.contains("CALLBACK_STATE = \"opaque-state\"")); + assert!(shell_html.contains("error: \"invalid-response\"")); + assert!(shell_html.contains("error: \"connect-failed\"")); + assert!(!shell_html.contains("connect-failed-\" + res.status")); assert!(!shell_html.contains("I approved this connection")); assert!(!shell_html.contains(".json resource'], ['LOCKS_LIVE_DEMO_ORIGIN', 'Origin allowed by creator_authority_acquisition.legacy_connect.allowed_return_origins'], @@ -21,7 +21,7 @@ for (const [name, description] of required) { console.log('\nManual smoke sequence once prerequisites exist:'); console.log('1. npm --prefix locks-sdk/bindings/js run build'); console.log('2. python3 -m http.server 8080 --directory locks-sdk/bindings/js'); -console.log('3. Open http://localhost:8080/demo/ from the allowed origin / configured browser context.'); +console.log('3. Open http://127.0.0.1:8080/demo/ from the allowed origin / configured browser context.'); console.log('4. Build LocksOptions and add LOCKS_LIVE_PKARR_RELAY with addPkarrRelay().'); console.log('5. Verify Locks.forServerWithOptions/createConnectUrl/exchangeFrontendSessionCode against the live Lock Server.'); console.log('6. Verify Locks.forCreatorWithOptions and Locks.readContentLockWithOptions using the creator and content-lock resource.'); diff --git a/locks-sdk/bindings/js/scripts/smoke-examples.mjs b/locks-sdk/bindings/js/scripts/smoke-examples.mjs index 9542319..4e3edc2 100644 --- a/locks-sdk/bindings/js/scripts/smoke-examples.mjs +++ b/locks-sdk/bindings/js/scripts/smoke-examples.mjs @@ -1,6 +1,9 @@ import assert from 'node:assert/strict'; -import { existsSync, readFileSync } from 'node:fs'; +import { EventEmitter } from 'node:events'; +import { chmodSync, existsSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; import { join, resolve } from 'node:path'; +import { PassThrough } from 'node:stream'; import { pathToFileURL } from 'node:url'; const repoRoot = resolve(new URL('../../../..', import.meta.url).pathname); @@ -9,14 +12,29 @@ const files = { readme: join(examplesDir, 'README.md'), packageJson: join(examplesDir, 'package.json'), app: join(examplesDir, 'app.js'), + appIframe: join(examplesDir, 'app-iframe.js'), index: join(examplesDir, 'index.html'), + iframe: join(examplesDir, 'iframe.html'), + flows: join(examplesDir, 'flows.html'), creator: join(examplesDir, 'creator-complete-flow.js'), + creatorIdentity: join(examplesDir, 'creator-identity.js'), + creatorPolicy: join(examplesDir, 'creator-lock-policy.js'), + paykitSetup: join(examplesDir, 'paykit-setup.js'), readerHtml: join(examplesDir, 'reader.html'), readerApp: join(examplesDir, 'reader-app.js'), readerFlow: join(examplesDir, 'reader-flow.js'), initConfig: join(examplesDir, 'scripts', 'init-config.mjs'), 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'), + paykitReaderLib: join(examplesDir, 'scripts', 'lib', 'paykit-reader-helper.mjs'), + paykitReaderStatus: join(examplesDir, 'scripts', 'lib', 'paykit-reader-status.mjs'), + paykitReaderWorker: join(examplesDir, 'scripts', 'lib', 'paykit-reader-worker.mjs'), + creatorSessionState: join(examplesDir, 'scripts', 'lib', 'creator-session-state.mjs'), startServer: join(examplesDir, 'scripts', 'start-demo-server.mjs'), startReaderServer: join(examplesDir, 'scripts', 'start-reader-demo-server.mjs'), pathsLib: join(examplesDir, 'scripts', 'lib', 'paths.mjs'), @@ -40,30 +58,74 @@ 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://127.0.0.1:8080/examples/js-sdk/', 'npm --prefix examples/js-sdk run start-server', 'npm --prefix examples/js-sdk run start-reader-server', - './.local/js-sdk-demo/config.json', + './.local/demo-config/config.json', './.local/content-creator/recovery_file', - 'http://localhost:15411', - 'http://localhost:8081/reader/', + 'http://127.0.0.1:15411', + 'http://127.0.0.1:8088/reader/', '/priv/locks.app/content/', 'Viewer content lock resource', 'Reset reader state', + 'Both creator pages open the Lock Server `/connect` shell in an iframe modal.', + 'kept in memory only', + 'lock type defaults to `dev-static`', + '`paykit-payment`', + 'asset is fixed to `BTC`', + 'recipient is the authenticated content creator', + 'must both be approved by that same content-creator identity', + 'Paykit browser = http://127.0.0.1:3001', + 'opens `GET http://127.0.0.1:3001/setup` in a Paykit-origin iframe', + 'exact iframe window and origin with the pending state', + 'npm --prefix examples/js-sdk run authenticate-paykit -- --role content-creator', + 'Non-TTY stdin is exactly those three ordered lines', + 'in-process Paykit reader worker starts with `reader-demo`', + 'sole mutable owner of `./.local/paykit-reader/state.v1`', + './.local/paykit-reader/state.v1', + './.local/paykit-reader/prepared.v1.json', + './.local/paykit-reader/worker.v1.json', + 'Reader Pubky matches the current `content-viewer` identity', + 'top-level `reader_public_key`', + 'minimum_confirmations = 0', ], packageJson: [ '"@synonymdev/pubky"', '"init-config": "node scripts/init-config.mjs"', '"create-user": "node scripts/create-user.mjs"', '"authenticate": "node scripts/authenticate.mjs"', + '"authenticate-paykit": "node scripts/authenticate-paykit.mjs"', + '"prepare-paykit-reader": "node scripts/prepare-paykit-reader.mjs"', + '"receive-paykit-request": "node scripts/receive-paykit-request.mjs"', + '"test:paykit-reader-worker": "node scripts/test-paykit-reader-worker.mjs"', '"start-server": "node scripts/start-demo-server.mjs"', '"start-reader-server": "node scripts/start-reader-demo-server.mjs"', + '"smoke": "npm --prefix ../../locks-sdk/bindings/js run smoke:examples"', + 'node --check creator-lock-policy.js', + 'node --check paykit-setup.js', + 'node --check scripts/authenticate-paykit.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 creator-identity.js', ], - app: [ + app: ["import './app-iframe.js'"], + appIframe: [ "from './creator-complete-flow.js'", 'POST /api/demo-auth/start', 'GET /api/demo-auth/status', - '/auth/lock-server/callback', - 'localStorage.setItem', + 'const returnTo = `${window.location.origin}/auth/lock-server/callback`', + "deliveryUrl.searchParams.set('delivery', 'postmessage')", + 'openLockAuthIframe(deliveryUrl.toString())', + 'frame.src = connectUrl', + 'event.origin !== state.lockServerOrigin', + 'event.source !== state.lockAuthFrame?.contentWindow', + 'expectedState: state.pendingConnectState', + 'expectedCreatorPubky: state.creatorPubky', + 'state.pendingConnectState = null', + 'state.lockServerOrigin = null', + 'state.lockAuthFrame = null', 'Configure Lock Service Pointer', 'Create locked content', 'dev-static', @@ -76,17 +138,95 @@ const required = { 'secondaryContentFiles', 'appendSelectedResource', 'duplicate guarded resource path', + 'buildCreatorLockPolicy', + 'refreshLockTypeFields', + 'paykitSetupComplete: false', + 'const creatorPubky = status.authenticated ? status.pubky : null', + 'const creatorChanged = state.creatorPubky !== creatorPubky', + 'if (creatorChanged) {', + 'state.paykitSetupComplete = false', + 'closePaykitSetupIframe()', + 'state.creatorPubky = creatorPubky', + 'invalidateIdentityScopedCreatorState', + 'signOutCreator', + 'localStorage.removeItem(pointerConfiguredKey(previousCreatorPubky))', + 'recipientPubky: state.creatorPubky', + 'paykitSetupComplete: state.paykitSetupComplete', + "const paymentSelected = el.lockType.value === 'paykit-payment'", + 'el.devStaticFields.hidden = paymentSelected', + 'el.paykitPaymentFields.hidden = !paymentSelected', + 'el.paykitAmountSats.required = paymentSelected', + 'openPaykitSetupIframe', + 'npm --prefix examples/js-sdk run authenticate-paykit -- --role content-creator', + 'acceptPaykitSetupEvent', + 'state.paykitSetupComplete = true', + "el.paykitSetupStatus.className = 'ok'", + 'paykitUrl: state.config.paykit.url', + 'returnTo: window.location.origin', + 'expectedOrigin: state.paykitSetupOrigin', + 'expectedSource: state.paykitSetupFrame?.contentWindow', + 'expectedState: state.pendingPaykitSetupState', + 'setupCreator: state.paykitSetupCreator', + 'currentCreator: state.creatorPubky', + "if (el.lockType.value === 'paykit-payment' && state.creatorPubky) startPaykitSetup()", + 'el.retryPaykitSetup.hidden = false', ], - index: ['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"'], - readerHtml: ['id="content-lock-resource"', 'id="lock-resources"', 'id="primary-resource-list"', 'id="secondary-resource-list"', 'id="reset-reader-state"', 'id="read-content"', '/reader-app.js'], - initConfig: ['~/.pubky-lock/config.toml', './.local/js-sdk-demo/config.json', 'lock_server_public_key', 'http://localhost:15411', 'http://localhost:15412', 'localhost:6881'], - createUser: ['requiredRole', 'Keypair.random()', 'createRecoveryFile', 'profile.json', '--force', 'content-creator', 'content-viewer', 'lock-server'], + 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 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'], - startServer: ['createServer', '--allow-unhealthy', 'pubkyAuthRelayInboxUrl', '/api/demo-auth/start', '/api/demo-auth/status', '/config.json', 'awaitApproval', 'content-creator-session.json', '/healthz', '/readyz'], - startReaderServer: ['createServer', '--allow-unhealthy', '/reader/', '/config.json', '/api/preflight', '/api/debug/config', '/api/client-log', '8081', 'never proxy'], - pathsLib: ['localPath', 'roleDir', 'demoConfigPath', 'contentCreatorSessionPath'], - configLib: ['readDemoConfig', 'writeDemoConfig', 'parseLockServerTomlPublicKey', 'pubkyAuthRelayInboxUrl', 'validateDemoConfig'], - pubkyLib: ["from '@synonymdev/pubky'", 'Pubky.testnet', 'Keypair.fromRecoveryFile', 'AuthFlowKind', 'PublicKey.from'], + authenticatePaykit: [ + 'PAYKIT_COMPANION_AUTH_BIN', + '/usr/local/bin/paykit-companion-auth', + 'paykit-companion-auth-compose.sh', + 'loadRoleSecret', + 'content-creator', + 'version: 1', + 'auth_url', + 'creator_secret', + 'creatorSecret.buffer', + 'account_xpub', + 'account_index', + 'spawnProcess(helperPath, helperArgs, spawnOptions)', + 'catch {\n payload.fill(0);', + "child.stdin.end", + "child.kill('SIGTERM')", + "child.kill('SIGKILL')", + ], + composeCompanionHelper: [ + 'compose.paykit-local-demo.yaml', + 'PAYKIT_EXTERNAL_READER_PUBKY', + '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: [ + 'PAYKIT_READER_DEMO_BIN', + '/usr/local/bin/paykit-reader-demo', + 'version: 1', + 'reader_secret', + 'PAYKIT_READER_STATE_PATH', + 'loadRoleSecret', + 'runBoundedHelper', + 'payment_command', + 'optional_mining_command', + 'session?.free();', + 'signer?.free();', + 'keypair.free();', + ], + paykitReaderStatus: ['validatePreparedReaderStatus', 'clearPreparedReaderStatus', 'writePreparedReaderStatus', 'readPreparedReaderStatus', 'buildPreparedReaderBrowserStatus', 'writePaykitReaderWorkerStatus', 'readPaykitReaderWorkerStatus', 'buildPaykitReaderBrowserStatus', '0o600'], + paykitReaderWorker: ['runPaykitReaderWorker', 'assertStandaloneReaderOperationAllowed', 'acquirePaykitReaderOwnership', 'supervisePaykitReaderWorker', '/usr/bin/flock', "'--no-fork'", 'shell: false', "operation: 'prepare'", "operation: 'receive'", "state: 'request_received'"], + registerPaykitReader: ['signupReaderBestEffort', "request.operation !== 'register'", 'registration_failed'], + creatorSessionState: ['clearCreatorDemoSession', 'readCreatorDemoSessionForCurrentRole', 'writeCreatorDemoSessionForCurrentRole', 'contentCreatorSessionPath', 'rm'], + startServer: ['createServer', '--allow-unhealthy', 'pubkyAuthRelayInboxUrl', '/api/demo-auth/start', '/api/demo-auth/status', '/config.json', 'awaitApproval', 'content-creator-session.json', 'readCreatorDemoSessionForCurrentRole', 'writeCreatorDemoSessionForCurrentRole', '/healthz', '/readyz', 'paykit: source.paykit'], + startReaderServer: ['createServer', '--allow-unhealthy', 'runPaykitReaderWorker', 'supervisePaykitReaderWorker', 'workerOwnsState', 'handleTerminalWorkerFailure', 'writePaykitReaderWorkerStatus', 'readPaykitReaderWorkerStatus', 'AbortController', 'SIGTERM', '/reader/', '/config.json', '/api/health', '/api/preflight', '/api/debug/config', '/api/paykit-reader/status', '/api/client-log', "'cache-control': 'no-store'", '8088', 'never proxy'], + pathsLib: ['localPath', 'roleDir', 'demoConfigPath', 'contentCreatorSessionPath', 'paykitReaderPreparedPath', 'paykitReaderOwnershipPath', 'prepared.v1.json', 'owner.lock'], + configLib: ['readDemoConfig', 'writeDemoConfig', 'parseLockServerTomlPublicKey', 'pubkyAuthRelayInboxUrl', 'validateDemoConfig', "url: 'http://127.0.0.1:3001'", "['paykit', 'url']"], + pubkyLib: ["from '@synonymdev/pubky'", 'Pubky.testnet', 'Keypair.fromRecoveryFile', 'keypair.secret()', 'loadRoleSecret', 'AuthFlowKind', 'PublicKey.from'], creator: [ "from '../../locks-sdk/bindings/js/pkg/locks_sdk_wasm.js'", 'startCreatorConnect', @@ -107,11 +247,20 @@ const required = { 'normalizeResources', 'new SetLockServicePointerOptions(lockServer)', 'session.signout()', + '.lockLogic(lockLogic)', ], + creatorIdentity: ['enforceCreatorIdentityMatch', 'invalidateIdentityScopedCreatorState', 'session.signout()', 'does not match the demo creator'], + creatorPolicy: ['buildCreatorLockPolicy', 'paykit-payment', 'recipient_pubky', "asset: 'BTC'"], + paykitSetup: ['buildPaykitSetupRequest', 'acceptPaykitSetupEvent', 'paykit-setup-callback'], readerFlow: [ "from '../../locks-sdk/bindings/js/pkg/locks_sdk_wasm.js'", 'loadContentLock', 'submitDevStaticProof', + 'submitPaykitPaymentProof', + 'buildPaykitPaymentProofBundle', + 'reader_public_key', + "verifier_type: 'paykit-payment'", + 'payload: {}', 'completeDevVerification', 'lookupVerificationTask', 'issueAccessCredential', @@ -128,13 +277,18 @@ const required = { 'new VerificationTaskHandleOptions(creator, bundleId)', 'viewer.completeVerificationTask', 'viewer.issueAccessCredential', - 'viewer.proxyReadGuardedResource(accessCredential, path)', + 'viewer.proxyReadGuardedResourceResponse(accessCredential, path)', + 'response.headers.get', + 'response.arrayBuffer()', ], readerApp: [ "from './reader-flow.js'", 'pubky-locks-reader-demo.state', 'reader-load-lock-started', 'reader-submit-proof-started', + 'pollPaymentLifecycle', + "status === 'in_progress'", + "status === 'expired'", 'reader-complete-verification-started', 'reader-complete-verification-conflict-looking-up', 'reader-issue-credential-started', @@ -142,6 +296,28 @@ const required = { 'lockResources', 'renderLockResources', 'data-read-resource-path', + 'let workflowIncarnation = 0;', + 'if (state.submittingProof) return;', + 'activeSubmissionToken !== submissionToken', + '!workflowMatches(handle) || activePollToken !== pollToken', + 'issuePaymentCredential(handle)', + 'readPaymentContent(handle, handle.primaryPath, credential)', + "fetch('/api/paykit-reader/status'", + "cache: 'no-store'", + 'parsePaykitReaderBrowserStatus', + 'selectCurrentPaykitPaymentRequest', + 'state.paykitReaderState', + 'state.baselinePaymentRequestId', + 'state.paykitPaymentRequest', + 'el.paykitReaderPayment.textContent', + 'createLatestRequestGate()', + 'paykitReaderStatusRequests.begin(workflowIncarnation)', + 'paykitReaderStatusRequests.isCurrent(request, workflowIncarnation)', + 'paykitReaderStatusRequests.invalidate()', + 'state.paykitReaderPrepared', + "state.verifierType === 'paykit-payment'", + '(!state.paykitReaderPrepared || !state.readerPublicKey)', + 'baselinePaymentRequestId: _baselinePaymentRequestId', 'toPlainJson', 'localStorage.setItem', ], @@ -155,6 +331,1111 @@ for (const [label, snippets] of Object.entries(required)) { } } +if (texts.startReaderServer.includes('8081')) { + throw new Error('reader demo server must use the canonical port 8088'); +} + +const readerElementMapStart = texts.readerApp.indexOf('const el = {'); +const readerElementMapEnd = texts.readerApp.indexOf('\n};', readerElementMapStart); +const readerElementMap = texts.readerApp.slice(readerElementMapStart, readerElementMapEnd); +const declaredReaderElements = new Set( + [...readerElementMap.matchAll(/^\s*([A-Za-z][A-Za-z0-9]*):/gm)].map((match) => match[1]), +); +const usedReaderElements = new Set( + [...texts.readerApp.matchAll(/\bel\.([A-Za-z][A-Za-z0-9]*)/g)].map((match) => match[1]), +); +const undeclaredReaderElements = [...usedReaderElements].filter((name) => !declaredReaderElements.has(name)); +if (undeclaredReaderElements.length > 0) { + throw new Error(`reader app uses undeclared DOM bindings: ${undeclaredReaderElements.join(', ')}`); +} + +const sessionFreeIndex = texts.paykitReaderLib.indexOf('session?.free();'); +const signerFreeIndex = texts.paykitReaderLib.indexOf('signer?.free();', sessionFreeIndex); +const keypairFreeIndex = texts.paykitReaderLib.indexOf('keypair.free();', signerFreeIndex); +if (sessionFreeIndex < 0 || signerFreeIndex < sessionFreeIndex || keypairFreeIndex < signerFreeIndex) { + throw new Error('Paykit reader registration must free session, signer, and keypair in reverse ownership order'); +} + +if (texts.authenticatePaykit.includes('Buffer.from(creatorSecret)')) { + throw new Error('authenticate-paykit must not create an untracked raw-secret Buffer copy'); +} +if (texts.readerApp.includes("readerPublicKey.addEventListener('input'")) { + throw new Error('reader payment identity must come from confirmed prepare status, not manual input'); +} +const clearPreparedOnViewerRotation = texts.createUser.indexOf( + "if (role === 'content-viewer') await clearPreparedReaderStatus();", +); +const generateReplacementViewer = texts.createUser.indexOf('Keypair.random()'); +if (clearPreparedOnViewerRotation < 0 || generateReplacementViewer < clearPreparedOnViewerRotation) { + throw new Error('content-viewer replacement must clear prepared Paykit reader evidence before key generation'); +} + +const demoAuthFinally = texts.startServer.indexOf('.finally(() => {'); +const clearSettledDemoAuthPromise = texts.startServer.indexOf('demoAuthPromise = null;', demoAuthFinally); +if (demoAuthFinally < 0 || clearSettledDemoAuthPromise < demoAuthFinally) { + throw new Error('settled demo auth flows must clear their pending promise'); +} + +const clearDemoSessionOnCreatorRotation = texts.createUser.indexOf( + "if (role === 'content-creator') await clearCreatorDemoSession();", +); +const generateReplacementCreator = texts.createUser.indexOf('Keypair.random()'); +const writeReplacementCreatorProfile = texts.createUser.indexOf('await writeJson(profileFile, profile);'); +const clearDemoSessionAfterCreatorRotation = texts.createUser.lastIndexOf( + "if (role === 'content-creator') await clearCreatorDemoSession();", +); +if ( + clearDemoSessionOnCreatorRotation < 0 + || generateReplacementCreator < clearDemoSessionOnCreatorRotation + || clearDemoSessionAfterCreatorRotation <= writeReplacementCreatorProfile +) { + throw new Error('content-creator replacement must clear stale demo auth before and after key generation'); +} + +const { + clearCreatorDemoSession, + readCreatorDemoSessionForCurrentRole, + writeCreatorDemoSessionForCurrentRole, +} = await import(pathToFileURL(files.creatorSessionState).href); +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: secondCreatorPubky })); + writeFileSync(creatorSessionTestPath, JSON.stringify({ role: 'content-creator', pubky: firstCreatorPubky, exported_session: 'old-secret' })); + assert.equal(await readCreatorDemoSessionForCurrentRole({ + sessionPath: creatorSessionTestPath, + profilePath: creatorProfileTestPath, + }), null); + assert.equal(existsSync(creatorSessionTestPath), false); + + await assert.rejects( + writeCreatorDemoSessionForCurrentRole( + { 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: secondCreatorPubky, exported_session: 'current-secret' }; + await writeCreatorDemoSessionForCurrentRole(currentSession, { + sessionPath: creatorSessionTestPath, + profilePath: creatorProfileTestPath, + }); + assert.deepEqual( + await readCreatorDemoSessionForCurrentRole({ + sessionPath: creatorSessionTestPath, + profilePath: creatorProfileTestPath, + }), + 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 }); +} + +const { buildCreatorLockPolicy } = await import(pathToFileURL(files.creatorPolicy).href); +assert.deepEqual( + buildCreatorLockPolicy({ criterionId: 'criterion-1', devStaticSatisfied: true }), + { + criteria: [{ + criterion_id: 'criterion-1', + verifier_type: 'dev-static', + params: { satisfied: true }, + }], + lockLogic: { type: 'all', criteria: ['criterion-1'] }, + }, +); +assert.deepEqual( + buildCreatorLockPolicy({ criterionId: 'criterion-2', devStaticSatisfied: false }), + { + criteria: [{ + criterion_id: 'criterion-2', + verifier_type: 'dev-static', + params: { satisfied: false }, + }], + lockLogic: { type: 'all', criteria: ['criterion-2'] }, + }, +); + +const creatorPubky = 'pubkytkrq8zmwb8a3m9k15csu3q17qmfgqnp9dskbrg9uq1rydpyxp7qy'; +const { enforceCreatorIdentityMatch } = await import(pathToFileURL(files.creatorIdentity).href); +let mismatchSignouts = 0; +await assert.rejects( + enforceCreatorIdentityMatch({ + session: { + creatorPubky: () => 'pubkyyyr1ttte48bcp4zjychjyscicrwi1j34mtt91ptsafdbjmr8g9eo', + signout: async () => { mismatchSignouts += 1; }, + }, + expectedCreatorPubky: creatorPubky, + }), + /does not match the demo creator/, +); +assert.equal(mismatchSignouts, 1); + +let matchingSignouts = 0; +await enforceCreatorIdentityMatch({ + session: { + creatorPubky: () => creatorPubky, + signout: async () => { matchingSignouts += 1; }, + }, + expectedCreatorPubky: creatorPubky, +}); +assert.equal(matchingSignouts, 0); + +const { invalidateIdentityScopedCreatorState } = await import(pathToFileURL(files.creatorIdentity).href); +const identityScopedState = { + feLockSessionToken: 'old-creator-session', + lockAuthenticated: true, + pendingConnectState: 'old-connect-state', + lockServerOrigin: 'https://locks.example', + lockAuthFrame: {}, +}; +let revokedSession; +const invalidation = await invalidateIdentityScopedCreatorState({ + state: identityScopedState, + revokeSession: async (secret) => { revokedSession = secret; }, +}); +assert.equal(revokedSession, 'old-creator-session'); +assert.equal(invalidation.revoked, true); +assert.deepEqual(identityScopedState, { + feLockSessionToken: null, + lockAuthenticated: false, + pendingConnectState: null, + lockServerOrigin: null, + lockAuthFrame: null, +}); + +const failedRevocationState = { + feLockSessionToken: 'unreachable-session', + lockAuthenticated: true, + pendingConnectState: null, + lockServerOrigin: null, + lockAuthFrame: null, +}; +const failedInvalidation = await invalidateIdentityScopedCreatorState({ + state: failedRevocationState, + revokeSession: async () => { throw new Error('network unavailable'); }, +}); +assert.equal(failedInvalidation.revoked, false); +assert.equal(failedRevocationState.feLockSessionToken, null); +assert.equal(failedRevocationState.lockAuthenticated, false); + +const completeCreatorConnectStart = texts.creator.indexOf('export async function completeCreatorConnect'); +const exchangeCreatorConnectStart = texts.creator.indexOf('export async function exchangeCreatorConnectCode'); +const completeCreatorConnectBody = texts.creator.slice(completeCreatorConnectStart, exchangeCreatorConnectStart); +if (!completeCreatorConnectBody.includes('expectedCreatorPubky') || !completeCreatorConnectBody.includes('expectedCreatorPubky,')) { + throw new Error('completeCreatorConnect must require and forward expectedCreatorPubky'); +} + +assert.deepEqual( + buildCreatorLockPolicy({ + lockType: 'paykit-payment', + criterionId: 'payment-1', + amountSats: '00018446744073709551616', + recipientPubky: creatorPubky, + paykitSetupComplete: true, + }), + { + criteria: [{ + criterion_id: 'payment-1', + verifier_type: 'paykit-payment', + params: { + recipient_pubky: creatorPubky, + amount: '00018446744073709551616', + asset: 'BTC', + }, + }], + lockLogic: { type: 'all', criteria: ['payment-1'] }, + }, +); + +for (const amountSats of ['', '0', '000', '-1', '1.5', '1e3', ' 1', '1 ']) { + assert.throws( + () => buildCreatorLockPolicy({ + lockType: 'paykit-payment', + criterionId: 'payment-1', + amountSats, + recipientPubky: creatorPubky, + paykitSetupComplete: true, + }), + /positive decimal integer string/, + ); +} +for (const amountSats of [1, null, undefined]) { + assert.throws( + () => buildCreatorLockPolicy({ + lockType: 'paykit-payment', + criterionId: 'payment-1', + amountSats, + recipientPubky: creatorPubky, + paykitSetupComplete: true, + }), + /positive decimal integer string/, + ); +} +assert.throws( + () => buildCreatorLockPolicy({ + lockType: 'paykit-payment', + criterionId: 'payment-1', + amountSats: '1', + recipientPubky: creatorPubky, + paykitSetupComplete: false, + }), + /complete Paykit setup/, +); +assert.throws( + () => buildCreatorLockPolicy({ + lockType: 'paykit-payment', + criterionId: 'payment-1', + amountSats: '1', + recipientPubky: '', + paykitSetupComplete: true, + }), + /authenticated creator/, +); + +const { buildPaykitSetupRequest, acceptPaykitSetupEvent } = await import( + pathToFileURL(files.paykitSetup).href +); +const setupRequest = buildPaykitSetupRequest({ + paykitUrl: 'http://localhost:3001', + returnTo: 'http://localhost:8080', + state: 'opaque-setup-state', + xpub: 'must-not-enter-the-iframe-url', +}); +const setupUrl = new URL(setupRequest.url); +assert.equal(setupRequest.origin, 'http://localhost:3001'); +assert.equal(setupUrl.origin, 'http://localhost:3001'); +assert.equal(setupUrl.pathname, '/setup'); +assert.deepEqual([...setupUrl.searchParams.entries()], [ + ['return_to', 'http://localhost:8080'], + ['state', 'opaque-setup-state'], +]); +assert.equal(setupRequest.url.includes('xpub'), false); +assert.equal(setupRequest.url.includes('must-not-enter'), false); +assert.throws(() => buildPaykitSetupRequest({ + paykitUrl: 'http://localhost:3001/setup?xpub=forbidden', + returnTo: 'http://localhost:8080', + state: 'opaque-setup-state', +})); + +const paykitFrameWindow = {}; +const acceptedEvent = { + origin: 'http://localhost:3001', + source: paykitFrameWindow, + data: { type: 'paykit-setup-callback', state: 'opaque-setup-state' }, +}; +const acceptanceContext = { + expectedOrigin: 'http://localhost:3001', + expectedSource: paykitFrameWindow, + expectedState: 'opaque-setup-state', + setupCreator: creatorPubky, + currentCreator: creatorPubky, +}; +assert.deepEqual( + acceptPaykitSetupEvent({ event: acceptedEvent, ...acceptanceContext }), + { status: 'complete' }, +); +assert.deepEqual( + acceptPaykitSetupEvent({ + event: { + ...acceptedEvent, + data: { type: 'paykit-setup-callback', state: 'opaque-setup-state', error: 'setup-failed' }, + }, + ...acceptanceContext, + }), + { status: 'error', error: 'setup-failed' }, +); +for (const event of [ + { ...acceptedEvent, origin: '*' }, + { ...acceptedEvent, origin: 'http://localhost:3002' }, + { ...acceptedEvent, source: {} }, + { ...acceptedEvent, data: { ...acceptedEvent.data, state: 'wrong-state' } }, + { ...acceptedEvent, data: { ...acceptedEvent.data, type: 'unrelated-message' } }, + { ...acceptedEvent, data: { ...acceptedEvent.data, xpub: 'forbidden' } }, + { ...acceptedEvent, data: null }, +]) { + assert.equal(acceptPaykitSetupEvent({ event, ...acceptanceContext }), null); +} +assert.equal( + acceptPaykitSetupEvent({ event: acceptedEvent, ...acceptanceContext, expectedOrigin: '*' }), + null, +); +assert.equal( + acceptPaykitSetupEvent({ event: acceptedEvent, ...acceptanceContext, currentCreator: 'different' }), + null, +); +assert.equal( + acceptPaykitSetupEvent({ event: acceptedEvent, ...acceptanceContext, expectedState: null }), + null, +); +assert.equal( + acceptPaykitSetupEvent({ + event: { ...acceptedEvent, source: null }, + ...acceptanceContext, + expectedSource: null, + }), + null, +); + +const { defaultDemoConfig, validateDemoConfig } = await import(pathToFileURL(files.configLib).href); +const validDemoConfig = structuredClone(defaultDemoConfig); +validDemoConfig.lockServer.pubky = creatorPubky; +assert.equal(validateDemoConfig(validDemoConfig), validDemoConfig); +for (const paykitUrl of [ + '', + 'ftp://localhost:3001', + 'http://user:pass@localhost:3001', + 'http://localhost:3001/setup', + 'http://localhost:3001?query=forbidden', +]) { + const invalidConfig = structuredClone(validDemoConfig); + invalidConfig.paykit.url = paykitUrl; + assert.throws(() => validateDemoConfig(invalidConfig)); +} +const missingPaykitConfig = structuredClone(validDemoConfig); +delete missingPaykitConfig.paykit; +assert.throws(() => validateDemoConfig(missingPaykitConfig)); + +for (const label of ['index', 'iframe']) { + const selectIndex = texts[label].indexOf('id="lock-type"'); + const devStaticIndex = texts[label].indexOf('', selectIndex); + const paymentIndex = texts[label].indexOf('', selectIndex); + if (selectIndex < 0 || devStaticIndex < selectIndex || paymentIndex < devStaticIndex) { + throw new Error(`${label} must default the lock-type selector to dev-static`); + } + if (/<(?:input|select|textarea)[^>]*(?:recipient|asset)/i.test(texts[label])) { + throw new Error(`${label} must not expose editable payment recipient or asset controls`); + } +} + +const publishCallIndex = texts.appIframe.indexOf('await publishLockedContent({'); +const publishCallEndIndex = texts.appIframe.indexOf('});', publishCallIndex); +const publishCall = texts.appIframe.slice(publishCallIndex, publishCallEndIndex); +for (const binding of ['criteria,', 'lockLogic,']) { + if (!publishCall.includes(binding)) { + throw new Error(`creator publishing must pass tested policy binding: ${binding}`); + } +} + +const authStatusRefreshIndex = texts.appIframe.indexOf('async function refreshDemoAuthStatus()'); +const identityResetIndex = texts.appIframe.indexOf( + 'state.paykitSetupComplete = false', + authStatusRefreshIndex, +); +const identityAssignmentIndex = texts.appIframe.indexOf( + 'state.creatorPubky = creatorPubky', + authStatusRefreshIndex, +); +if ( + authStatusRefreshIndex < 0 + || identityResetIndex < 0 + || identityAssignmentIndex < 0 + || identityResetIndex > identityAssignmentIndex +) { + throw new Error('creator identity changes must reset Paykit setup before replacing the identity'); +} + +const lockTypeRefreshIndex = texts.appIframe.indexOf('function refreshLockTypeFields()'); +const lockTypeRefreshEnd = texts.appIframe.indexOf('function startPaykitSetup()', lockTypeRefreshIndex); +const lockTypeRefresh = texts.appIframe.slice(lockTypeRefreshIndex, lockTypeRefreshEnd); +const setupStartIndex = lockTypeRefresh.indexOf('startPaykitSetup()'); +for (const guard of [ + 'if (!paymentSelected)', + 'if (state.paykitSetupComplete)', + 'if (!state.creatorPubky)', + 'if (state.paykitSetupFrame) return', +]) { + const guardIndex = lockTypeRefresh.indexOf(guard); + if (guardIndex < 0 || setupStartIndex < 0 || guardIndex > setupStartIndex) { + throw new Error(`Paykit setup must enforce ${guard} before starting`); + } +} + +const setupStartFunctionIndex = texts.appIframe.indexOf('function startPaykitSetup()'); +const setupStartFunctionEnd = texts.appIframe.indexOf('function refreshLockAuthStatus()', setupStartFunctionIndex); +const setupStartFunction = texts.appIframe.slice(setupStartFunctionIndex, setupStartFunctionEnd); +const setupOpenIndex = setupStartFunction.indexOf('openPaykitSetupIframe(request.url)'); +for (const guard of [ + "el.lockType.value !== 'paykit-payment'", + '|| !state.creatorPubky', + '|| state.paykitSetupComplete', + '|| state.paykitSetupFrame', +]) { + const guardIndex = setupStartFunction.indexOf(guard); + if (guardIndex < 0 || guardIndex > setupOpenIndex) { + throw new Error(`Paykit setup action must enforce ${guard} before iframe navigation`); + } +} +for (const binding of [ + 'state.pendingPaykitSetupState = pendingState', + 'state.paykitSetupOrigin = request.origin', + 'state.paykitSetupCreator = state.creatorPubky', +]) { + const bindingIndex = setupStartFunction.indexOf(binding); + if (bindingIndex < 0 || setupOpenIndex < 0 || bindingIndex > setupOpenIndex) { + throw new Error(`Paykit setup must bind ${binding} before iframe navigation`); + } +} + +const setupAcceptanceIndex = texts.appIframe.indexOf('const result = acceptPaykitSetupEvent({'); +const setupAcceptedGuardIndex = texts.appIframe.indexOf('if (!result) return;', setupAcceptanceIndex); +const setupCompleteIndex = texts.appIframe.indexOf('state.paykitSetupComplete = true', setupAcceptanceIndex); +if ( + setupAcceptanceIndex < 0 + || setupAcceptedGuardIndex < setupAcceptanceIndex + || setupCompleteIndex < setupAcceptedGuardIndex +) { + throw new Error('Paykit setup must mark completion only after exact callback acceptance'); +} + +const setupCloseIndex = texts.appIframe.indexOf('function closePaykitSetupIframe()'); +const setupCloseEnd = texts.appIframe.indexOf('function showLockAuthComplete()', setupCloseIndex); +const setupCloseFunction = texts.appIframe.slice(setupCloseIndex, setupCloseEnd); +for (const clearedBinding of [ + 'state.pendingPaykitSetupState = null', + 'state.paykitSetupOrigin = null', + 'state.paykitSetupFrame = null', + 'state.paykitSetupCreator = null', +]) { + if (!setupCloseFunction.includes(clearedBinding)) { + throw new Error(`closing Paykit setup must clear ${clearedBinding}`); + } +} + +for (const label of ['appIframe', 'index', 'iframe', 'paykitSetup']) { + if (/xpub/i.test(texts[label])) { + throw new Error(`${label} must not receive, render, store, or log xpub material`); + } +} +if (/localStorage\.(?:getItem|setItem)\([^)]*paykit/i.test(texts.appIframe)) { + throw new Error('Paykit setup state must remain in memory only'); +} +const setupCallbackEnd = texts.appIframe.indexOf('// Open the Lock Server /connect page', setupAcceptanceIndex); +const setupCallback = texts.appIframe.slice(setupAcceptanceIndex, setupCallbackEnd); +if (setupCallback.includes('postClientLog(') || setupStartFunction.includes('postClientLog(')) { + throw new Error('Paykit setup URL and callback data must not enter client logs'); +} + +for (const [label, snippets] of Object.entries({ + app: ['window.location.assign', 'completeCreatorConnect'], + appIframe: ['state.lastReceivedCode', 'feLockSessionToken: ${state.feLockSessionToken}', "'lock-auth-iframe-complete', { code }", 'xpub', 'account_xpub'], + readme: ['redirect to the Lock-Server-hosted `/connect` shell', 'stores the Locks frontend session in `localStorage`', 'verifier dropdown has one option'], + index: ['full-page redirect', 'Switch to iframe flow', 'id="paykit-recipient', 'id="paykit-asset', 'xpub', 'account_xpub'], + iframe: ['full-page redirect', 'Switch to redirect flow', 'id="paykit-recipient', 'id="paykit-asset', 'xpub', 'account_xpub'], + flows: ['Redirect flow', 'full-page redirect', 'localStorage'], +})) { + for (const snippet of snippets) { + if (texts[label].includes(snippet)) { + throw new Error(`${label} contains forbidden auth snippet: ${snippet}`); + } + } +} + +const stateGuardIndex = texts.creator.indexOf('if (state !== expectedState)'); +const codeExchangeIndex = texts.creator.indexOf('locks.exchangeFrontendSessionCode('); +if (stateGuardIndex < 0 || codeExchangeIndex < 0 || stateGuardIndex > codeExchangeIndex) { + throw new Error('creator must reject mismatched connect state before exchanging the one-time code'); +} + +const callbackExchangeIndex = texts.appIframe.indexOf('await exchangeCreatorConnectCode({'); +const callbackExchangeEndIndex = texts.appIframe.indexOf('});', callbackExchangeIndex); +const callbackExchangeCall = texts.appIframe.slice(callbackExchangeIndex, callbackExchangeEndIndex); +for (const binding of ['state: receivedState', 'expectedState: state.pendingConnectState']) { + if (!callbackExchangeCall.includes(binding)) { + throw new Error(`iframe callback exchange must include ${binding}`); + } +} +for (const guard of [ + 'event.origin !== state.lockServerOrigin', + 'event.source !== state.lockAuthFrame?.contentWindow', +]) { + const guardIndex = texts.appIframe.indexOf(guard); + if (guardIndex < 0 || callbackExchangeIndex < 0 || guardIndex > callbackExchangeIndex) { + throw new Error(`iframe callback must enforce ${guard} before exchanging the one-time code`); + } +} + +const { + buildPaykitPaymentProofBundle, + classifyPaymentLifecycle, + createLatestRequestGate, + decodeGuardedContentResponse, + parsePreparedReaderBrowserStatus, + selectCurrentPaykitPaymentRequest, + workflowHandleMatches, +} = await import(pathToFileURL(files.readerFlow).href); +const paymentResource = 'pubkytkrq8zmwb8a3m9k15csu3q17qmfgqnp9dskbrg9uq1rydpyxp7qy/pub/locks.app/000G40R40M30E209185GR38E1W8124GK2GAHC5RR34D1P70X3RFG.json'; +const paymentBundleId = '000G40R40M30E209185GR38E1W'; +const readerPublicKey = 'pubky7ir1ttte48bcp4zjychjyscicrwi1j34mtt91ptsafdbjmr8g9eo'; +assert.deepEqual(buildPaykitPaymentProofBundle({ + resource: paymentResource, + readerPublicKey, + criterionId: 'payment-criterion', + bundleId: paymentBundleId, +}), { + version: 1, + bundle_id: paymentBundleId, + pubky_lock_resource: paymentResource, + reader_public_key: readerPublicKey, + proofs: [{ + criterion_id: 'payment-criterion', + verifier_type: 'paykit-payment', + payload: {}, + }], +}); +assert.equal(classifyPaymentLifecycle({ status: 'pending' }), 'retry'); +assert.equal(classifyPaymentLifecycle({ status: 'in_progress' }), 'retry'); +assert.equal(classifyPaymentLifecycle({ status: 'completed' }), 'completed'); +assert.equal(classifyPaymentLifecycle({ status: 'failed' }), 'failed'); +assert.equal(classifyPaymentLifecycle({ status: 'expired' }), 'failed'); +assert.throws(() => classifyPaymentLifecycle({ status: 'unknown' }), /unknown lifecycle status/); +const workflowHandle = { incarnation: 7, resource: paymentResource, creator: creatorPubky, bundleId: paymentBundleId }; +const currentWorkflow = { ...workflowHandle }; +assert.equal(workflowHandleMatches(workflowHandle, currentWorkflow), true); +for (const staleWorkflow of [ + { ...currentWorkflow, incarnation: 8 }, + { ...currentWorkflow, resource: `${paymentResource}.changed` }, + { ...currentWorkflow, creator: readerPublicKey }, + { ...currentWorkflow, bundleId: `${paymentBundleId}0` }, +]) { + assert.equal(workflowHandleMatches(workflowHandle, staleWorkflow), false); +} +assert.equal( + workflowHandleMatches( + { incarnation: 7, resource: paymentResource }, + currentWorkflow, + ), + true, +); +const latestRequestGate = createLatestRequestGate(); +const olderStatusRequest = latestRequestGate.begin(7); +const newerStatusRequest = latestRequestGate.begin(7); +assert.equal(latestRequestGate.isCurrent(olderStatusRequest, 7), false); +assert.equal(latestRequestGate.isCurrent(newerStatusRequest, 7), true); +assert.equal(latestRequestGate.isCurrent(newerStatusRequest, 8), false); +latestRequestGate.finish(newerStatusRequest); +assert.equal(latestRequestGate.isCurrent(newerStatusRequest, 7), false); +const invalidatedStatusRequest = latestRequestGate.begin(8); +latestRequestGate.invalidate(); +assert.equal(latestRequestGate.isCurrent(invalidatedStatusRequest, 8), false); +const priorPaymentRequest = { + state: 'request_received', + payment_request_id: '12345678-1234-4123-8123-123456789abc', +}; +const newerPaymentRequest = { + state: 'request_received', + payment_request_id: 'abcdef12-3456-4789-8123-123456789abc', +}; +assert.equal(selectCurrentPaykitPaymentRequest({ + status: priorPaymentRequest, + baselinePaymentRequestId: priorPaymentRequest.payment_request_id, + currentPaymentRequest: null, +}), null); +assert.equal(selectCurrentPaykitPaymentRequest({ + status: priorPaymentRequest, + baselinePaymentRequestId: priorPaymentRequest.payment_request_id, + currentPaymentRequest: newerPaymentRequest, +}), newerPaymentRequest); +assert.equal(selectCurrentPaykitPaymentRequest({ + status: newerPaymentRequest, + baselinePaymentRequestId: priorPaymentRequest.payment_request_id, + currentPaymentRequest: null, +}), newerPaymentRequest); +assert.equal(selectCurrentPaykitPaymentRequest({ + status: newerPaymentRequest, + baselinePaymentRequestId: priorPaymentRequest.payment_request_id, + currentPaymentRequest: newerPaymentRequest, +}), newerPaymentRequest); +assert.deepEqual(parsePreparedReaderBrowserStatus({ + version: 1, + prepared: true, + reader_pubky: readerPublicKey, +}), { + version: 1, + prepared: true, + reader_pubky: readerPublicKey, +}); +assert.deepEqual(parsePreparedReaderBrowserStatus({ version: 1, prepared: false }), { + version: 1, + prepared: false, +}); +for (const invalidStatus of [ + { version: 1, prepared: true }, + { version: 1, prepared: false, reader_pubky: readerPublicKey }, + { version: 2, prepared: false }, + { version: 1, prepared: true, reader_pubky: `${readerPublicKey}x` }, +]) { + assert.throws(() => parsePreparedReaderBrowserStatus(invalidStatus), /invalid prepared Paykit reader status/); +} +const guardedResponse = new Response(new TextEncoder().encode('payment unlocked'), { + headers: { 'content-type': 'text/plain; charset=utf-8' }, +}); +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, + parseReaderHelperSuccess, + requireReaderEnvironment, + runReaderOperation, + signupReaderBestEffort, +} = await import(pathToFileURL(files.paykitReaderLib).href); +const { + buildPreparedReaderBrowserStatus, + readPreparedReaderStatus, + validatePreparedReaderStatus, + writePreparedReaderStatus, +} = await import(pathToFileURL(files.paykitReaderStatus).href); +const { main: preparePaykitReaderMain } = await import(pathToFileURL(files.preparePaykitReader).href); +const readerSecret = Uint8Array.from({ length: 32 }, (_, index) => index + 1); +const prepareInput = buildReaderHelperInput({ operation: 'prepare', readerSecret }); +assert.deepEqual(Object.keys(prepareInput), ['version', 'operation', 'reader_secret']); +assert.equal(prepareInput.version, 1); +assert.equal(prepareInput.operation, 'prepare'); +assert.equal(Buffer.from(prepareInput.reader_secret, 'base64url').length, 32); +assert.deepEqual(parseReaderHelperSuccess({ + operation: 'prepare', + stdout: '{"version":1,"status":"prepared","reader_pubky":"pubky7ir1ttte48bcp4zjychjyscicrwi1j34mtt91ptsafdbjmr8g9eo","receiver_path":"bitkit/wallet"}\n', +}), { + version: 1, + status: 'prepared', + reader_pubky: readerPublicKey, + receiver_path: 'bitkit/wallet', +}); +const receivedOutput = { + version: 1, + status: 'received', + payment_request_id: 'b7f9c2a1-6d43-4b0e-a8d4-0fe2c712ab33', + address: 'bcrt1qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqdku202', + 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, + 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', + stdout: `${JSON.stringify(receivedOutput)}\n`, +}), 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, + payment_command: receivedOutput.payment_command.replace(receivedOutput.address, `${receivedOutput.address.slice(0, -1)}3`), + })}\n`, + `${JSON.stringify({ + ...receivedOutput, + payment_command: receivedOutput.payment_command.replace('0.00050000', '0.00050001'), + })}\n`, + `${JSON.stringify({ + ...receivedOutput, + payment_command: receivedOutput.payment_command.replace(receivedOutput.address, `${receivedOutput.address}\";echo unsafe`), + })}\n`, + `${JSON.stringify({ ...receivedOutput, amount_sats: '0' })}\n`, +]) { + assert.throws(() => parseReaderHelperSuccess({ operation: 'receive', stdout: invalid }), /invalid reader helper output/); +} + +const { + buildCompanionHelperInput, + companionResultCategory, + collectPaykitInputs, + parsePaykitInputLines, + requirePaykitCreatorRole, + resolveCompanionHelperPath, + runCompanionHelper, +} = await import(pathToFileURL(files.authenticatePaykit).href); +const { + Keypair, + loadRoleKeypair, + loadRoleSecret, + secretFromRecoveryFile, +} = await import(pathToFileURL(files.pubkyLib).href); + +const missingRoleFile = async () => { + const error = new Error('missing role file'); + error.code = 'ENOENT'; + throw error; +}; +for (const loadIdentity of [loadRoleKeypair, loadRoleSecret]) { + await assert.rejects( + loadIdentity('content-creator', { readFile: missingRoleFile }), + /missing local identity for content-creator; run `npm --prefix examples\/js-sdk run create-user -- --role content-creator` before authentication/, + ); +} + +const authUrl = 'pubkyauth://signin?secret=test-auth-secret'; +const accountXpub = 'tpub-test-account-xpub'; +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 [ + `${authUrl}\n${accountXpub}`, + `${authUrl}\n${accountXpub}\n7\nextra`, + `${authUrl}\n\n7`, +]) { + assert.throws(() => parsePaykitInputLines(invalid), /three ordered lines/); +} + +const prompts = []; +const answers = [authUrl, accountXpub, '7']; +assert.deepEqual( + await collectPaykitInputs({ + isTTY: true, + question: async (prompt) => { + prompts.push(prompt); + return answers.shift(); + }, + }), + parsedLines, +); +const promptText = prompts.join('\n'); +for (const sensitive of [authUrl, accountXpub, 'test-auth-secret']) { + assert.equal(promptText.includes(sensitive), false); +} + +const expectedSecret = Uint8Array.from({ length: 32 }, (_, index) => index + 1); +const recoveryPassphrase = 'test-only-recovery-passphrase'; +const recoveryKeypair = Keypair.fromSecret(expectedSecret); +const recoveryFile = recoveryKeypair.createRecoveryFile(recoveryPassphrase); +recoveryKeypair.free(); +assert.deepEqual(secretFromRecoveryFile(recoveryFile, recoveryPassphrase), expectedSecret); + +const helperInput = buildCompanionHelperInput({ ...parsedLines, creatorSecret: expectedSecret }); +assert.deepEqual(Object.keys(helperInput), [ + 'version', 'auth_url', 'creator_secret', 'account_xpub', 'account_index', +]); +assert.equal(helperInput.version, 1); +assert.equal(Buffer.from(helperInput.creator_secret, 'base64url').length, 32); +expectedSecret[0] = 255; +assert.equal(Buffer.from(helperInput.creator_secret, 'base64url')[0], 1); +expectedSecret[0] = 1; +assert.equal(requirePaykitCreatorRole('content-creator'), 'content-creator'); +for (const role of ['content-viewer', 'lock-server', undefined]) { + assert.throws(() => requirePaykitCreatorRole(role), /requires --role content-creator/); +} +assert.deepEqual(companionResultCategory({ status: 'approved' }), { + exitCode: 0, + stream: 'stdout', + message: 'Paykit companion authentication approved.', +}); +assert.deepEqual(companionResultCategory({ status: 'failed' }), { + exitCode: 1, + stream: 'stderr', + message: 'Paykit companion authentication failed.', +}); +assert.deepEqual(companionResultCategory({ status: 'timeout' }), { + exitCode: 1, + stream: 'stderr', + message: 'Paykit companion authentication timed out.', +}); +assert.throws( + () => parseReaderHelperSuccess({ + operation: 'prepare', + stdout: `${JSON.stringify({ + version: 1, + status: 'prepared', + reader_pubky: readerPublicKey, + receiver_path: 'bitkit/wallet', + extra: true, + })}\n`, + }), + /invalid reader helper output/, +); + +const registrationCleanup = []; +await signupReaderBestEffort({ + readConfig: async () => ({}), + normalizeConfig: (config) => config, + loadKeypair: async (role) => { + assert.equal(role, 'content-viewer'); + return { free: () => registrationCleanup.push('keypair') }; + }, + pubkyFactory: () => ({ signer: () => ({ free: () => registrationCleanup.push('signer') }) }), + signup: async () => ({ free: () => registrationCleanup.push('session') }), + getHomeserverPublicKey: () => 'homeserver', +}); +assert.deepEqual(registrationCleanup, ['session', 'signer', 'keypair']); + +const failedRegistrationCleanup = []; +await assert.rejects( + signupReaderBestEffort({ + readConfig: async () => ({}), + normalizeConfig: (config) => config, + loadKeypair: async () => ({ free: () => failedRegistrationCleanup.push('keypair') }), + pubkyFactory: () => ({ signer: () => ({ free: () => failedRegistrationCleanup.push('signer') }) }), + signup: async () => { throw new Error('registration failed'); }, + getHomeserverPublicKey: () => 'homeserver', + }), + /registration failed/, +); +assert.deepEqual(failedRegistrationCleanup, ['signer', 'keypair']); + +const helperDir = mkdtempSync(join(tmpdir(), 'locks-paykit-helper-')); +try { + const preparedStatus = { + version: 1, + status: 'prepared', + reader_pubky: readerPublicKey, + receiver_path: 'bitkit/wallet', + }; + assert.deepEqual(validatePreparedReaderStatus(preparedStatus), preparedStatus); + const preparedStatusPath = join(helperDir, 'prepared.v1.json'); + await writePreparedReaderStatus(preparedStatus, preparedStatusPath); + assert.deepEqual(await readPreparedReaderStatus(preparedStatusPath), preparedStatus); + assert.equal(statSync(preparedStatusPath).mode & 0o777, 0o600); + assert.deepEqual( + buildPreparedReaderBrowserStatus(preparedStatus, { role: 'content-viewer', pubky: readerPublicKey }), + { version: 1, prepared: true, reader_pubky: readerPublicKey }, + ); + assert.deepEqual( + buildPreparedReaderBrowserStatus(preparedStatus, { role: 'content-viewer', pubky: creatorPubky }), + { version: 1, prepared: false }, + ); + chmodSync(preparedStatusPath, 0o644); + assert.equal(await readPreparedReaderStatus(preparedStatusPath), null); + + const prepareOrder = []; + assert.equal(await preparePaykitReaderMain({ + clearStatus: async () => prepareOrder.push('clear'), + runOperation: async () => ({ status: 'success', value: preparedStatus }), + writeStatus: async (value) => { + assert.deepEqual(value, preparedStatus); + prepareOrder.push('write'); + }, + printSuccess: () => prepareOrder.push('print'), + }), 0); + assert.deepEqual(prepareOrder, ['clear', 'write', 'print']); + let wroteFailedPrepare = false; + let clearedFailedPrepare = false; + assert.equal(await preparePaykitReaderMain({ + clearStatus: async () => { clearedFailedPrepare = true; }, + runOperation: async () => ({ status: 'failed', error: 'protocol_failed' }), + writeStatus: async () => { wroteFailedPrepare = true; }, + printError: () => {}, + }), 1); + assert.equal(clearedFailedPrepare, true); + assert.equal(wroteFailedPrepare, false); + + const readerEnv = { + PATH: process.env.PATH, + PAYKIT_READER_STATE_PATH: join(helperDir, '.local', 'paykit-reader', 'state.v1'), + PAYKIT_READER_PUBKY_TESTNET_HOST: 'pubky-testnet', + PAYKIT_READER_RECEIVER_PATH: 'bitkit/wallet', + PAYKIT_READER_SERVER_PUBKY: readerPublicKey, + PAYKIT_READER_SERVER_PATH: 'bitkit/server', + }; + assert.equal(requireReaderEnvironment(readerEnv), undefined); + assert.throws( + () => requireReaderEnvironment({ ...readerEnv, PAYKIT_READER_STATE_PATH: '/tmp/state.v1' }), + /state path/, + ); + const preparedHelper = join(helperDir, 'prepared-reader-helper'); + writeFileSync(preparedHelper, `#!/usr/bin/env node +let body = ''; +for await (const chunk of process.stdin) body += chunk; +const value = JSON.parse(body); +const keys = ['version','operation','reader_secret']; +if (process.argv.length !== 2 || JSON.stringify(Object.keys(value)) !== JSON.stringify(keys)) process.exit(21); +if (value.version !== 1 || value.operation !== 'prepare' || Buffer.from(value.reader_secret, 'base64url').length !== 32) process.exit(22); +process.stdout.write('{"version":1,"status":"prepared","reader_pubky":"${readerPublicKey}","receiver_path":"bitkit/wallet"}\\n'); +`); + chmodSync(preparedHelper, 0o700); + const readerSecretForRun = Uint8Array.from({ length: 32 }, (_, index) => index + 1); + let readerRegistered = false; + assert.deepEqual(await runReaderOperation({ + operation: 'prepare', + helperPath: preparedHelper, + env: readerEnv, + readerSecret: readerSecretForRun, + ensureRegistered: async () => { readerRegistered = true; }, + }), { + status: 'success', + value: { + version: 1, + status: 'prepared', + reader_pubky: readerPublicKey, + receiver_path: 'bitkit/wallet', + }, + }); + assert.equal(readerRegistered, true); + assert.deepEqual(readerSecretForRun, new Uint8Array(32)); + + const readerFailureHelper = join(helperDir, 'failed-reader-helper'); + writeFileSync(readerFailureHelper, `#!/usr/bin/env node +for await (const _chunk of process.stdin) {} +process.stderr.write('{"version":1,"error":"invalid_state"}\\n'); +process.exit(1); +`); + chmodSync(readerFailureHelper, 0o700); + assert.deepEqual(await runReaderOperation({ + operation: 'receive', + helperPath: readerFailureHelper, + env: readerEnv, + readerSecret: Uint8Array.from({ length: 32 }, (_, index) => index + 1), + }), { status: 'failed', error: 'invalid_state' }); + + assert.deepEqual( + await runCompanionHelper({ helperPath: 'invalid\0helper', input: helperInput }), + { status: 'failed' }, + ); + assert.deepEqual( + await runCompanionHelper({ helperPath: join(helperDir, 'missing-helper'), input: helperInput }), + { status: 'failed' }, + ); + + const observedSignals = []; + class ErrorDuringTerminationChild extends EventEmitter { + constructor() { + super(); + this.stdin = new PassThrough(); + this.stdout = new PassThrough(); + this.stderr = new PassThrough(); + queueMicrotask(() => this.emit('spawn')); + } + + kill(signal) { + observedSignals.push(signal); + if (signal === 'SIGTERM') queueMicrotask(() => this.emit('error', new Error('test kill error'))); + if (signal === 'SIGKILL') queueMicrotask(() => this.emit('close', null, 'SIGKILL')); + return false; + } + + unref() {} + } + assert.deepEqual( + await runCompanionHelper({ + helperPath: 'injected-helper', + input: helperInput, + timeoutMs: 10, + killGraceMs: 10, + spawnProcess: () => new ErrorDuringTerminationChild(), + }), + { status: 'timeout' }, + ); + assert.deepEqual(observedSignals, ['SIGTERM', 'SIGKILL']); + + const approvedHelper = join(helperDir, 'approved-helper'); + writeFileSync(approvedHelper, `#!/usr/bin/env node +let body = ''; +for await (const chunk of process.stdin) body += chunk; +const value = JSON.parse(body); +const keys = ['version','auth_url','creator_secret','account_xpub','account_index']; +if (process.argv.length !== 2 || JSON.stringify(Object.keys(value)) !== JSON.stringify(keys)) process.exit(21); +if (value.version !== 1 || value.auth_url !== ${JSON.stringify(authUrl)} || value.account_xpub !== ${JSON.stringify(accountXpub)} || value.account_index !== 7) process.exit(22); +if (Buffer.from(value.creator_secret, 'base64url').length !== 32) process.exit(23); +process.stdout.write('{"version":1,"status":"approved"}\\n'); +`); + chmodSync(approvedHelper, 0o700); + assert.deepEqual( + await runCompanionHelper({ helperPath: approvedHelper, input: helperInput }), + { status: 'approved' }, + ); + + const failedHelper = join(helperDir, 'failed-helper'); + writeFileSync(failedHelper, `#!/usr/bin/env node +let body = ''; +for await (const chunk of process.stdin) body += chunk; +const value = JSON.parse(body); +process.stderr.write(value.auth_url + value.account_xpub + value.creator_secret); +process.exit(1); +`); + chmodSync(failedHelper, 0o700); + const failed = await runCompanionHelper({ helperPath: failedHelper, input: helperInput }); + assert.deepEqual(failed, { status: 'failed' }); + for (const sensitive of [authUrl, accountXpub, helperInput.creator_secret]) { + assert.equal(JSON.stringify(failed).includes(sensitive), false); + } + + const hangingHelper = join(helperDir, 'hanging-helper'); + writeFileSync(hangingHelper, `#!/usr/bin/env node +process.on('SIGTERM', () => {}); +setInterval(() => {}, 1000); +`); + chmodSync(hangingHelper, 0o700); + assert.deepEqual( + await runCompanionHelper({ + helperPath: hangingHelper, + input: helperInput, + timeoutMs: 25, + killGraceMs: 25, + }), + { status: 'timeout' }, + ); + + const floodingHelper = join(helperDir, 'flooding-helper'); + writeFileSync(floodingHelper, `#!/usr/bin/env node +process.on('SIGTERM', () => {}); +process.stdout.write('x'.repeat(8192)); +setInterval(() => {}, 1000); +`); + chmodSync(floodingHelper, 0o700); + assert.deepEqual( + await runCompanionHelper({ + helperPath: floodingHelper, + input: helperInput, + timeoutMs: 1000, + killGraceMs: 25, + }), + { status: 'failed' }, + ); +} finally { + rmSync(helperDir, { recursive: true, force: true }); +} + const { parseLockServerTomlPublicKey } = await import(pathToFileURL(files.configLib).href); const lockServerPubky = 'pubky7ir1ttte48bcp4zjychjyscicrwi1j34mtt91ptsafdbjmr8g9eo'; assert.equal( diff --git a/locks-sdk/bindings/js/scripts/smoke-generated-api.mjs b/locks-sdk/bindings/js/scripts/smoke-generated-api.mjs index dec49c6..f465e4b 100644 --- a/locks-sdk/bindings/js/scripts/smoke-generated-api.mjs +++ b/locks-sdk/bindings/js/scripts/smoke-generated-api.mjs @@ -60,6 +60,7 @@ const requiredSnippets = [ 'lookupVerificationTask(options: VerificationTaskHandleOptions): Promise;', 'issueAccessCredential(options: VerificationTaskHandleOptions): Promise;', 'proxyReadGuardedResource(access_credential: string, path: string): Promise;', + 'proxyReadGuardedResourceResponse(access_credential: string, path: string): Promise;', 'export class VerificationTaskHandleOptions', 'constructor(creator: string, bundle_id: string);', 'export class Session', @@ -71,6 +72,7 @@ const requiredSnippets = [ 'createContentLock(body: any): Promise;', 'deleteGuardedResource(options: DeleteGuardedResourceOptions): Promise;', 'setLockServicePointer(options: SetLockServicePointerOptions): Promise;', + 'paykitSetupStatus(): Promise;', 'export class CreateContentLockRequestBuilder', 'primaryResource(resource: any): CreateContentLockRequestBuilder;', 'secondaryResource(resource: any): CreateContentLockRequestBuilder;', @@ -94,6 +96,13 @@ for (const snippet of requiredSnippets) { const sdk = await import(pathToFileURL(jsPath)); await sdk.default(await readFile(wasmPath)); +if (typeof sdk.Creator.prototype.paykitSetupStatus !== 'function') { + throw new Error('generated Creator missing paykitSetupStatus'); +} +if (sdk.Creator.prototype.paykitSetupStatus.length !== 0) { + throw new Error('paykitSetupStatus must not accept a caller-supplied Creator'); +} + const primaryResource = { path: '/priv/locks.app/content/primary.txt', hash: '0W3GE1R70W3GE1R70W3GE1R70W3GE1R70W3GE1R70W3GE1R70W3G', diff --git a/locks-sdk/bindings/js/src/creator.rs b/locks-sdk/bindings/js/src/creator.rs index be56bae..9a0b0bf 100644 --- a/locks-sdk/bindings/js/src/creator.rs +++ b/locks-sdk/bindings/js/src/creator.rs @@ -373,6 +373,25 @@ impl Creator { .map_err(|err| crate::js_error::invalid_input(err.to_string()))?; fetch_authorized_empty(&request).await } + + #[cfg(target_arch = "wasm32")] + #[wasm_bindgen(js_name = paykitSetupStatus)] + pub async fn paykit_setup_status(&self) -> crate::js_error::JsResult { + let resolver = BrowserPkarrResolver::new_with_options(self.session.options()) + .map_err(|err| crate::js_error::invalid_input(err.to_string()))?; + let request = self + .build_paykit_setup_status_request() + .prepare_with_pkarr_resolver(&resolver, None) + .await + .map_err(|err| crate::js_error::invalid_input(err.to_string()))?; + let value = fetch_authorized_json(&request).await?; + let value = serde_wasm_bindgen::from_value(value) + .map_err(|_| crate::js_error::invalid_input("invalid paykit setup status response"))?; + let validated = validate_paykit_setup_status_response_for_tests(value) + .map_err(crate::js_error::invalid_input)?; + to_plain_js_value(&validated) + .map_err(|_| crate::js_error::invalid_input("invalid paykit setup status response")) + } } impl Creator { @@ -426,6 +445,12 @@ impl Creator { Ok(self.authorized_request_plan(request)) } + #[cfg(any(test, target_arch = "wasm32"))] + #[cfg_attr(target_arch = "wasm32", allow(dead_code))] + pub(crate) fn build_paykit_setup_status_request(&self) -> JsAuthorizedRequestPlan { + self.authorized_request_plan(self.session.inner().creator().paykit_setup_status()) + } + #[cfg(any(test, target_arch = "wasm32"))] #[cfg_attr(target_arch = "wasm32", allow(dead_code))] pub(crate) fn create_build_content_lock_request( @@ -454,11 +479,21 @@ impl Creator { body: match request.body { locks_sdk::SdkRequestBody::Bytes(bytes) => JsRequestBody::Bytes(bytes), locks_sdk::SdkRequestBody::Json(body) => JsRequestBody::Json(body), + locks_sdk::SdkRequestBody::Empty => JsRequestBody::Empty, }, } } } +#[cfg(any(test, target_arch = "wasm32"))] +fn validate_paykit_setup_status_response_for_tests( + value: serde_json::Value, +) -> Result { + let status = locks_sdk::CreatorLocks::parse_paykit_setup_status_response(value) + .map_err(|_| "invalid paykit setup status response".to_owned())?; + serde_json::to_value(status).map_err(|_| "invalid paykit setup status response".to_owned()) +} + #[cfg(test)] mod tests { use super::*; @@ -696,6 +731,40 @@ mod tests { assert!(body.get("creator").is_none()); } + #[test] + fn paykit_setup_status_request_uses_current_session_without_creator_argument() { + let creator = Creator::new(test_session()); + + let request = creator.build_paykit_setup_status_request(); + + assert_eq!(request.method, "GET"); + assert_eq!(request.path, "/creator/paykit/setup-status"); + assert_eq!(request.authorization, "Bearer frontend-session-secret"); + assert_eq!(request.content_type, None); + assert_eq!(request.body, JsRequestBody::Empty); + } + + #[test] + fn paykit_setup_status_response_projects_closed_plain_status() { + for status in ["ready", "setup_required", "unavailable"] { + assert_eq!( + validate_paykit_setup_status_response_for_tests(serde_json::json!({ + "status": status + })) + .unwrap(), + serde_json::json!({ "status": status }) + ); + } + + for invalid in [ + serde_json::json!({ "status": "future" }), + serde_json::json!({ "status": "ready", "extra": true }), + serde_json::json!({}), + ] { + assert!(validate_paykit_setup_status_response_for_tests(invalid).is_err()); + } + } + #[test] fn create_content_lock_request_body_omits_creator() { let creator = Creator::new(test_session()); diff --git a/locks-sdk/bindings/js/src/locks.rs b/locks-sdk/bindings/js/src/locks.rs index 3788884..4c3f7b2 100644 --- a/locks-sdk/bindings/js/src/locks.rs +++ b/locks-sdk/bindings/js/src/locks.rs @@ -343,11 +343,12 @@ impl Locks { .prepare_exchange_request_with_pkarr_resolver(&request, &resolver, None) .await .map_err(|err| invalid_input(err.to_string()))?; - let token = post_json_for_session_token(&request).await?; - Ok(Session::new( - self.inner.restore_session(token), + let response = post_json_for_session(&request).await?; + Ok(Session::new_with_creator( + self.inner.restore_session(&response.session_token), self.inner.clone(), self.options.clone(), + Some(response.creator.to_string()), )) } } @@ -684,7 +685,9 @@ async fn fetch_content_lock_json(request_plan: &JsPreparedContentLockRequest) -> } #[cfg(target_arch = "wasm32")] -async fn post_json_for_session_token(request_plan: &JsPreparedRequest) -> JsResult { +async fn post_json_for_session( + request_plan: &JsPreparedRequest, +) -> JsResult { let request_init = web_sys::RequestInit::new(); request_init.set_method(request_plan.method); request_init.set_mode(web_sys::RequestMode::Cors); @@ -725,17 +728,68 @@ async fn post_json_for_session_token(request_plan: &JsPreparedRequest) -> JsResu .map_err(|err| invalid_input(format!("failed to parse JSON response: {err:?}")))?; let value: Value = serde_wasm_bindgen::from_value(json) .map_err(|err| invalid_input(format!("invalid session response JSON: {err}")))?; - value + parse_frontend_session_response(value).map_err(invalid_input) +} + +#[cfg(any(test, target_arch = "wasm32"))] +#[derive(Debug, Clone, PartialEq, Eq)] +struct FrontendSessionResponse { + session_token: String, + creator: CreatorPubky, +} + +#[cfg(any(test, target_arch = "wasm32"))] +fn parse_frontend_session_response(value: Value) -> Result { + let session_token = value .get("session_token") .and_then(Value::as_str) .map(ToOwned::to_owned) - .ok_or_else(|| invalid_input("frontend session response missing session_token")) + .ok_or_else(|| "frontend session response missing session_token".to_owned())?; + let creator = value + .get("creator") + .and_then(Value::as_str) + .ok_or_else(|| "frontend session response missing creator".to_owned()) + .and_then(|creator| { + CreatorPubky::from_str(creator) + .map_err(|_| "frontend session response contains invalid creator".to_owned()) + })?; + Ok(FrontendSessionResponse { + session_token, + creator, + }) } #[cfg(test)] mod tests { use super::*; + #[test] + fn frontend_session_response_preserves_authenticated_creator() { + let response = parse_frontend_session_response(serde_json::json!({ + "session_token": "frontend-session-secret", + "creator": "pubkytkrq8zmwb8a3m9k15csu3q17qmfgqnp9dskbrg9uq1rydpyxp7qy", + "expires_at": "2030-01-01T00:00:00Z" + })) + .unwrap(); + + assert_eq!(response.session_token, "frontend-session-secret"); + assert_eq!( + response.creator.to_string(), + "pubkytkrq8zmwb8a3m9k15csu3q17qmfgqnp9dskbrg9uq1rydpyxp7qy" + ); + } + + #[test] + fn frontend_session_response_rejects_missing_creator() { + let error = parse_frontend_session_response(serde_json::json!({ + "session_token": "frontend-session-secret", + "expires_at": "2030-01-01T00:00:00Z" + })) + .unwrap_err(); + + assert!(error.contains("missing creator")); + } + #[test] fn locks_constructor_is_available_for_valid_lock_server_pubky() { let locks = diff --git a/locks-sdk/bindings/js/src/session.rs b/locks-sdk/bindings/js/src/session.rs index ac68fb7..575adad 100644 --- a/locks-sdk/bindings/js/src/session.rs +++ b/locks-sdk/bindings/js/src/session.rs @@ -240,6 +240,7 @@ impl JsAuthorizedRequestPlan { pub struct Session { inner: locks_sdk::LocksSession, client: locks_sdk::LocksClient, + creator_pubky: Option, #[cfg_attr(not(any(test, target_arch = "wasm32")), allow(dead_code))] options: LocksOptions, } @@ -256,6 +257,11 @@ impl Session { self.client.lock_server().to_string() } + #[wasm_bindgen(js_name = creatorPubky)] + pub fn creator_pubky(&self) -> Option { + self.creator_pubky.clone() + } + #[wasm_bindgen(getter)] pub fn creator(&self) -> Creator { Creator::new(self.clone()) @@ -279,10 +285,20 @@ impl Session { inner: locks_sdk::LocksSession, client: locks_sdk::LocksClient, options: LocksOptions, + ) -> Self { + Self::new_with_creator(inner, client, options, None) + } + + pub(crate) fn new_with_creator( + inner: locks_sdk::LocksSession, + client: locks_sdk::LocksClient, + options: LocksOptions, + creator_pubky: Option, ) -> Self { Self { inner, client, + creator_pubky, options, } } @@ -510,6 +526,20 @@ mod tests { ); } + #[test] + fn exchanged_session_exposes_authenticated_creator_pubky() { + let client = test_client(); + let creator = "pubkytkrq8zmwb8a3m9k15csu3q17qmfgqnp9dskbrg9uq1rydpyxp7qy"; + let session = Session::new_with_creator( + client.restore_session("frontend-session-secret"), + client, + LocksOptions::new(), + Some(creator.to_owned()), + ); + + assert_eq!(session.creator_pubky(), Some(creator.to_owned())); + } + #[test] fn signout_request_uses_current_frontend_session_endpoint_and_bearer() { let client = test_client(); diff --git a/locks-sdk/bindings/js/src/viewer.rs b/locks-sdk/bindings/js/src/viewer.rs index e7afb7d..a9cab03 100644 --- a/locks-sdk/bindings/js/src/viewer.rs +++ b/locks-sdk/bindings/js/src/viewer.rs @@ -210,6 +210,23 @@ impl Viewer { .map_err(|err| invalid_input(err.to_string()))?; fetch_viewer_bytes(&request).await } + + #[cfg(target_arch = "wasm32")] + #[wasm_bindgen(js_name = proxyReadGuardedResourceResponse)] + pub async fn proxy_read_guarded_resource_response( + &self, + access_credential: &str, + path: String, + ) -> JsResult { + let resolver = BrowserPkarrResolver::new_with_options(&self.options) + .map_err(|err| invalid_input(err.to_string()))?; + let request = self + .build_proxy_read_guarded_resource_request(access_credential, path) + .prepare_with_pkarr_resolver(&resolver, None) + .await + .map_err(|err| invalid_input(err.to_string()))?; + fetch_viewer_response(&request).await + } } impl Viewer { @@ -408,13 +425,8 @@ async fn fetch_viewer_json_value(request: &JsPreparedViewerRequest) -> JsResult< #[cfg(target_arch = "wasm32")] async fn fetch_viewer_bytes(request: &JsPreparedViewerRequest) -> JsResult { - let response = fetch_viewer(request).await?; - if !response.ok() { - return Err(invalid_input(format!( - "Lock Server viewer request failed with HTTP {}", - response.status() - ))); - } + let response = fetch_viewer_response(request).await?; + let buffer = wasm_bindgen_futures::JsFuture::from( response .array_buffer() @@ -425,6 +437,18 @@ async fn fetch_viewer_bytes(request: &JsPreparedViewerRequest) -> JsResult JsResult { + let response = fetch_viewer(request).await?; + if !response.ok() { + return Err(invalid_input(format!( + "Lock Server viewer request failed with HTTP {}", + response.status() + ))); + } + Ok(response) +} + #[cfg(target_arch = "wasm32")] async fn fetch_viewer(request: &JsPreparedViewerRequest) -> JsResult { use wasm_bindgen::JsCast; diff --git a/locks-sdk/src/creator.rs b/locks-sdk/src/creator.rs index 6edf4cc..529de18 100644 --- a/locks-sdk/src/creator.rs +++ b/locks-sdk/src/creator.rs @@ -1,6 +1,6 @@ pub use locks_core::creator_publishing::{CreateContentLockRequest, SetLockServicePointerRequest}; use percent_encoding::{AsciiSet, CONTROLS, utf8_percent_encode}; -use serde::Serialize; +use serde::{Deserialize, Serialize}; use serde_json::Value; use crate::session::LocksSession; @@ -41,6 +41,21 @@ pub struct SdkRequest { pub enum SdkRequestBody { Json(Value), Bytes(Vec), + Empty, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum PaykitSetupStatusKind { + Ready, + SetupRequired, + Unavailable, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct PaykitSetupStatus { + pub status: PaykitSetupStatusKind, } #[derive(Debug, Clone, PartialEq, Eq)] @@ -119,6 +134,26 @@ impl CreatorLocks { self.set_lock_service_pointer_request(request) } + pub fn paykit_setup_status_request(&self) -> SdkRequest { + SdkRequest { + method: "GET", + path: "/creator/paykit/setup-status".to_owned(), + authorization: self.session.authorization_header_value(), + content_type: String::new(), + body: SdkRequestBody::Empty, + } + } + + pub fn paykit_setup_status(&self) -> SdkRequest { + self.paykit_setup_status_request() + } + + pub fn parse_paykit_setup_status_response(value: Value) -> crate::Result { + serde_json::from_value(value).map_err(|_| { + crate::LocksSdkError::InvalidResponse("invalid paykit setup status response".to_owned()) + }) + } + fn post_json(&self, path: &'static str, body: impl Serialize) -> SdkRequest { SdkRequest { method: "POST", @@ -285,6 +320,45 @@ mod tests { ); } + #[test] + fn paykit_setup_status_request_uses_authenticated_get_without_creator_input() { + let creator = LocksSession::new("frontend-session-secret").creator(); + + let request = creator.paykit_setup_status_request(); + + assert_eq!(request.method, "GET"); + assert_eq!(request.path, "/creator/paykit/setup-status"); + assert_eq!(request.authorization, "Bearer frontend-session-secret"); + assert_eq!(request.content_type, ""); + assert_eq!(request.body, SdkRequestBody::Empty); + } + + #[test] + fn paykit_setup_status_response_accepts_only_closed_statuses() { + for (wire_status, expected) in [ + ("ready", PaykitSetupStatusKind::Ready), + ("setup_required", PaykitSetupStatusKind::SetupRequired), + ("unavailable", PaykitSetupStatusKind::Unavailable), + ] { + assert_eq!( + CreatorLocks::parse_paykit_setup_status_response(json!({ + "status": wire_status + })) + .unwrap(), + PaykitSetupStatus { status: expected } + ); + } + + for invalid in [ + json!({ "status": "future" }), + json!({ "status": "ready", "extra": true }), + json!({}), + json!("ready"), + ] { + assert!(CreatorLocks::parse_paykit_setup_status_response(invalid).is_err()); + } + } + fn guarded_resource() -> GuardedResource { GuardedResource::new( "/priv/locks.app/content/example.txt", diff --git a/locks-sdk/src/lib.rs b/locks-sdk/src/lib.rs index fe931a9..a438270 100644 --- a/locks-sdk/src/lib.rs +++ b/locks-sdk/src/lib.rs @@ -8,8 +8,9 @@ pub mod viewer; pub use client::LocksClient; pub use creator::{ - CreateContentLockRequest, CreatorLocks, DeleteGuardedResourceRequest, - RegisterGuardedResourceRequest, SdkRequest, SdkRequestBody, SetLockServicePointerRequest, + CreateContentLockRequest, CreatorLocks, DeleteGuardedResourceRequest, PaykitSetupStatus, + PaykitSetupStatusKind, RegisterGuardedResourceRequest, SdkRequest, SdkRequestBody, + SetLockServicePointerRequest, }; pub use discovery::{ CreatorLockServicePointer, WellKnownLocksServer, content_lock_resource_url, 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/api/creator_authority.rs b/locks-server/src/api/creator_authority.rs index b29a74d..b4fdd94 100644 --- a/locks-server/src/api/creator_authority.rs +++ b/locks-server/src/api/creator_authority.rs @@ -91,6 +91,7 @@ pub(super) async fn connect_shell_start( .allowed_return_origins; let return_to = validate_return_to_url(&query.return_to, allowed_origins)?; let delivery = ConnectDeliveryMode::from_query(query.delivery.as_deref()); + let callback_state = query.state.clone(); // Guaranteed `Some` because `validate_return_to_url` already parsed the origin. let target_origin = origin_from_return_to(&return_to).ok_or_else(invalid_return_to_url)?; @@ -106,21 +107,12 @@ pub(super) async fn connect_shell_start( ) .await?; - // Local dev only: the shell shows the Pubky authorization URL solely as a QR, which a desktop - // tester without a phone cannot use. Emit the secret-bearing URL to the server log so it can be - // opened directly. Gated to Development so it never lands in a shared staging/production log. - if state.config().runtime.environment.is_development() { - tracing::info!( - authorization_url = response.authorization_url.expose_url(), - "dev: legacy-connect authorization URL" - ); - } - let html = render_connect_shell_html( response.flow_id.as_str(), response.authorization_url.expose_url(), delivery, &target_origin, + &callback_state, ); // Scope framing to this flow's validated return origin — the exact origin that will also @@ -191,6 +183,7 @@ fn render_connect_shell_html( authorization_url: &str, delivery: ConnectDeliveryMode, target_origin: &str, + callback_state: &str, ) -> String { let escaped_flow_id = escape_html(flow_id); let escaped_authorization_url = escape_html(authorization_url); @@ -223,7 +216,7 @@ fn render_connect_shell_html( match delivery { // Embedded in the parent app's modal: render only the QR, transparent, no card/title/close. ConnectDeliveryMode::PostMessage => { - let script = render_postmessage_script(flow_id, target_origin); + let script = render_postmessage_script(flow_id, target_origin, callback_state); format!( r#" @@ -259,19 +252,21 @@ fn render_connect_shell_html( /// approval). Before approval the endpoint is effectively idempotent (the pending flow still /// exists), so transient failures — a dropped connection or a gateway timeout from a proxy that /// capped the idle long-poll — are retried with capped exponential backoff. A definitive error -/// (expired/rejected flow) is surfaced to the parent as `{ type, error }` so the embedder is never +/// (expired/rejected flow) is surfaced as a closed `{ type, state, error }` message so the embedder is never /// left hanging. On success it posts `{ type, state, code }` and stops. -fn render_postmessage_script(flow_id: &str, target_origin: &str) -> String { +fn render_postmessage_script(flow_id: &str, target_origin: &str, callback_state: &str) -> String { let flow_id_js = js_string_literal(flow_id); let target_origin_js = js_string_literal(target_origin); let type_js = js_string_literal(POSTMESSAGE_CALLBACK_TYPE); let resize_type_js = js_string_literal(POSTMESSAGE_RESIZE_TYPE); + let callback_state_js = js_string_literal(callback_state); format!( r#"