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..e0c6b1b 100644 --- a/README.md +++ b/README.md @@ -56,6 +56,20 @@ Verified browser-facing defaults are: - creator demo: - reader demo: +For the opt-in payment-lock demonstration, including Paykit Server, Bitcoin regtest, +and Fulcrum, use the separate Compose definition: + +```bash +docker compose -f compose.paykit-local-demo.yaml up --build +``` + +Its external build contexts use anonymously reachable public repositories pinned to +immutable commits; no sibling Paykit or Pubky checkout is required. The full demo adds +Paykit Server at and publishes the reader at +. Pubky Testnet is built from `pubky/pubky-core` source at +commit `75eb1324f86e8caa16c41f18a2cd6b8e1909ee7b`, not from a released Pubky image or +version. Payment remains a manual operator action. + ## Documentation - [Lock Server API](docs/API.md) diff --git a/compose.paykit-local-demo.yaml b/compose.paykit-local-demo.yaml new file mode 100644 index 0000000..0d5ac90 --- /dev/null +++ b/compose.paykit-local-demo.yaml @@ -0,0 +1,400 @@ +# 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 + 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 + + 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_HOMESERVER_ADMIN_PORT:-6288}:6288" + - "127.0.0.1:${LOCKS_SERVER_PORT:-3000}:3000" + - "127.0.0.1:${LOCKS_PAYKIT_PORT:-3001}:3001" + - "127.0.0.1:${LOCKS_CREATOR_DEMO_PORT:-8080}:8080" + - "127.0.0.1:${LOCKS_READER_DEMO_PORT:-8088}:8081" + 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 + + 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#f38c7915e6b9b104e040773e78438f8aa984c46c" + dockerfile: Dockerfile.local + additional_contexts: + paykit-lib: "https://github.com/pubky/paykit-rs.git#52a852995bfc457b78d32f5a45f6741766a89bba:paykit-lib" + paykit-sdk: "https://github.com/pubky/paykit-rs.git#52a852995bfc457b78d32f5a45f6741766a89bba:paykit-sdk" + 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://localhost:15412 + LOCKS_INTERNAL_PKARR_RELAY: http://localhost: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: + - ./locks-sdk/bindings/js/pkg:/workspace/locks-sdk/bindings/js/pkg:ro + - ./.local/demo-config:/workspace/.local/demo-config:ro + - ./.local/js-sdk-demo:/workspace/.local/js-sdk-demo + - ./.local/content-creator:/workspace/.local/content-creator + - ./.local/creator-public:/workspace/.local/creator-public + command: + - sh + - -lc + - | + set -eu + npm --prefix examples/js-sdk run create-user -- --role content-creator + npm --prefix examples/js-sdk run publish-creator-profile + npm --prefix examples/js-sdk run start-server -- --allow-unhealthy + + 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://localhost:15412 + LOCKS_INTERNAL_PKARR_RELAY: http://localhost: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" + PUBKY_LOCK_DEBUG: ${PUBKY_LOCK_DEBUG:-0} + volumes: + - ./locks-sdk/bindings/js/pkg:/workspace/locks-sdk/bindings/js/pkg:ro + - ./.local/demo-config:/workspace/.local/demo-config:ro + - ./.local/creator-public:/workspace/.local/creator-public:ro + - ./.local/content-viewer:/workspace/.local/content-viewer + - ./.local/paykit-reader:/workspace/.local/paykit-reader + command: + - sh + - -euc + - | + npm --prefix examples/js-sdk run create-user -- --role content-viewer + exec node examples/js-sdk/scripts/start-reader-demo-server.mjs --allow-unhealthy + healthcheck: + test: + - CMD + - node + - -e + - fetch('http://127.0.0.1:8081/api/paykit-reader/status').then((response) => { if (!response.ok) process.exit(1); }).catch(() => process.exit(1)) + 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..2ac9475 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" 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..c2d6ce6 --- /dev/null +++ b/docker/js-demo.Dockerfile @@ -0,0 +1,17 @@ +# syntax=docker/dockerfile:1.7@sha256:a57df69d0ea827fb7266491f2813635de6f17269be881f696fbfdf2d83dda33e +FROM paykit-runtime AS paykit-runtime + +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=paykit-runtime /usr/local/bin/paykit-companion-auth /usr/local/bin/paykit-companion-auth +COPY --from=paykit-runtime /usr/local/bin/paykit-reader-demo /usr/local/bin/paykit-reader-demo +RUN mkdir -p /workspace/locks-sdk/bindings/js/pkg /workspace/.local \ + && 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..c7b015c 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" < 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/examples/js-sdk/README.md b/examples/js-sdk/README.md index 99d36ec..a019561 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. @@ -17,6 +17,11 @@ examples/js-sdk/reader-flow.js examples/js-sdk/scripts/init-config.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 +29,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 +37,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 @@ -69,14 +81,11 @@ 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` +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. -### Docker Compose local stack +### Basic Locks stack For a containerized local stack from the repository root: @@ -116,161 +125,90 @@ docker compose exec creator-demo npm --prefix examples/js-sdk run create-user -- The browser-facing demo config still uses `localhost`; container-internal health checks/auth use 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://localhost:15411 +HTTP/auth relay = http://localhost:15412 +Pubky Auth inbox = http://localhost:15412/inbox/ +DHT bootstrap = localhost:6881 +Paykit browser = http://localhost: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 - -Use whatever local Postgres you normally use. The Lock Server reads its URL from `PUBKY_LOCK_DATABASE_URL`; include the database name explicitly: - -```bash -export PUBKY_LOCK_DATABASE_URL='postgres://locks:locks@localhost:55433/locks_test' -``` +## Setup -A quick readiness check: +Initialize JS demo config: ```bash -psql "$PUBKY_LOCK_DATABASE_URL" -c 'select 1;' -``` - -If you use a different local database/user/port, keep the same environment variable name and update only the URL value. - -### 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 +npm --prefix examples/js-sdk run init-config ``` -Generate a local creator-authority encryption key for the same shell that starts the Lock Server: +Create the content creator signing keypair: ```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 ``` -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`: +The dev-static reader flow does not need a `content-viewer` keypair. The Paykit reader flow does: ```bash -cargo run -p locks-server +npm --prefix examples/js-sdk run create-user -- --role content-viewer ``` -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 - -[creator_authority_acquisition.legacy_connect] -allowed_return_origins = ["http://localhost:8080"] +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. -[pubky] -network = "testnet" - -[pkdns] -icann_domain = "localhost" -public_icann_http_port = 3000 -pkarr_relays = ["http://localhost:15411"] -``` - -Keep the generated or derived `credentials.lock_server_public_key` value in that file. If it is still: +Existing keypairs are reused. To regenerate one role: -```toml -lock_server_public_key = "" +```bash +npm --prefix examples/js-sdk run create-user -- --role content-creator --force ``` -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`. +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. -### 4. Start Lock Server +## Run the demo server -```bash -cargo run -p locks-server -``` +### Complete local Compose stack -Wait for these checks to pass: +Build and start the complete stack: ```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 +docker compose -f compose.paykit-local-demo.yaml up --build ``` -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.`. - -## 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 -f 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, Locks, Paykit Server, and both browser demos. All published ports bind to host loopback. Paykit is browser-visible at `http://localhost:3001`; Locks reaches it at `http://127.0.0.1:3001` inside the shared Pubky network namespace. The unprivileged creator and reader images contain the reviewed native helpers at `/usr/local/bin`; they receive only their explicit role/runtime directories and generated WASM package, never the repository root or Lock Server identity volume. -These values are written to: +Open: ```text -./.local/js-sdk-demo/config.json +Creator: http://localhost:8080/examples/js-sdk/ +Reader: http://localhost:8088/reader/ +Paykit: http://localhost:3001/setup ``` -## Setup - -Initialize JS demo config: - -```bash -npm --prefix examples/js-sdk run init-config -``` - -Create the content creator signing keypair: +The Compose reader process still listens on container port `8081`; only its host mapping is `8088`. To remove the four explicit disposable database/Bitcoin/Fulcrum volumes, empty bootstrap scratch directory, and encrypted reader-helper state while preserving generated credentials/config, role identities, and Lock Server identity: ```bash -npm --prefix examples/js-sdk run 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 -f 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 @@ -351,9 +289,9 @@ 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 @@ -367,7 +305,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 +322,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://localhost: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. Then run the companion-auth wrapper: + +```bash +docker compose -f compose.paykit-local-demo.yaml exec creator-demo npm --prefix examples/js-sdk run authenticate-paykit -- --role content-creator +``` + +The command loads the existing encrypted content-creator recovery file and starts `/usr/local/bin/paykit-companion-auth` directly with no arguments. `PAYKIT_COMPANION_AUTH_BIN` may override that executable path for local testing. Interactive input prompts for the Paykit auth URL, account xpub/tpub, and account index. Non-TTY stdin is exactly those three ordered lines, with one optional final newline. Sensitive inputs are sent only through the helper's stdin and are never forwarded in wrapper output. The browser uses the Locks JS/WASM SDK for publishing: @@ -405,11 +362,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,6 +375,20 @@ 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**. +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. Bundle IDs and access credentials are bearer-like local-dev secrets; the demo displays them for debugging only. ## Static drift check @@ -432,8 +403,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..65174e8 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 = 'docker compose -f compose.paykit-local-demo.yaml exec creator-demo 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() { @@ -193,6 +317,7 @@ async function bootstrap() { }); await refreshDemoAuthStatus(); refreshLockAuthStatus(); + refreshLockTypeFields(); refreshPublishingState(); setInterval(refreshDemoAuthStatus, 2000); } @@ -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) { 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

diff --git a/examples/js-sdk/scripts/authenticate-paykit.mjs b/examples/js-sdk/scripts/authenticate-paykit.mjs new file mode 100644 index 0000000..b42323b --- /dev/null +++ b/examples/js-sdk/scripts/authenticate-paykit.mjs @@ -0,0 +1,327 @@ +#!/usr/bin/env node +import { spawn } from 'node:child_process'; +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 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 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 = process.env.PAYKIT_COMPANION_AUTH_BIN || DEFAULT_HELPER_PATH, + 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/init-config.mjs b/examples/js-sdk/scripts/init-config.mjs index 2c2655a..735f292 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. +// Defaults: read lock_server_public_key from ~/.pubky-lock/config.toml and write ./.local/demo-config/config.json. // Local testnet defaults: http://localhost:15411, http://localhost:15412, localhost:6881. 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..22c0751 --- /dev/null +++ b/examples/js-sdk/scripts/init-paykit-compose.mjs @@ -0,0 +1,178 @@ +#!/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'), + }; +} + +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', + ].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, + })), + ]); + + 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..3255d0d 100644 --- a/examples/js-sdk/scripts/lib/config.mjs +++ b/examples/js-sdk/scripts/lib/config.mjs @@ -16,6 +16,9 @@ export const defaultDemoConfig = { pubky: '', configPath: defaultLockServerConfigPath, }, + paykit: { + url: 'http://localhost:3001', + }, testnet: { homeserver: 'pubky8pinxxgqs41n4aididenw5apqp1urfmzdztr8jt4abrkdn435ewo', httpRelay: 'http://localhost:15412', @@ -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://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..458a49c --- /dev/null +++ b/examples/js-sdk/scripts/lib/creator-session-state.mjs @@ -0,0 +1,66 @@ +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 = await readJson(profilePath); + if (!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 = await readJson(profilePath); + if (!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 = await readJson(profilePath); + if (!creatorIdentitiesMatch(session, profileAfterWrite)) { + await clearCreatorDemoSession(sessionPath); + throw new Error('creator identity changed during demo authentication'); + } +} + +function creatorIdentitiesMatch(session, profile) { + return session?.role === 'content-creator' + && profile?.role === 'content-creator' + && typeof session.pubky === 'string' + && session.pubky === profile.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..09420fb --- /dev/null +++ b/examples/js-sdk/scripts/lib/paykit-reader-helper.mjs @@ -0,0 +1,385 @@ +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 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 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, + payment_command: `${COMPOSE_COMMAND}${value.payment_command.slice('docker compose'.length)}`, + optional_mining_command: `${COMPOSE_COMMAND}${value.optional_mining_command.slice('docker compose'.length)}`, + }; +} + +export function validateReaderOperatorResult(value) { + const commandPrefix = `${COMPOSE_COMMAND} exec`; + if ( + !value + || typeof value.payment_command !== 'string' + || typeof value.optional_mining_command !== 'string' + || !value.payment_command.startsWith(commandPrefix) + || !value.optional_mining_command.startsWith(commandPrefix) + ) { + throw new Error('invalid reader helper output'); + } + parseReaderHelperSuccess({ + operation: 'receive', + stdout: `${JSON.stringify({ + ...value, + payment_command: `docker compose${value.payment_command.slice(COMPOSE_COMMAND.length)}`, + optional_mining_command: `docker compose${value.optional_mining_command.slice(COMPOSE_COMMAND.length)}`, + })}\n`, + }); + return value; +} + +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 resolveReaderEnvironment({ + env = process.env, + loadProfile = loadRoleProfile, +} = {}) { + const resolved = { ...env }; + if (!resolved.PAYKIT_READER_SERVER_PUBKY) { + const profile = resolved.PAYKIT_READER_CREATOR_PROFILE_PATH + ? await readJson(resolved.PAYKIT_READER_CREATOR_PROFILE_PATH).catch(() => undefined) + : await loadProfile('content-creator').catch(() => undefined); + 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..49da7e4 --- /dev/null +++ b/examples/js-sdk/scripts/lib/paykit-reader-status.mjs @@ -0,0 +1,178 @@ +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' + && 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 } = {}) { + 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..d84ea6a --- /dev/null +++ b/examples/js-sdk/scripts/lib/paykit-reader-worker.mjs @@ -0,0 +1,335 @@ +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 } = {}, +) { + await mkdir(dirname(path), { recursive: true, mode: 0o700 }); + await chmod(dirname(path), 0o700); + const handle = await open(path, 'a', 0o600); + await handle.close(); + await chmod(path, 0o600); + const metadata = await lstat(path); + if (!metadata.isFile() || (metadata.mode & 0o077) !== 0) { + throw new Error('Paykit reader ownership lock permissions are unsafe'); + } + + 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 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..68d9743 100644 --- a/examples/js-sdk/scripts/lib/pubky.mjs +++ b/examples/js-sdk/scripts/lib/pubky.mjs @@ -26,6 +26,25 @@ export async function loadRoleKeypair(role) { 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) { + const passphrase = (await readFile(rolePassphrasePath(role), 'utf8')).trim(); + const recoveryFile = await readFile(roleRecoveryFilePath(role)); + return secretFromRecoveryFile(recoveryFile, passphrase); +} + export async function loadRoleProfile(role) { return readJson(new URL(`../../../../.local/${role}/profile.json`, import.meta.url).pathname); } 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..884151a --- /dev/null +++ b/examples/js-sdk/scripts/publish-creator-profile.mjs @@ -0,0 +1,37 @@ +#!/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, +} = {}) { + const profile = await readJson(source); + if (profile?.role !== 'content-creator' || !CANONICAL_PUBKY.test(profile.pubky ?? '')) { + throw new Error('valid content-creator profile is required'); + } + const publicProfile = Object.freeze({ role: 'content-creator', pubky: profile.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..c459218 --- /dev/null +++ b/examples/js-sdk/scripts/smoke-paykit-compose.mjs @@ -0,0 +1,373 @@ +#!/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 { 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.doesNotMatch(creatorServerSource, /JSON\.stringify\(entry\)|url\.search/); +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://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', + ]; + 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', + ]) { + 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, + }); + 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); +} 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:', + '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#f38c7915e6b9b104e040773e78438f8aa984c46c', + 'https://github.com/pubky/paykit-rs.git#52a852995bfc457b78d32f5a45f6741766a89bba:paykit-lib', + 'https://github.com/pubky/paykit-rs.git#52a852995bfc457b78d32f5a45f6741766a89bba:paykit-sdk', + 'https://github.com/pubky/locks.git#df5ea1b6d8dcdec3a9b5a915c3f57bca69d75c8a', + '127.0.0.1:${LOCKS_PAYKIT_PORT:-3001}:3001', + '127.0.0.1:${LOCKS_READER_DEMO_PORT:-8088}:8081', + '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', + '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 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('healthcheck:'), 'reader must expose worker-aware Compose health'); +assert.ok(readerService.includes('http://127.0.0.1:8081/api/paykit-reader/status'), 'reader health must use the closed worker status endpoint'); +assert.ok(readerService.includes('restart: unless-stopped'), 'reader worker must have an explicit restart policy'); +assert.ok(!compose.includes('POSTGRES_PASSWORD: locks'), 'database credentials must not be committed inline'); +assert.ok(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..9672471 100644 --- a/examples/js-sdk/scripts/start-demo-server.mjs +++ b/examples/js-sdk/scripts/start-demo-server.mjs @@ -1,12 +1,16 @@ #!/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'; const args = parseArgs(); const allowUnhealthy = Boolean(args['allow-unhealthy']); @@ -49,8 +53,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 +70,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 +80,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); } }); @@ -96,19 +105,23 @@ async function startDemoAuth() { demoAuthPromise = activeDemoAuthFlow .awaitApproval() .then(async (session) => { - await writeJson(contentCreatorSessionPath, { + await writeCreatorDemoSessionForCurrentRole({ role: 'content-creator', pubky: session.info.publicKey.toString(), capabilities: session.info.capabilities, exported_session: session.export(), authenticated_at: new Date().toISOString(), }); - activeDemoAuthFlow = null; return session; }) .catch((error) => { + console.error('demo auth failed'); + }) + .finally(() => { activeDemoAuthFlow = null; - console.error(`demo auth failed: ${error.message}`); + activeDemoAuthUrl = null; + activeDemoAuthStartedAt = null; + demoAuthPromise = null; }); } return { @@ -121,8 +134,8 @@ async function startDemoAuth() { } async function demoAuthStatus() { - if (existsSync(contentCreatorSessionPath)) { - const session = JSON.parse(await readFile(contentCreatorSessionPath, 'utf8')); + const session = await readCreatorDemoSessionForCurrentRole(); + 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 +157,7 @@ async function demoAuthStatus() { } async function hasPersistedDemoSession() { - return existsSync(contentCreatorSessionPath); + return Boolean(await readCreatorDemoSessionForCurrentRole()); } function publicBrowserConfig(source) { @@ -152,6 +165,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 +177,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 +214,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) { @@ -243,27 +257,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..e5febc8 100644 --- a/examples/js-sdk/scripts/start-reader-demo-server.mjs +++ b/examples/js-sdk/scripts/start-reader-demo-server.mjs @@ -1,10 +1,20 @@ #!/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 { + runPaykitReaderWorker, + supervisePaykitReaderWorker, +} 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; @@ -14,6 +24,9 @@ const readerUrl = new URL(config.demoServer.url); readerUrl.port = String(args.port ?? 8081); const readerServerUrl = readerUrl.toString().replace(/\/$/, ''); const preflightStatus = await runPreflight(serviceConfig); +const workerEnabled = process.env.PAYKIT_READER_WORKER_ENABLED === '1'; +const workerController = new AbortController(); +let workerOwnsState = false; logStartupDiagnostics(config, preflightStatus); @@ -34,6 +47,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 +59,20 @@ 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, + }); + 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 +83,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 +94,20 @@ server.listen(Number(readerUrl.port), () => { console.log(`Open ${readerServerUrl}/reader/`); }); +const workerTask = workerEnabled + ? supervisePaykitReaderWorker( + runPaykitReaderWorker({ + signal: workerController.signal, + writeWorkerStatus: writePaykitReaderWorkerStatus, + onOwnershipChange: (owned) => { workerOwnsState = owned; }, + }), + { 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 +118,44 @@ function publicBrowserConfig(source) { }; } +export async function publicPaykitReaderStatus({ + readWorker = readPaykitReaderWorkerStatus, + readProfile = () => readJson(roleProfilePath('content-viewer')), + currentOwner = false, +} = {}) { + const [worker, profile] = await Promise.all([ + Promise.resolve().then(readWorker).catch(() => null), + Promise.resolve().then(readProfile).catch(() => null), + ]); + return buildPaykitReaderBrowserStatus(worker, profile, { currentOwner }); +} + +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(), @@ -115,11 +194,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 +233,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..b76e93d --- /dev/null +++ b/examples/js-sdk/scripts/test-paykit-reader-worker.mjs @@ -0,0 +1,410 @@ +#!/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, +} 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=\"$BITCOIN_DATA/bitcoin.conf\" -regtest -rpcwallet=miner sendtoaddress \"bcrt1qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqdku202\" \"0.00050000\"'", + optional_mining_command: "docker compose --file ./compose.paykit-local-demo.yaml exec -T bitcoin sh -ec 'bitcoin-cli -conf=\"$BITCOIN_DATA/bitcoin.conf\" -regtest -rpcwallet=miner generatetoaddress 6 \"$(bitcoin-cli -conf=\"$BITCOIN_DATA/bitcoin.conf\" -regtest -rpcwallet=miner getnewaddress)\"'", +}; + +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', +}); + +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' }, + ); +} 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'); \ No newline at end of file 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..d4b8fd8 --- /dev/null +++ b/examples/js-sdk/scripts/validate-paykit-compose.mjs @@ -0,0 +1,146 @@ +#!/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', + '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', + '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 port of model.services['pubky-testnet'].ports ?? []) { + if (port.host_ip !== '127.0.0.1') throw new Error('published demo ports must bind to loopback'); + } + 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("dev-static', '', 'id="dev-static-fields"', 'id="paykit-payment-fields" hidden', 'id="paykit-amount-sats"', 'id="paykit-setup-status"', 'id="retry-paykit-setup"'], + iframe: ['iframe modal', 'id="demo-auth"', 'id="creator-publishing"', '/examples/js-sdk/app-iframe.js', 'id="lock-type"', '', '', 'id="dev-static-fields"', 'id="paykit-payment-fields" hidden', 'id="paykit-amount-sats"', 'id="paykit-setup-status"', 'id="retry-paykit-setup"'], + flows: ['Both creator pages use iframe auth', '/examples/js-sdk/', '/examples/js-sdk/iframe.html'], + readerHtml: ['id="content-lock-resource"', 'id="lock-resources"', 'id="primary-resource-list"', 'id="secondary-resource-list"', 'id="reset-reader-state"', 'id="read-content"', 'id="reader-public-key" readonly', 'id="refresh-paykit-reader"', 'id="paykit-reader-status"', 'id="paykit-reader-payment"', 'id="paykit-reader-commands"', 'id="poll-payment"', 'paykit-payment', 'Paykit reader worker runs automatically', '/reader-app.js'], + initConfig: ['~/.pubky-lock/config.toml', './.local/demo-config/config.json', 'lock_server_public_key', 'http://localhost:15411', 'http://localhost:15412', 'localhost:6881'], + 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', + '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')", + ], + 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'", '8081', 'never proxy'], + pathsLib: ['localPath', 'roleDir', 'demoConfigPath', 'contentCreatorSessionPath', 'paykitReaderPreparedPath', 'paykitReaderOwnershipPath', 'prepared.v1.json', 'owner.lock'], + configLib: ['readDemoConfig', 'writeDemoConfig', 'parseLockServerTomlPublicKey', 'pubkyAuthRelayInboxUrl', 'validateDemoConfig', "url: 'http://localhost:3001'", "['paykit', 'url']"], + 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 +236,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 +266,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 +285,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 +320,1050 @@ for (const [label, snippets] of Object.entries(required)) { } } +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'); +try { + writeFileSync(creatorSessionTestPath, '{"exported_session":"sensitive"}'); + await clearCreatorDemoSession(creatorSessionTestPath); + assert.equal(existsSync(creatorSessionTestPath), false); + await clearCreatorDemoSession(creatorSessionTestPath); + + writeFileSync(creatorProfileTestPath, JSON.stringify({ role: 'content-creator', pubky: 'creator-b' })); + writeFileSync(creatorSessionTestPath, JSON.stringify({ role: 'content-creator', pubky: 'creator-a', exported_session: 'old-secret' })); + assert.equal(await readCreatorDemoSessionForCurrentRole({ + sessionPath: creatorSessionTestPath, + profilePath: creatorProfileTestPath, + }), null); + assert.equal(existsSync(creatorSessionTestPath), false); + + await assert.rejects( + writeCreatorDemoSessionForCurrentRole( + { role: 'content-creator', pubky: 'creator-a', exported_session: 'late-old-secret' }, + { sessionPath: creatorSessionTestPath, profilePath: creatorProfileTestPath }, + ), + /creator identity changed during demo authentication/, + ); + assert.equal(existsSync(creatorSessionTestPath), false); + + const currentSession = { role: 'content-creator', pubky: 'creator-b', exported_session: 'current-secret' }; + await writeCreatorDemoSessionForCurrentRole(currentSession, { + sessionPath: creatorSessionTestPath, + profilePath: creatorProfileTestPath, + }); + assert.deepEqual( + await readCreatorDemoSessionForCurrentRole({ + sessionPath: creatorSessionTestPath, + profilePath: creatorProfileTestPath, + }), + currentSession, + ); + assert.equal(statSync(creatorSessionTestPath).mode & 0o777, 0o600); +} 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.text, 'payment unlocked'); +assert.equal(decodedGuarded.size, 16); + +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, + payment_command: receivedOutput.payment_command.replace('docker compose', 'docker compose --file ./compose.paykit-local-demo.yaml'), + optional_mining_command: receivedOutput.optional_mining_command.replace('docker compose', 'docker compose --file ./compose.paykit-local-demo.yaml'), +}; +assert.deepEqual(parseReaderHelperSuccess({ + operation: 'receive', + stdout: `${JSON.stringify(receivedOutput)}\n`, +}), operatorReceivedOutput); +for (const invalid of [ + `${JSON.stringify({ ...receivedOutput, extra: true })}\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, + runCompanionHelper, +} = await import(pathToFileURL(files.authenticatePaykit).href); +const { Keypair, secretFromRecoveryFile } = await import(pathToFileURL(files.pubkyLib).href); + +const authUrl = 'pubkyauth://signin?secret=test-auth-secret'; +const accountXpub = 'tpub-test-account-xpub'; +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..8106350 100644 --- a/locks-sdk/bindings/js/scripts/smoke-generated-api.mjs +++ b/locks-sdk/bindings/js/scripts/smoke-generated-api.mjs @@ -60,6 +60,7 @@ const requiredSnippets = [ 'lookupVerificationTask(options: VerificationTaskHandleOptions): Promise;', 'issueAccessCredential(options: VerificationTaskHandleOptions): Promise;', 'proxyReadGuardedResource(access_credential: string, path: string): Promise;', + 'proxyReadGuardedResourceResponse(access_credential: string, path: string): Promise;', 'export class VerificationTaskHandleOptions', 'constructor(creator: string, bundle_id: string);', 'export class Session', diff --git a/locks-sdk/bindings/js/src/locks.rs b/locks-sdk/bindings/js/src/locks.rs index 3788884..4c3f7b2 100644 --- a/locks-sdk/bindings/js/src/locks.rs +++ b/locks-sdk/bindings/js/src/locks.rs @@ -343,11 +343,12 @@ impl Locks { .prepare_exchange_request_with_pkarr_resolver(&request, &resolver, None) .await .map_err(|err| invalid_input(err.to_string()))?; - let token = post_json_for_session_token(&request).await?; - Ok(Session::new( - self.inner.restore_session(token), + let response = post_json_for_session(&request).await?; + Ok(Session::new_with_creator( + self.inner.restore_session(&response.session_token), self.inner.clone(), self.options.clone(), + Some(response.creator.to_string()), )) } } @@ -684,7 +685,9 @@ async fn fetch_content_lock_json(request_plan: &JsPreparedContentLockRequest) -> } #[cfg(target_arch = "wasm32")] -async fn post_json_for_session_token(request_plan: &JsPreparedRequest) -> JsResult { +async fn post_json_for_session( + request_plan: &JsPreparedRequest, +) -> JsResult { let request_init = web_sys::RequestInit::new(); request_init.set_method(request_plan.method); request_init.set_mode(web_sys::RequestMode::Cors); @@ -725,17 +728,68 @@ async fn post_json_for_session_token(request_plan: &JsPreparedRequest) -> JsResu .map_err(|err| invalid_input(format!("failed to parse JSON response: {err:?}")))?; let value: Value = serde_wasm_bindgen::from_value(json) .map_err(|err| invalid_input(format!("invalid session response JSON: {err}")))?; - value + parse_frontend_session_response(value).map_err(invalid_input) +} + +#[cfg(any(test, target_arch = "wasm32"))] +#[derive(Debug, Clone, PartialEq, Eq)] +struct FrontendSessionResponse { + session_token: String, + creator: CreatorPubky, +} + +#[cfg(any(test, target_arch = "wasm32"))] +fn parse_frontend_session_response(value: Value) -> Result { + let session_token = value .get("session_token") .and_then(Value::as_str) .map(ToOwned::to_owned) - .ok_or_else(|| invalid_input("frontend session response missing session_token")) + .ok_or_else(|| "frontend session response missing session_token".to_owned())?; + let creator = value + .get("creator") + .and_then(Value::as_str) + .ok_or_else(|| "frontend session response missing creator".to_owned()) + .and_then(|creator| { + CreatorPubky::from_str(creator) + .map_err(|_| "frontend session response contains invalid creator".to_owned()) + })?; + Ok(FrontendSessionResponse { + session_token, + creator, + }) } #[cfg(test)] mod tests { use super::*; + #[test] + fn frontend_session_response_preserves_authenticated_creator() { + let response = parse_frontend_session_response(serde_json::json!({ + "session_token": "frontend-session-secret", + "creator": "pubkytkrq8zmwb8a3m9k15csu3q17qmfgqnp9dskbrg9uq1rydpyxp7qy", + "expires_at": "2030-01-01T00:00:00Z" + })) + .unwrap(); + + assert_eq!(response.session_token, "frontend-session-secret"); + assert_eq!( + response.creator.to_string(), + "pubkytkrq8zmwb8a3m9k15csu3q17qmfgqnp9dskbrg9uq1rydpyxp7qy" + ); + } + + #[test] + fn frontend_session_response_rejects_missing_creator() { + let error = parse_frontend_session_response(serde_json::json!({ + "session_token": "frontend-session-secret", + "expires_at": "2030-01-01T00:00:00Z" + })) + .unwrap_err(); + + assert!(error.contains("missing creator")); + } + #[test] fn locks_constructor_is_available_for_valid_lock_server_pubky() { let locks = diff --git a/locks-sdk/bindings/js/src/session.rs b/locks-sdk/bindings/js/src/session.rs index ac68fb7..575adad 100644 --- a/locks-sdk/bindings/js/src/session.rs +++ b/locks-sdk/bindings/js/src/session.rs @@ -240,6 +240,7 @@ impl JsAuthorizedRequestPlan { pub struct Session { inner: locks_sdk::LocksSession, client: locks_sdk::LocksClient, + creator_pubky: Option, #[cfg_attr(not(any(test, target_arch = "wasm32")), allow(dead_code))] options: LocksOptions, } @@ -256,6 +257,11 @@ impl Session { self.client.lock_server().to_string() } + #[wasm_bindgen(js_name = creatorPubky)] + pub fn creator_pubky(&self) -> Option { + self.creator_pubky.clone() + } + #[wasm_bindgen(getter)] pub fn creator(&self) -> Creator { Creator::new(self.clone()) @@ -279,10 +285,20 @@ impl Session { inner: locks_sdk::LocksSession, client: locks_sdk::LocksClient, options: LocksOptions, + ) -> Self { + Self::new_with_creator(inner, client, options, None) + } + + pub(crate) fn new_with_creator( + inner: locks_sdk::LocksSession, + client: locks_sdk::LocksClient, + options: LocksOptions, + creator_pubky: Option, ) -> Self { Self { inner, client, + creator_pubky, options, } } @@ -510,6 +526,20 @@ mod tests { ); } + #[test] + fn exchanged_session_exposes_authenticated_creator_pubky() { + let client = test_client(); + let creator = "pubkytkrq8zmwb8a3m9k15csu3q17qmfgqnp9dskbrg9uq1rydpyxp7qy"; + let session = Session::new_with_creator( + client.restore_session("frontend-session-secret"), + client, + LocksOptions::new(), + Some(creator.to_owned()), + ); + + assert_eq!(session.creator_pubky(), Some(creator.to_owned())); + } + #[test] fn signout_request_uses_current_frontend_session_endpoint_and_bearer() { let client = test_client(); diff --git a/locks-sdk/bindings/js/src/viewer.rs b/locks-sdk/bindings/js/src/viewer.rs index e7afb7d..a9cab03 100644 --- a/locks-sdk/bindings/js/src/viewer.rs +++ b/locks-sdk/bindings/js/src/viewer.rs @@ -210,6 +210,23 @@ impl Viewer { .map_err(|err| invalid_input(err.to_string()))?; fetch_viewer_bytes(&request).await } + + #[cfg(target_arch = "wasm32")] + #[wasm_bindgen(js_name = proxyReadGuardedResourceResponse)] + pub async fn proxy_read_guarded_resource_response( + &self, + access_credential: &str, + path: String, + ) -> JsResult { + let resolver = BrowserPkarrResolver::new_with_options(&self.options) + .map_err(|err| invalid_input(err.to_string()))?; + let request = self + .build_proxy_read_guarded_resource_request(access_credential, path) + .prepare_with_pkarr_resolver(&resolver, None) + .await + .map_err(|err| invalid_input(err.to_string()))?; + fetch_viewer_response(&request).await + } } impl Viewer { @@ -408,13 +425,8 @@ async fn fetch_viewer_json_value(request: &JsPreparedViewerRequest) -> JsResult< #[cfg(target_arch = "wasm32")] async fn fetch_viewer_bytes(request: &JsPreparedViewerRequest) -> JsResult { - let response = fetch_viewer(request).await?; - if !response.ok() { - return Err(invalid_input(format!( - "Lock Server viewer request failed with HTTP {}", - response.status() - ))); - } + let response = fetch_viewer_response(request).await?; + let buffer = wasm_bindgen_futures::JsFuture::from( response .array_buffer() @@ -425,6 +437,18 @@ async fn fetch_viewer_bytes(request: &JsPreparedViewerRequest) -> JsResult JsResult { + let response = fetch_viewer(request).await?; + if !response.ok() { + return Err(invalid_input(format!( + "Lock Server viewer request failed with HTTP {}", + response.status() + ))); + } + Ok(response) +} + #[cfg(target_arch = "wasm32")] async fn fetch_viewer(request: &JsPreparedViewerRequest) -> JsResult { use wasm_bindgen::JsCast; diff --git a/locks-server/src/api/creator_authority.rs b/locks-server/src/api/creator_authority.rs index 60968e6..aea5b79 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 @@ -186,6 +178,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); @@ -218,7 +211,7 @@ fn render_connect_shell_html( match delivery { // Embedded in the parent app's modal: render only the QR, transparent, no card/title/close. ConnectDeliveryMode::PostMessage => { - let script = render_postmessage_script(flow_id, target_origin); + let script = render_postmessage_script(flow_id, target_origin, callback_state); format!( r#" @@ -254,19 +247,21 @@ fn render_connect_shell_html( /// approval). Before approval the endpoint is effectively idempotent (the pending flow still /// exists), so transient failures — a dropped connection or a gateway timeout from a proxy that /// capped the idle long-poll — are retried with capped exponential backoff. A definitive error -/// (expired/rejected flow) is surfaced to the parent as `{ type, error }` so the embedder is never +/// (expired/rejected flow) is surfaced as a closed `{ type, state, error }` message so the embedder is never /// left hanging. On success it posts `{ type, state, code }` and stops. -fn render_postmessage_script(flow_id: &str, target_origin: &str) -> String { +fn render_postmessage_script(flow_id: &str, target_origin: &str, callback_state: &str) -> String { let flow_id_js = js_string_literal(flow_id); let target_origin_js = js_string_literal(target_origin); let type_js = js_string_literal(POSTMESSAGE_CALLBACK_TYPE); let resize_type_js = js_string_literal(POSTMESSAGE_RESIZE_TYPE); + let callback_state_js = js_string_literal(callback_state); format!( r#"